Compare commits

..

1 Commits

Author SHA1 Message Date
Alex Knight
bd5cf54f75 fix(plugin-sdk): export approval handler runtime event types 2026-06-18 13:00:25 +10:00
4852 changed files with 74090 additions and 255242 deletions

View File

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

View File

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

View File

@@ -12,10 +12,10 @@ content, ordering, grouping, and attribution discipline.
## Goal
Rebuild the target `CHANGELOG.md` version section from a complete, generated
history manifest, not stale draft notes. Produce grouped user-facing release
notes sorted by user interest while preserving every relevant issue/PR ref and
every human `Thanks @...` attribution.
Rewrite the target `CHANGELOG.md` version section from history, not from stale
draft notes. Produce grouped user-facing release notes sorted by user interest
while preserving every relevant issue/PR ref and every human `Thanks @...`
attribution.
## Inputs
@@ -34,37 +34,8 @@ every human `Thanks @...` attribution.
- `git log --first-parent --date=iso-strict --pretty=format:'%h%x09%ad%x09%s' <base-tag>..<target-ref>`
- `git log --first-parent --grep='(#' --date=short --pretty=format:'%h%x09%ad%x09%s' <base-tag>..<target-ref>`
- also inspect `--since='24 hours ago'` when main moved during the release.
3. Generate the complete contribution record and editorial manifest before
writing grouped prose:
```bash
node .agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs \
--base <base-tag> \
--target <target-ref> \
--version <YYYY.M.PATCH> \
--manifest /tmp/openclaw-release-<YYYY.M.PATCH>.json \
--write-ledger
```
- the manifest is the required input to the rewrite, not an after-the-fact
audit; it contains every referenced PR, eligible contributor credit,
inline issue context, every direct commit, and an editorial-eligibility
classification for PRs and direct commits
- for a historical backfill, add `--seed-ref <pre-backfill-ref>` once so
contribution records from the prior changelog are retained even when an
older merged commit omitted its PR number; the verifier excludes records
for work reverted after the base tag, including beta work reverted before
the stable release
- source PR discovery combines merged GitHub commit associations with merged
PR references explicitly present in active commit subjects/bodies so
cherry-picks and squash commits remain accounted for. Resolve every
association page and exclude PRs merged after the target release commit
- read the manifest before editing `### Highlights`, `### Changes`, or
`### Fixes`; do not carry old grouped prose forward without re-auditing it
- inspect linked PRs/issues or diffs for ambiguous commits. Direct commits
are editorial input, not public ledger rows; infer material user outcomes
from subject, body, touched files, tests, and nearby commits
3. Read linked PRs/issues or diffs for ambiguous commits. Direct commits matter;
infer notes from subject, body, touched files, tests, and nearby commits.
4. Rewrite one stable-base section only:
- use `## YYYY.M.PATCH`
- do not create beta-specific headings
@@ -73,21 +44,10 @@ every human `Thanks @...` attribution.
section instead of deleting them
5. Section shape:
- `### Highlights`: 5-8 bullets, broad user wins first
- include only a clear user-visible capability or workflow unlock, a
material reliability/safety fix, a broad cross-surface improvement, or
a release-defining integration/compatibility milestone
- every highlight must say what changed for a user in one sentence; use
one user story per bullet and group its supporting PRs
- exclude tests, CI, refactors, docs, catalog churn, and implementation
detail unless the outcome is a material install/update, data-safety, or
widely visible user improvement
- `### Changes`: new capabilities and behavior changes
- `### Fixes`: user-facing fixes first, grouped by impact and surface
- group related changes/fixes by surface and user impact; avoid one bullet
per tiny commit when several commits tell one user-facing story
- `### Complete contribution record`: generated PR-first record after the
grouped prose; it is the exhaustive accounting surface, not a second
release summary
6. Preserve attribution:
- keep `#issue`, `(#PR)`, `Fixes #...`, and `Thanks @...`
- every human-authored merged PR represented by a user-facing entry needs
@@ -102,35 +62,17 @@ every human `Thanks @...` attribution.
- multiple `Thanks @...` handles in one bullet are expected; do not drop or
collapse contributor credit just because the note is grouped
- if one grouped bullet covers both direct commits and PRs, keep all PR refs
and thanks, plus any issue refs and human credit from the direct work
- issues remain normal inline `#NNN` references. Do not add a separate
linked-issues inventory. The generated PR record keeps source issues
inline as `Related #NNN` on the PR that shipped them
- when backfilling an older linked-issues inventory, preserve reporter
credit inline for every GitHub-confirmed closing PR relationship. Do not
infer a PR relationship from a generic cross-reference event, invent an
unrelated PR link for a standalone report, or recreate the retired
inventory
- the complete contribution record lists every merged source PR exactly once
as `**PR #NNN**`; source PRs include GitHub commit associations and merged
PR references explicitly present in active commit subjects/bodies. It
preserves author/co-author credit and any issue references in the original
title
- direct commits remain in the manifest with GitHub-resolved author,
co-author, issue, and editorial-eligibility data. They inform grouped
prose but are never rendered as a public `#### Direct commits` dump. Add
direct-commit credit to a grouped bullet only when it shares an explicit
closing issue reference or at least two distinctive subject terms
- the verifier rejects `docs`, `test`, `refactor`, `ci`, `build`, `chore`,
and `style` PRs in Highlights, Changes, or Fixes. Keep those internal
contributions in the complete PR record, but do not give them editorial
release-note space
- classify internal-only work from conventional prefixes and clear title
signals such as `QA`, `test`, `docs`, `refactor`, `lint`, or `CI`; an
untyped title is not automatically editorial
and thanks, plus any issue refs from the direct commits
- before finalizing, audit the final release-note body:
- extract all `#NNN` refs from the notes
- resolve which refs are PRs and collect human PR authors
- resolve issue refs used as bug/report refs and collect human reporters
- scan represented commits for `Co-authored-by`
- compare those handles to the final `Thanks @...` set
- fix every missing human credit or explicitly record why it is omitted
- do not add GHSA references, advisory IDs, or security advisory slugs to
changelog entries or GitHub release-note text unless explicitly requested
- never thank bots, `@claude`, `@openclaw`, `@clawsweeper`, or `@steipete`
- never thank bots, `@openclaw`, `@clawsweeper`, or `@steipete`
- do not use GitHub's release contributor count as the source of truth; the
changelog must carry the complete human credit set itself
7. Sorting preference:
@@ -149,50 +91,36 @@ every human `Thanks @...` attribution.
- if any compatibility `removeAfter` is on/before release date, resolve it
or explicitly record the blocker before shipping
10. Validate and ship:
- after the manifest-driven rewrite, regenerate and verify the complete
contribution record before committing:
```bash
node .agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs \
--base <base-tag> \
--target <target-ref> \
--version <YYYY.M.PATCH> \
--manifest /tmp/openclaw-release-<YYYY.M.PATCH>.json \
--write-ledger
```
- the command fails when any `#NNN` reference in release history or the
rendered release section cannot resolve, when reverted work is presented
as shipped, when a source PR is absent from the contribution record, when
direct commits are rendered as a public record dump, when non-editorial
PRs appear in grouped prose, or when an eligible PR author or known
co-author is missing from that PR's `Thanks @...` credit
- when grouped prose names a PR, that same bullet must retain every
contributor and linked-reporter credit from its generated PR record
- unqualified `#NNN` references resolve against `openclaw/openclaw`;
cross-repository references such as `openclaw/imsg#141` remain literal
text and must not be rewritten as local issue links
- after the GitHub release or prerelease is published, verify every matching
release page against the same source section:
```bash
node .agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs \
--base <base-tag> \
--target <target-ref> \
--version <YYYY.M.PATCH> \
--release-tag v<YYYY.M.PATCH> \
--check-github
```
- add one `--release-tag` for every beta and stable page in the train; a
`### Release verification` tail is permitted, but any other body drift
fails the check; the GitHub body must begin with the complete
`## YYYY.M.PATCH` changelog section, including its heading
- GitHub release bodies are limited to 125,000 characters. If the complete
source section plus an existing verification tail exceeds that limit, keep
the source section intact and omit the tail; never truncate the
contribution record
- `git diff --check`
- for docs/changelog-only changes, no broad tests are required
- commit with `scripts/committer "docs(changelog): refresh YYYY.M.PATCH notes" CHANGELOG.md`
- push, pull/rebase if needed, then branch/rebase release from latest `main`
- generate and verify the complete contribution ledger before committing:
```bash
node .agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs \
--base <base-tag> \
--target <target-ref> \
--version <YYYY.M.PATCH> \
--write-ledger
```
- the command fails when any `#NNN` reference in release history or the
rendered release section is absent from the ledger, when reverted work is
presented as shipped, or when an eligible PR author, issue reporter, or
known co-author is missing from that entry's `Thanks @...` credit
- after the GitHub release or prerelease is published, verify every matching
release page against the same source section:
```bash
node .agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs \
--base <base-tag> \
--target <target-ref> \
--version <YYYY.M.PATCH> \
--release-tag v<YYYY.M.PATCH> \
--check-github
```
- add one `--release-tag` for every beta and stable page in the train; a
`### Release verification` tail is permitted, but any other body drift
fails the check; the GitHub body must begin with the complete
`## YYYY.M.PATCH` changelog section, including its heading
- `git diff --check`
- for docs/changelog-only changes, no broad tests are required
- commit with `scripts/committer "docs(changelog): refresh YYYY.M.PATCH notes" CHANGELOG.md`
- push, pull/rebase if needed, then branch/rebase release from latest `main`
## Quota / API Outage Rule

View File

@@ -1,197 +0,0 @@
---
name: openclaw-ci-limits
description: Manage OpenClaw GitHub Actions and Blacksmith CI capacity, runner-registration budgets, fanout caps, main-push debounce, shard sizing, hosted-runner offload, queue health, and safe ramp-down/ramp-up changes. Use when tuning `.github/workflows/*`, `docs/ci.md`, CI runner labels, matrix `max-parallel`, ClawSweeper/Blacksmith burst protection, CodeQL runner placement, or investigating slow/queued OpenClaw CI.
---
# OpenClaw CI Limits
Use this skill for CI capacity changes, not ordinary test failure triage. The
goal is to keep OpenClaw fast while staying below GitHub's self-hosted runner
registration edge limit.
## Core Facts
- The scarce resource is Blacksmith runner registrations, not Blacksmith vCPU
capacity.
- GitHub runner registrations for `openclaw` are currently capped at 3,000 per
5 minutes per repository, organization, or enterprise. The `openclaw`
organization shares one bucket.
- Core REST quota does not draw down this bucket. Check
`actions_runner_registration` separately; core quota can be healthy while
runner registration is throttled.
- Use 2,000 registrations per 5 minutes as the operating target. Leave the last
third for other repos, retries, and burst overlap.
- Jobs that route, notify, summarize, choose shards, or run short CodeQL quality
scans should stay on GitHub-hosted runners unless measured evidence says
Blacksmith is required.
## First Checks
Before changing CI, collect current pressure:
```bash
ghx api rate_limit --jq '{core:.resources.core,graphql:.resources.graphql,search:.resources.search,actions_runner_registration:.resources.actions_runner_registration}'
ghx run list -R openclaw/openclaw --limit 20 --json databaseId,status,conclusion,workflowName,event,headBranch,createdAt,updatedAt,url
ghx run list -R openclaw/clawsweeper --limit 20 --json databaseId,status,conclusion,workflowName,event,headBranch,createdAt,updatedAt,url
curl -fsS https://clawsweeper.openclaw.ai/api/status | jq '{generated_at,fleet,diagnostics:{errors:.diagnostics.errors}}'
curl -fsS https://clawsweeper.openclaw.ai/api/exact-review-queue | jq '.'
node scripts/ci-run-timings.mjs --latest-main
node scripts/ci-run-timings.mjs --recent 10
```
Read:
- `.github/workflows/ci.yml`
- `.github/workflows/codeql-critical-quality.yml`
- `docs/ci.md`
- `test/scripts/ci-workflow-guards.test.ts`
- touched planner files under `scripts/lib/*ci*`, `scripts/lib/*test-plan*`, or
`scripts/ci-changed-scope.mjs`
## Diagnose The Bottleneck
Classify the issue before changing caps:
- **Runner-registration throttle:** many jobs queued before runner assignment,
Blacksmith/GitHub reports 403/429 or spam-style 422 responses from
`generate-jitconfig`, and API core quota is still healthy. Treat 422 as this
signal only when the request payload is otherwise valid. Fix burstiness and
Blacksmith job count.
- **Blacksmith capacity:** Blacksmith dashboard shows actual concurrency caps or
unavailable capacity. Do not solve this with GitHub workflow fanout alone.
- **OpenClaw test runtime:** jobs start quickly but one lane dominates wall time.
Use `$openclaw-test-performance` instead of runner tuning.
- **Real failing CI:** one job fails after starting. Use `$github:gh-fix-ci` or
`$openclaw-testing`, not this skill.
- **ClawSweeper backlog:** exact-review queue grows while CI is healthy. Tune
ClawSweeper workers in `openclaw/clawsweeper`, not OpenClaw CI.
## Registration Budget Math
Estimate worst-case registrations for a change before editing:
```text
new Blacksmith registrations ~= number of Blacksmith jobs that can become queued
inside one 5 minute window
```
For matrix jobs, count every row that can start in the 5-minute window.
`strategy.max-parallel` only caps simultaneous rows; short rows can turn over
and register more runners before the window resets. Use job duration, retries,
and queue turnover to justify any lower estimate. Add non-matrix Blacksmith jobs
such as `preflight`, `security-fast`, `build-artifacts`, and platform lanes.
For repeated pushes, multiply by the number of runs expected to reach
Blacksmith admission in the same 5-minute window, including runs canceled after
admission. The debounce only suppresses pushes that arrive while
`runner-admission` is still sleeping; once Blacksmith jobs register, those
registrations are spent even if a later push cancels the run. If timing is
uncertain, count every sequential push in the window.
Reject a change unless the org-level worst case stays below 2,000 registrations
per 5 minutes with headroom for ClawSweeper, ClawHub, Clownfish, OpenClaw RTT,
and Clawbench.
## Safe Levers
Prefer these in order:
1. Add or preserve concurrency groups that cancel superseded PR and canonical
`main` runs before Blacksmith work starts.
2. Keep the `runner-admission` hosted debounce for canonical `main` pushes.
Change `OPENCLAW_MAIN_CI_DEBOUNCE_SECONDS` only with evidence.
3. Move high-frequency, short, non-build jobs to `ubuntu-24.04`.
4. Reduce matrix rows by bundling related tests inside one runner job when the
combined job stays under timeout and keeps useful failure names.
5. Lower `strategy.max-parallel` for bursty Blacksmith matrices.
6. Right-size runners from timing evidence. Use fewer/larger jobs only when
elapsed time improves enough to justify registration count.
7. Split truly slow tests with `$openclaw-test-performance`; do not hide a slow
test problem by registering more runners.
Do not:
- add another Blacksmith installation expecting a higher registration bucket;
- move CodeQL Critical Quality back to Blacksmith;
- raise all `max-parallel` values at once;
- make manual `workflow_dispatch` runs cancel normal push/PR validation;
- delete coverage just to reduce runner count;
- treat cancelled superseded runs as failures without checking the newest run
for the same ref.
## Current OpenClaw Knobs
These are intentionally guarded by `test/scripts/ci-workflow-guards.test.ts`:
- `CI` concurrency key version and `cancel-in-progress` for PRs and canonical
`main` pushes.
- `runner-admission` on `ubuntu-24.04` with
`OPENCLAW_MAIN_CI_DEBOUNCE_SECONDS=90`.
- `preflight` and `security-fast` needing `runner-admission`.
- CI matrix caps: fast/check lanes at 12, Node test shards at 24, Windows and
Android at 2.
- `build-artifacts` on `blacksmith-16vcpu-ubuntu-2404`.
- lower-weight Node/check shards on `blacksmith-4vcpu-ubuntu-2404`.
- heavy retained Linux/Android shards on `blacksmith-8vcpu-ubuntu-2404`.
- CodeQL Critical Quality on `ubuntu-24.04` with no `blacksmith-` labels.
When changing one knob, update `docs/ci.md` and the guard test in the same PR.
## Validation
For workflow-only or docs/skill-only changes in a Codex worktree:
```bash
node scripts/run-vitest.mjs test/scripts/ci-workflow-guards.test.ts
node scripts/check-workflows.mjs
node scripts/docs-list.js
./node_modules/.bin/oxfmt --check .github/workflows/ci.yml .github/workflows/codeql-critical-quality.yml docs/ci.md test/scripts/ci-workflow-guards.test.ts .agents/skills/openclaw-ci-limits/SKILL.md .agents/skills/openclaw-ci-limits/agents/openai.yaml
git diff --check
```
If `pnpm docs:list` tries to reconcile dependencies in a linked Codex worktree,
stop and use `node scripts/docs-list.js`.
For a PR before requesting maintainer approval:
```bash
.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
ghx pr checks <pr> -R openclaw/openclaw --watch --interval 15
```
Use hosted exact-head gates for CI workflow tuning. Do not burn local
`pnpm test` on unrelated full-suite proof.
Only after the maintainer explicitly asks you to prepare or land the PR, run the
repo-native mutating wrapper:
```bash
scripts/pr review-init <pr>
scripts/pr review-artifacts-init <pr>
scripts/pr review-validate-artifacts <pr>
OPENCLAW_TESTBOX=1 scripts/pr prepare-run <pr>
```
`prepare-run` can push a prepared commit to the PR branch. Only run
`scripts/pr merge-run <pr>` after the maintainer has explicitly asked you to
land the PR. Both commands mutate GitHub state.
## Post-Land Monitoring
After merge, watch at least one fresh main cycle and the adjacent repos:
```bash
ghx run list -R openclaw/openclaw --limit 20 --json databaseId,status,conclusion,workflowName,event,headBranch,createdAt,updatedAt,url
for repo in openclaw/clawsweeper openclaw/clawhub openclaw/clownfish openclaw/openclaw-rtt openclaw/clawbench; do
ghx run list -R "$repo" --limit 12 --json databaseId,status,conclusion,workflowName,event,headBranch,createdAt,updatedAt,url
done
curl -fsS https://clawsweeper.openclaw.ai/api/exact-review-queue | jq '.'
```
Report:
- exact PR/commit landed;
- expected registration reduction or added headroom;
- CI run status and slowest/queued jobs;
- ClawSweeper queue pending, dispatching, leased, oldest pending age;
- any real failures that remain outside runner registration.

View File

@@ -1,4 +0,0 @@
interface:
display_name: "OpenClaw CI Limits"
short_description: "Tune OpenClaw CI fanout and runner budgets"
default_prompt: "Use $openclaw-ci-limits to inspect OpenClaw CI pressure, tune runner-registration fanout safely, and document the exact validation before landing."

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ description: "Run or recover OpenClaw macOS release signing, notarization, appca
# OpenClaw Mac Release
Use with `$release-openclaw-maintainer`, `$release-openclaw-ci`, `$one-password`, and `$release-private` if it exists when stable macOS assets, release-ops mac preflight, notarization, appcast promotion, or mac release recovery is involved.
Use with `$release-openclaw-maintainer`, `$release-openclaw-ci`, `$one-password`, and `$release-private` if it exists when stable macOS assets, private mac preflight, notarization, appcast promotion, or mac release recovery is involved.
## Credentials
@@ -23,7 +23,7 @@ Use with `$release-openclaw-maintainer`, `$release-openclaw-ci`, `$one-password`
## GitHub Secrets
Target release-ops repo environment: `openclaw/releases`, env `mac-release`.
Target private repo environment: `openclaw/releases-private`, env `mac-release`.
Set only after local notary auth validation:
@@ -35,24 +35,12 @@ Do not update these from mixed sources. All three ASC fields must come from the
## Workflow Shape
- `openclaw/openclaw` is the public product repo. Its GitHub Releases page is
where macOS assets are ultimately attached.
- `openclaw/openclaw` `macos-release.yml` is public handoff validation only.
It never signs, notarizes, or uploads macOS assets, regardless of
`preflight_only`.
- `openclaw/releases` is the restricted release-ops repo. Its macOS workflows
sign, notarize, validate, and promote assets onto the
`openclaw/openclaw` GitHub release.
- Public release branch may carry mac-only packaging fixes after the stable tag/npm are already live.
- Use `source_ref=release/YYYY.M.PATCH` for release-ops mac preflight/validation when building that branch variation.
- Use `source_ref=release/YYYY.M.PATCH` for private mac preflight/validation when building that branch variation.
- Keep `tag=vYYYY.M.PATCH` pointing at the original stable release commit.
- Real mac publish must reuse:
- a successful release-ops mac preflight run for the same tag/source SHA
- a successful release-ops mac validation run for the same tag/source SHA
- Release-ops preflight and real publish enter the protected `mac-release`
environment in the `build_sign_and_package` job. Operators may be able to
trigger the workflow while Vincent or another environment reviewer approves
the paused deployment before signing/notarization/promotion proceeds.
- a successful private mac preflight run for the same tag/source SHA
- a successful private mac validation run for the same tag/source SHA
- If preflight source SHA differs from tag SHA, validation must also use the same `source_ref`; promotion rejects mismatched proof.
## Notarization
@@ -64,25 +52,10 @@ Do not update these from mixed sources. All three ASC fields must come from the
## Dispatch
Public handoff validation:
Private preflight:
```bash
gh workflow run macos-release.yml --repo openclaw/openclaw \
--ref release/YYYY.M.PATCH \
-f tag=vYYYY.M.PATCH \
-f preflight_only=true \
-f public_release_branch=release/YYYY.M.PATCH
```
- Use the public release branch as the workflow ref so the Actions list displays
`release/YYYY.M.PATCH`, matching prior stable macOS handoff runs.
- Do not use `--ref main` or `--ref vYYYY.M.PATCH` for this public handoff
validation. The workflow checks out the tag from the `tag` input internally.
Release-ops preflight:
```bash
gh workflow run openclaw-macos-publish.yml --repo openclaw/releases --ref main \
gh workflow run openclaw-macos-publish.yml --repo openclaw/releases-private --ref main \
-f tag=vYYYY.M.PATCH \
-f source_ref=release/YYYY.M.PATCH \
-f preflight_only=true \
@@ -91,24 +64,18 @@ gh workflow run openclaw-macos-publish.yml --repo openclaw/releases --ref main \
-f public_release_branch=release/YYYY.M.PATCH
```
Wait for the run to reach the `mac-release` environment approval if GitHub
pauses it, then get approval from Vincent or another configured environment
reviewer. Record the successful preflight run id.
Release-ops validation for a branch-variation preflight:
Private validation for a branch-variation preflight:
```bash
gh workflow run openclaw-macos-validate.yml --repo openclaw/releases --ref main \
gh workflow run openclaw-macos-validate.yml --repo openclaw/releases-private --ref main \
-f tag=vYYYY.M.PATCH \
-f source_ref=release/YYYY.M.PATCH
```
Record the successful validation run id.
Real publish:
```bash
gh workflow run openclaw-macos-publish.yml --repo openclaw/releases --ref main \
gh workflow run openclaw-macos-publish.yml --repo openclaw/releases-private --ref main \
-f tag=vYYYY.M.PATCH \
-f preflight_only=false \
-f smoke_test_only=false \
@@ -118,14 +85,6 @@ gh workflow run openclaw-macos-publish.yml --repo openclaw/releases --ref main \
-f public_release_branch=release/YYYY.M.PATCH
```
Wait for the `mac-release` environment approval again if GitHub pauses the real
publish run before it promotes assets.
- Release-ops `openclaw/releases` publish/validate workflows run from their own
trusted `main` workflow ref. Real publish has a guard that rejects any other
workflow ref. That displayed `main` ref is expected; the public OpenClaw
source is selected by `tag` and optional `source_ref`.
## Verify
- `gh release view vYYYY.M.PATCH --repo openclaw/openclaw` shows zip, dmg, dSYM zip, not draft, not prerelease.

View File

@@ -203,9 +203,8 @@ Stable publication is not complete until `main` carries the actual shipped relea
validation-only release machinery. If mac packaging needs release-branch-only
fixes after the stable npm package or GitHub tag is already published, do not
create a `vYYYY.M.PATCH-N` correction tag just to change the workflow source.
Dispatch the release-ops mac workflows for the original `tag=vYYYY.M.PATCH`
with `source_ref=release/YYYY.M.PATCH` and
`public_release_branch=release/YYYY.M.PATCH`;
Dispatch the private mac workflows for the original `tag=vYYYY.M.PATCH` with
`source_ref=release/YYYY.M.PATCH` and `public_release_branch=release/YYYY.M.PATCH`;
provenance checks must prove the source SHA descends from the tag and
validation/preflight use the same source. Reserve `vYYYY.M.PATCH-N` correction
tags for emergency hotfixes that must publish a new npm package/release
@@ -250,20 +249,12 @@ Stable publication is not complete until `main` carries the actual shipped relea
section from history, not existing notes. Use the last reachable stable or
beta release tag as the base, then inspect every commit through the target
release SHA.
- Generate `$openclaw-changelog-update`'s full contribution manifest before
the editorial rewrite. It is the required source for `### Highlights`,
`### Changes`, and `### Fixes`; do not preserve old grouped prose without
comparing it to the manifest's PRs, contributors, direct commits, and
unlinked commits.
- The changelog rewrite is not optional for beta reruns: any `beta.N` after a
rebase or backport must refresh the same stable-base `## YYYY.M.PATCH` section
before the new version/tag commit.
- Include both merged PR commits and direct commits on `main`. Direct commits
matter: infer notes from their subject, body, touched files, linked issues,
tests, and nearby code when no PR body exists.
- Keep direct commits in the generated manifest and use them to shape grouped
user outcomes, but never dump them into `CHANGELOG.md` or GitHub release
bodies. The public complete record is PR-first and exhaustive for PRs.
- Prefer PR bodies, issue links, review proof, and commit bodies over commit
subjects alone. If a commit fixed an issue directly, the commit body should
name the user-visible behavior, affected surface, issue ref, and credited
@@ -279,31 +270,11 @@ Stable publication is not complete until `main` carries the actual shipped relea
`#issue`, `(#PR)`, `Fixes #...`, and every human `Thanks @...` handle.
Multiple thanks in one bullet are expected when multiple contributor PRs are
grouped.
- Highlights earn their place only when they are a visible capability/workflow
unlock, a material reliability or safety repair, a broad user-facing
improvement, or a release-defining integration/compatibility change. Keep
five to eight user-outcome bullets; omit tests, CI, refactors, docs, and
implementation trivia unless their outcome materially affects users.
- Do not give `docs`, `test`, `refactor`, `ci`, `build`, `chore`, or `style`
PRs/direct commits their own Highlights, Changes, or Fixes entry. They remain
accounted for in the PR record or manifest, but are not product release
content. Treat explicit internal title signals such as `QA`, `lint`, or
`testing` the same way even when the PR has no conventional prefix.
- Use the generated `### Complete contribution record` as PR-first accounting:
every merged source PR appears once with author/co-author credit, including
PRs identified only by an explicit active-commit `#NNN` reference after a
cherry-pick or squash. Keep issues inline as `#NNN` in titles and grouped
prose; do not create a linked-issues inventory or a direct-commit listing.
When grouped prose names a PR, keep every contributor and linked-reporter
credit from that PR's record on the same bullet.
- Changelog entries should be user-facing, not internal release-process notes.
- GitHub release and prerelease bodies must use the full matching
`CHANGELOG.md` version section, not highlights or an excerpt. When creating
or editing a release, extract from `## YYYY.M.PATCH` through the line before the
next level-2 heading and use that complete block as the release notes.
- GitHub limits release bodies to 125,000 characters. If a historical
`### Release verification` tail would exceed that cap, omit the tail and keep
the complete changelog section; do not truncate the contribution record.
- Before publishing or closing a release, run
`$openclaw-changelog-update`'s `verify-release-notes.mjs` with every stable
and beta release tag in the train. Do not publish or leave a page live when
@@ -580,8 +551,8 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
- Actual npm install/update phases are capped at 5 minutes. If `npm install -g`, installer package install, or `openclaw update` takes longer than 300s in release e2e, stop treating the run as healthy progress and debug the installer/updater or harness.
- Serialize host build/package mutations ahead of VM lanes. Finish `pnpm build`, `pnpm ui:build`, `pnpm release:check`, install smoke, and any Docker/package-prep lanes before starting Parallels `npm pack` lanes; otherwise `dist` can disappear during VM pack prep and produce false failures.
- Include mac release readiness in preflight by running the public validation
workflow in `openclaw/openclaw` and the release-ops mac preflight in
`openclaw/releases` for every release.
workflow in `openclaw/openclaw` and the real mac preflight in
`openclaw/releases-private` for every release.
- Treat the `appcast.xml` update on `main` as part of mac release readiness, not an optional follow-up.
- The workflows remain tag-based. The agent is responsible for making sure
preflight runs complete successfully before any publish run starts.
@@ -609,16 +580,16 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
## Use the right auth flow
- OpenClaw publish uses GitHub trusted publishing.
- Stable npm promotion from `beta` to `latest` uses the restricted release-ops
`openclaw/releases/.github/workflows/openclaw-npm-dist-tags.yml` workflow
because `npm dist-tag` management needs `NPM_TOKEN`, while the public npm
release workflow stays OIDC-only.
- Prefer fixing the release-ops workflow token path over any local 1Password
fallback. The desired setup is a granular npm token stored as the release-ops
- Stable npm promotion from `beta` to `latest` uses the private
`openclaw/releases-private/.github/workflows/openclaw-npm-dist-tags.yml`
workflow because `npm dist-tag` management needs `NPM_TOKEN`, while the
public npm release workflow stays OIDC-only.
- Prefer fixing the private workflow token path over any local 1Password
fallback. The desired setup is a granular npm token stored as the private
repo's `NPM_TOKEN` secret, scoped to the `openclaw` package with read/write
and 2FA bypass for automation.
- If the release-ops dist-tag workflow cannot promote because `NPM_TOKEN` is
absent or stale, use the local tmux + 1Password fallback:
- If the private dist-tag workflow cannot promote because `NPM_TOKEN` is absent
or stale, use the local tmux + 1Password fallback:
- Start or reuse a tmux session so interactive `npm login` and OTP prompts
are observable and recoverable.
- Hard rule: never run `op` directly in the main agent shell during release
@@ -636,21 +607,21 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
- Verify with a cache-bypassed registry read, for example:
`npm view openclaw dist-tags --json --prefer-online --cache /tmp/openclaw-npm-cache-verify-$$`
and `npm view openclaw@latest version dist.tarball --json --prefer-online`.
- Direct stable publishes can also use that release-ops dist-tag workflow to
point `beta` at the already-published `latest` version when the operator wants
both tags aligned immediately.
- Direct stable publishes can also use that private dist-tag workflow to point
`beta` at the already-published `latest` version when the operator wants both
tags aligned immediately.
- The publish run must be started manually with `workflow_dispatch`.
- The npm workflow and the release-ops mac publish workflow accept
- The npm workflow and the private mac publish workflow accept
`preflight_only=true` to run validation/build/package steps without uploading
public release assets.
- Real npm publish requires a prior successful npm preflight run id and the
successful Full Release Validation run id for the same tag/SHA so the publish
job promotes the prepared tarball instead of rebuilding it and attaches the
correct release evidence.
- Real release-ops mac publish requires a prior successful release-ops mac
preflight run id so the publish job promotes the prepared artifacts instead of
- Real private mac publish requires a prior successful private mac preflight
run id so the publish job promotes the prepared artifacts instead of
rebuilding or renotarizing them again.
- The release-ops mac workflow also accepts `smoke_test_only=true` for branch-safe
- The private mac workflow also accepts `smoke_test_only=true` for branch-safe
workflow smoke tests that use ad-hoc signing, skip notarization, skip shared
appcast generation, and do not prove release readiness.
- `preflight_only=true` on the npm workflow is also the right way to validate an
@@ -671,27 +642,27 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
use only `main` or `release/YYYY.M.PATCH`.
- `.github/workflows/macos-release.yml` in `openclaw/openclaw` is now a
public validation-only handoff. It validates the tag/release state and points
operators to the release-ops repo. It still rebuilds the JS outputs needed for
operators to the private repo. It still rebuilds the JS outputs needed for
release validation, but it does not sign, notarize, or publish macOS
artifacts.
- `openclaw/releases/.github/workflows/openclaw-macos-validate.yml` is the
required release-ops mac validation lane for `swift test`; keep it green
- `openclaw/releases-private/.github/workflows/openclaw-macos-validate.yml`
is the required private mac validation lane for `swift test`; keep it green
before any real stable mac publish run starts.
- Real mac preflight and real mac publish both use
`openclaw/releases/.github/workflows/openclaw-macos-publish.yml`.
- The release-ops mac validation lane runs on GitHub's standard macOS runner.
- The release-ops mac preflight path runs on GitHub's xlarge macOS runner and uses
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml`.
- The private mac validation lane runs on GitHub's standard macOS runner.
- The private mac preflight path runs on GitHub's xlarge macOS runner and uses
a SwiftPM cache because the build/sign/notarize/package path is CPU-heavy.
- Release-ops mac preflight uploads notarized build artifacts as workflow
artifacts instead of uploading public GitHub release assets.
- Release-ops smoke-test runs upload ad-hoc, non-notarized build artifacts as
- Private mac preflight uploads notarized build artifacts as workflow artifacts
instead of uploading public GitHub release assets.
- Private smoke-test runs upload ad-hoc, non-notarized build artifacts as
workflow artifacts and intentionally skip stable `appcast.xml` generation.
- For stable releases, npm preflight, Full Release Validation, public mac
validation, release-ops mac validation, and release-ops mac preflight must all
pass before any real publish run starts. For beta releases, npm preflight and
Full Release Validation must pass before npm publish unless the operator
explicitly waives the full gate; mac beta validation is still only required
when requested.
validation, private mac validation, and private mac preflight must all pass
before any real publish run starts. For beta releases, npm preflight and Full
Release Validation must pass before npm publish unless the operator explicitly
waives the full gate; mac beta validation is still only required when
requested.
- Real publish runs may be dispatched from `main` or from a
`release/YYYY.M.PATCH` branch. For release-branch runs, the tag must be contained
in that release branch, and the real publish must reuse a successful preflight
@@ -700,21 +671,21 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
rather than workflow-level SHA pinning.
- The `npm-release` environment must be approved by `@openclaw/openclaw-release-managers` before publish continues.
- Mac publish uses
`openclaw/releases/.github/workflows/openclaw-macos-publish.yml` for
release-ops mac preflight artifact preparation and real publish artifact
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml` for
private mac preflight artifact preparation and real publish artifact
promotion.
- Real release-ops mac publish uploads the packaged `.zip`, `.dmg`, and
- Real private mac publish uploads the packaged `.zip`, `.dmg`, and
`.dSYM.zip` assets to the existing GitHub release in `openclaw/openclaw`
automatically when `OPENCLAW_PUBLIC_REPO_RELEASE_TOKEN` is present in the
release-ops repo `mac-release` environment.
private repo `mac-release` environment.
- For stable releases, the agent must also download the signed
`macos-appcast-<tag>` artifact from the successful release-ops mac workflow
and then update `appcast.xml` on `main`.
`macos-appcast-<tag>` artifact from the successful private mac workflow and
then update `appcast.xml` on `main`.
- For beta mac releases, do not update the shared production `appcast.xml`
unless a separate beta Sparkle feed exists.
- The release-ops repo targets a dedicated `mac-release` environment. If the
GitHub plan does not yet support required reviewers there, do not assume the
environment alone is the approval boundary; rely on restricted repo access and
- The private repo targets a dedicated `mac-release` environment. If the GitHub
plan does not yet support required reviewers there, do not assume the
environment alone is the approval boundary; rely on private repo access and
CODEOWNERS until those settings can be enabled.
- Do not use `NPM_TOKEN` or the plugin OTP flow for the OpenClaw package
publish path; package publishing uses trusted publishing.
@@ -801,12 +772,12 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
18. For stable releases, start `.github/workflows/macos-release.yml` in
`openclaw/openclaw` and wait for the public validation-only run to pass.
19. For stable releases, start
`openclaw/releases/.github/workflows/openclaw-macos-validate.yml` with the
same tag and wait for the release-ops mac validation lane to pass.
`openclaw/releases-private/.github/workflows/openclaw-macos-validate.yml`
with the same tag and wait for the private mac validation lane to pass.
20. For stable releases, start
`openclaw/releases/.github/workflows/openclaw-macos-publish.yml` with
`preflight_only=true` and wait for it to pass. Save that run id because the
real publish requires it to reuse the notarized mac artifacts.
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml`
with `preflight_only=true` and wait for it to pass. Save that run id because
the real publish requires it to reuse the notarized mac artifacts.
21. If any preflight or validation run fails, fix the issue on a new commit,
delete the tag and any accidental draft/incomplete GitHub release, recreate
the tag from the fixed commit, and rerun all relevant preflights from
@@ -862,23 +833,22 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
promotion roster when the matching beta already carried the full confidence
pass: published npm postpublish verify, Docker install/update smoke,
macOS-only Parallels install/update smoke, and required QA signal.
Then start the restricted release-ops
`openclaw/releases/.github/workflows/openclaw-npm-dist-tags.yml` workflow
to promote that stable version from `beta` to `latest`, then verify
`latest` now points at that version.
Then start the private
`openclaw/releases-private/.github/workflows/openclaw-npm-dist-tags.yml`
workflow to promote that stable version from `beta` to `latest`, then
verify `latest` now points at that version.
29. If the stable release was published directly to `latest` and `beta` should
follow it, start that same release-ops dist-tag workflow to point `beta` at
the stable version, then verify both `latest` and `beta` point at that
version.
follow it, start that same private dist-tag workflow to point `beta` at the
stable version, then verify both `latest` and `beta` point at that version.
30. For stable releases, start
`openclaw/releases/.github/workflows/openclaw-macos-publish.yml` for the
real publish with the successful release-ops mac `preflight_run_id` and wait
for success.
31. Verify the successful real release-ops mac run uploaded the `.zip`, `.dmg`,
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml`
for the real publish with the successful private mac `preflight_run_id` and
wait for success.
31. Verify the successful real private mac run uploaded the `.zip`, `.dmg`,
and `.dSYM.zip` artifacts to the existing GitHub release in
`openclaw/openclaw`.
32. For stable releases, download `macos-appcast-<tag>` from the successful
release-ops mac run, update `appcast.xml` on `main`, verify the feed, then
private mac run, update `appcast.xml` on `main`, verify the feed, then
complete the **Close stable releases on main** gate.
33. For beta releases, publish the mac assets only when intentionally requested;
expect no shared production

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.

22
.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:
@@ -118,7 +119,6 @@
- any-glob-to-any-file:
- "extensions/qa-lab/**"
- "qa/scenarios/**"
- "docs/maturity/**"
- "docs/concepts/qa-e2e-automation.md"
- "docs/concepts/personal-agent-benchmark-pack.md"
- "docs/channels/qa-channel.md"
@@ -171,10 +171,6 @@
- any-glob-to-any-file:
- "extensions/zalo/**"
- "docs/channels/zalo.md"
"channel: zaloclawbot":
- changed-files:
- any-glob-to-any-file:
- "docs/channels/zaloclawbot.md"
"channel: zalouser":
- changed-files:
- any-glob-to-any-file:
@@ -252,12 +248,12 @@
- "src/agents/sandbox*.ts"
- "src/commands/sandbox*.ts"
- "src/cli/sandbox-cli.ts"
- "src/docker-setup.e2e.test.ts"
- "src/docker-setup.test.ts"
- "src/config/**/*sandbox*"
- "docs/cli/sandbox.md"
- "docs/gateway/sandbox*.md"
- "docs/install/docker.md"
- "docs/tools/multi-agent-sandbox-tools.md"
- "docs/multi-agent-sandbox-tools.md"
"agents":
- changed-files:
@@ -270,7 +266,7 @@
- ".github/workflows/opengrep-*.yml"
- ".semgrepignore"
- "docs/cli/security.md"
- "docs/gateway/security/**"
- "docs/gateway/security.md"
- "security/**"
"extensions: admin-http-rpc":

View File

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

View File

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

View File

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

View File

@@ -17,10 +17,6 @@ on:
permissions:
contents: read
concurrency:
group: ${{ github.event_name == 'pull_request' && format('{0}-pr-v1-{1}', github.workflow, github.event.pull_request.number) || format('{0}-manual-v1-{1}', github.workflow, github.run_id) }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
PNPM_CONFIG_STORE_DIR: "/tmp/openclaw-pnpm-store"
@@ -33,7 +29,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 +101,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 "$@"
@@ -130,10 +117,8 @@ jobs:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
@@ -141,42 +126,20 @@ jobs:
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
MODELSTUDIO_API_KEY: ${{ secrets.MODELSTUDIO_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
OPENCODE_ZEN_API_KEY: ${{ secrets.OPENCODE_ZEN_API_KEY }}
OPENCLAW_LIVE_BROWSER_CDP_URL: ${{ secrets.OPENCLAW_LIVE_BROWSER_CDP_URL }}
OPENCLAW_LIVE_SETUP_TOKEN: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN }}
OPENCLAW_LIVE_SETUP_TOKEN_MODEL: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_MODEL }}
OPENCLAW_LIVE_SETUP_TOKEN_PROFILE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_PROFILE }}
OPENCLAW_LIVE_SETUP_TOKEN_VALUE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_VALUE }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
FAL_KEY: ${{ secrets.FAL_KEY }}
RUNWAY_API_KEY: ${{ secrets.RUNWAY_API_KEY }}
DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }}
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
VYDRA_API_KEY: ${{ secrets.VYDRA_API_KEY }}
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
BYTEPLUS_ACCESS_KEY_ID: ${{ secrets.BYTEPLUS_ACCESS_KEY_ID }}
BYTEPLUS_SECRET_ACCESS_KEY: ${{ secrets.BYTEPLUS_SECRET_ACCESS_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENCLAW_CODEX_AUTH_JSON: ${{ secrets.OPENCLAW_CODEX_AUTH_JSON }}
OPENCLAW_CODEX_CONFIG_TOML: ${{ secrets.OPENCLAW_CODEX_CONFIG_TOML }}
OPENCLAW_CLAUDE_JSON: ${{ secrets.OPENCLAW_CLAUDE_JSON }}
OPENCLAW_CLAUDE_CREDENTIALS_JSON: ${{ secrets.OPENCLAW_CLAUDE_CREDENTIALS_JSON }}
OPENCLAW_CLAUDE_SETTINGS_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_JSON }}
OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON }}
OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }}
run: bash scripts/ci-hydrate-testbox-env.sh
- name: Run Testbox
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,
@@ -251,6 +228,7 @@ jobs:
],
};
});
const createMatrix = (include) => ({ include });
const outputPath = process.env.GITHUB_OUTPUT;
const isCanonicalRepository = process.env.OPENCLAW_CI_REPOSITORY === "openclaw/openclaw";
@@ -268,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 =
@@ -284,7 +260,6 @@ jobs:
if (runNodeFull) {
checksFastCoreTasks.push(
{ check_name: "checks-fast-bundled-protocol", runtime: "node", task: "bundled-protocol" },
{ check_name: "QA Smoke CI", runtime: "node", task: "qa-smoke-ci" },
{ check_name: "checks-fast-bun-launcher", runtime: "bun", task: "bun-launcher" },
);
} else {
@@ -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: 12
matrix: ${{ fromJson(needs.preflight.outputs.checks_fast_core_matrix) }}
steps:
- name: Checkout
@@ -922,26 +882,6 @@ jobs:
pnpm test:bundled
pnpm protocol:check
;;
qa-smoke-ci)
output_dir=".artifacts/qa-e2e/smoke-ci-profile"
export OPENCLAW_BUILD_PRIVATE_QA=1
export OPENCLAW_ENABLE_PRIVATE_QA_CLI=1
export OPENCLAW_DISABLE_BUNDLED_PLUGINS=0
export OPENCLAW_QA_REDACT_PUBLIC_METADATA=1
export OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS=180000
NODE_OPTIONS=--max-old-space-size=8192 node scripts/build-all.mjs qaRuntime
qa_exit_code=0
pnpm openclaw qa run \
--repo-root . \
--qa-profile smoke-ci \
--concurrency 8 \
--output-dir "$output_dir" || qa_exit_code=$?
echo "QA smoke profile evidence: \`${output_dir}\`" >> "$GITHUB_STEP_SUMMARY"
if [ "$qa_exit_code" -ne 0 ]; then
echo "::error title=QA smoke profile failed::smoke-ci exited ${qa_exit_code}; evidence upload will still run"
exit "$qa_exit_code"
fi
;;
contracts-plugins-ci-routing)
pnpm test:contracts:plugins
pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/changed-lanes.test.ts test/scripts/ci-workflow-guards.test.ts test/scripts/run-vitest.test.ts test/scripts/test-projects.test.ts
@@ -958,15 +898,6 @@ jobs:
;;
esac
- name: Upload QA smoke profile evidence
if: always() && matrix.task == 'qa-smoke-ci'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: qa-smoke-profile-${{ github.run_id }}-${{ github.run_attempt }}
path: .artifacts/qa-e2e/smoke-ci-profile/
if-no-files-found: warn
retention-days: 7
checks-fast-plugin-contracts-shard:
permissions:
contents: read
@@ -977,7 +908,6 @@ jobs:
timeout-minutes: 60
strategy:
fail-fast: false
max-parallel: 12
matrix: ${{ fromJson(needs.preflight.outputs.plugin_contracts_matrix) }}
steps:
- name: Checkout
@@ -1058,7 +988,6 @@ jobs:
timeout-minutes: 60
strategy:
fail-fast: false
max-parallel: 12
matrix: ${{ fromJson(needs.preflight.outputs.channel_contracts_matrix) }}
steps:
- name: Checkout
@@ -1207,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
# widen this large matrix within the current runner-registration budget.
max-parallel: 24
matrix: ${{ fromJson(needs.preflight.outputs.checks_node_core_nondist_matrix) }}
steps:
- name: Checkout
@@ -1272,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"
@@ -1288,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
@@ -1351,7 +1248,6 @@ jobs:
timeout-minutes: 20
strategy:
fail-fast: false
max-parallel: 12
matrix:
include:
- check_name: check-guards
@@ -1368,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
@@ -1489,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: 12
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
@@ -1864,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
@@ -1876,7 +1763,6 @@ jobs:
shell: bash
strategy:
fail-fast: false
max-parallel: 2
matrix: ${{ fromJson(needs.preflight.outputs.checks_windows_matrix) }}
steps:
- name: Checkout
@@ -2196,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
@@ -2276,7 +2092,6 @@ jobs:
timeout-minutes: 20
strategy:
fail-fast: false
max-parallel: 2
matrix: ${{ fromJson(needs.preflight.outputs.android_matrix) }}
steps:
- name: Checkout
@@ -2339,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-
@@ -2369,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 }}
@@ -2417,10 +2232,8 @@ jobs:
- checks-windows
- macos-node
- macos-swift
- ios-build
- android
# Re-enable this job when we want to collect CI timing data for timing optimization.
if: ${{ false && !cancelled() && always() && github.event_name != 'push' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
if: ${{ !cancelled() && always() && github.event_name != 'push' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:

View File

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

View File

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

View File

@@ -96,7 +96,7 @@ on:
- "src/auto-reply/reply/post-compaction-context.ts"
- "src/auto-reply/reply/queue/**"
- "src/auto-reply/reply/startup-context.ts"
- "src/commands/doctor/cron/dreaming-payload-migration.ts"
- "src/commands/doctor-cron-dreaming-payload-migration.ts"
- "src/commands/doctor-memory-search.ts"
- "src/commands/doctor-session-*.ts"
- "src/commands/session-store-targets.ts"
@@ -136,7 +136,7 @@ on:
- cron: "30 6 * * *"
concurrency:
group: codeql-critical-quality-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && format('manual-{0}', github.run_id) || github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('ref-{0}', github.ref) }}
group: codeql-critical-quality-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.event_name == 'pull_request' && github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
@@ -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

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

View File

@@ -22,12 +22,18 @@ on:
push:
branches:
- main
paths:
- ".github/actions/**"
- ".github/codeql/**"
- ".github/workflows/**"
- "packages/**"
- "src/**"
schedule:
- cron: "0 6 * * *"
concurrency:
group: codeql-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && format('manual-{0}', github.run_id) || github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('ref-{0}', github.ref) }}
cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }}
group: codeql-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.event_name == 'pull_request' && github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
@@ -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

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

View File

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

View File

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

View File

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

View File

@@ -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: all
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 !== "all") {
throw new Error(`qa-evidence.json profile must be all, 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 !== "all") {
throw new Error(`QA evidence manifest profile must be all, 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 full taxonomy 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 all 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

@@ -609,6 +609,7 @@ jobs:
requires_repo_e2e: true
requires_live_suites: false
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENCLAW_E2E_WORKERS: "1"
OPENCLAW_VITEST_MAX_WORKERS: "1"
steps:
@@ -642,74 +643,9 @@ jobs:
set -euo pipefail
case "${{ matrix.suite_id }}" in
openshell-e2e)
echo "OPENCLAW_E2E_OPENSHELL_CONFIG_HOME=$HOME/.config" >> "$GITHUB_ENV"
;;
esac
- name: Install OpenShell CLI
if: |
(inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id) &&
matrix.suite_id == 'openshell-e2e'
shell: bash
run: |
set -euo pipefail
export OPENSHELL_VERSION=v0.0.68
curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/d64542f69d06694cbd203b64929d286dd0533bbb/install.sh | sh
openshell --version
- name: Bootstrap OpenShell gateway
if: |
(inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id) &&
matrix.suite_id == 'openshell-e2e'
shell: bash
run: |
set -euo pipefail
mtls_dir="$HOME/.config/openshell/gateways/openshell/mtls"
gateway_tls_dir="$RUNNER_TEMP/openshell-gateway-certs"
fallback_pid=""
if ! openshell --gateway openshell sandbox list >/dev/null 2>&1; then
rm -rf "$gateway_tls_dir"
openshell-gateway generate-certs \
--output-dir "$gateway_tls_dir" \
--server-san 127.0.0.1 \
--server-san localhost \
--server-san host.openshell.internal
rm -rf "$mtls_dir"
mkdir -p "$mtls_dir"
cp "$gateway_tls_dir/ca.crt" "$mtls_dir/ca.crt"
cp "$gateway_tls_dir/client/tls.crt" "$mtls_dir/tls.crt"
cp "$gateway_tls_dir/client/tls.key" "$mtls_dir/tls.key"
openshell gateway remove openshell >/dev/null 2>&1 || true
OPENSHELL_LOCAL_TLS_DIR="$gateway_tls_dir" nohup openshell-gateway \
--bind-address 0.0.0.0 \
--port 17670 \
--drivers docker \
--tls-cert "$gateway_tls_dir/server/tls.crt" \
--tls-key "$gateway_tls_dir/server/tls.key" \
--tls-client-ca "$mtls_dir/ca.crt" \
>"$RUNNER_TEMP/openshell-gateway.log" 2>&1 &
fallback_pid=$!
echo "OPENCLAW_OPENSHELL_FALLBACK_PID=$fallback_pid" >> "$GITHUB_ENV"
for _ in $(seq 1 30); do
if openshell gateway add --local --name openshell https://127.0.0.1:17670; then
break
fi
sleep 1
done
openshell gateway select openshell
for _ in $(seq 1 60); do
if openshell --gateway openshell sandbox list >/dev/null 2>&1; then
break
fi
sleep 1
done
fi
if [[ -z "$fallback_pid" ]]; then
echo "OPENCLAW_OPENSHELL_FALLBACK_PID=" >> "$GITHUB_ENV"
fi
openshell --gateway openshell sandbox list >/dev/null
openshell gateway list
- name: Validate suite credentials
if: inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id
shell: bash
@@ -729,15 +665,6 @@ jobs:
(inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id)
run: ${{ matrix.command }}
- name: Stop fallback OpenShell gateway
if: always() && matrix.suite_id == 'openshell-e2e'
shell: bash
run: |
set -euo pipefail
if [[ -n "${OPENCLAW_OPENSHELL_FALLBACK_PID:-}" ]]; then
kill "$OPENCLAW_OPENSHELL_FALLBACK_PID" 2>/dev/null || true
fi
validate_docker_e2e:
needs: [validate_selected_ref, prepare_docker_e2e_image, plan_release_workflow_matrices]
if: inputs.include_release_path_suites && inputs.docker_lanes == '' && needs.plan_release_workflow_matrices.outputs.docker_e2e_count != '0'
@@ -1759,8 +1686,7 @@ jobs:
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
OPENCLAW_LIVE_PROVIDERS: ${{ matrix.providers }}
OPENCLAW_LIVE_IMAGE: ${{ needs.prepare_live_test_image.outputs.live_image }}
OPENCLAW_LIVE_MODELS: ${{ matrix.models || 'modern' }}
OPENCLAW_LIVE_MAX_MODELS: ${{ matrix.max_models || '6' }}
OPENCLAW_LIVE_MAX_MODELS: "6"
OPENCLAW_LIVE_MODEL_TIMEOUT_MS: "45000"
OPENCLAW_SKIP_DOCKER_BUILD: "1"
OPENCLAW_VITEST_MAX_WORKERS: "2"
@@ -2074,7 +2000,7 @@ jobs:
profiles: stable full
- suite_id: native-live-src-gateway-profiles-minimax
label: Native live gateway profiles MiniMax
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MODELS=minimax/MiniMax-M2.7,minimax-portal/MiniMax-M2.7 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MODELS=minimax/MiniMax-M3,minimax-portal/MiniMax-M3 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 60
profile_env_only: false
profiles: stable full
@@ -2377,7 +2303,7 @@ jobs:
profiles: stable full
- suite_id: live-gateway-minimax-docker
label: Docker live gateway MiniMax
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MODELS=minimax/MiniMax-M2.7,minimax-portal/MiniMax-M2.7 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MODELS=minimax/MiniMax-M3,minimax-portal/MiniMax-M3 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
timeout_minutes: 40
profile_env_only: false
profiles: stable full

View File

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

View File

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

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

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

View File

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

@@ -1,51 +0,0 @@
name: Plugin Init Scaffold Validation
on:
workflow_dispatch:
push:
branches: [main]
paths:
- ".github/workflows/plugin-init-scaffold-validation.yml"
- "package.json"
- "pnpm-lock.yaml"
- "scripts/validate-plugin-init-provider-scaffold.ts"
- "src/cli/plugins-authoring-command.ts"
- "src/cli/plugins-authoring-command.test.ts"
- "src/cli/plugins-cli.ts"
- "src/plugin-sdk/**"
pull_request:
types: [opened, reopened, synchronize, ready_for_review]
paths:
- ".github/workflows/plugin-init-scaffold-validation.yml"
- "package.json"
- "pnpm-lock.yaml"
- "scripts/validate-plugin-init-provider-scaffold.ts"
- "src/cli/plugins-authoring-command.ts"
- "src/cli/plugins-authoring-command.test.ts"
- "src/cli/plugins-cli.ts"
- "src/plugin-sdk/**"
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
validate-provider-scaffold:
name: Validate provider scaffold
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Generate and validate provider scaffold
run: pnpm test:plugins:init-provider-scaffold

View File

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

View File

@@ -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
@@ -532,7 +532,6 @@ jobs:
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"
INPUT_SCENARIO: ${{ github.event_name == 'workflow_dispatch' && inputs.scenario || '' }}
run: |
set -euo pipefail
@@ -570,7 +569,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
@@ -625,7 +624,6 @@ jobs:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_DISCORD_CAPTURE_CONTENT: "1"
INPUT_SCENARIO: ${{ github.event_name == 'workflow_dispatch' && inputs.discord_scenario || '' }}
@@ -665,7 +663,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
@@ -723,7 +721,6 @@ jobs:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_WHATSAPP_CAPTURE_CONTENT: "1"
INPUT_SCENARIO: ${{ github.event_name == 'workflow_dispatch' && inputs.whatsapp_scenario || '' }}
@@ -763,7 +760,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
@@ -818,7 +815,6 @@ jobs:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_SLACK_CAPTURE_CONTENT: "1"
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"
@@ -859,4 +855,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: all
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

View File

@@ -19,7 +19,7 @@ permissions:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
@@ -57,10 +57,11 @@ jobs:
BASE_IMAGE="openclaw-sandbox-smoke-base:bookworm-slim" \
TARGET_IMAGE="openclaw-sandbox-common-smoke:bookworm-slim" \
PACKAGES="ca-certificates" \
INSTALL_PNPM=0 \
INSTALL_BUN=0 \
INSTALL_BREW=0 \
FINAL_USER=sandbox \
scripts/sandbox-common-setup.sh
timeout --kill-after=30s 2m docker run --rm openclaw-sandbox-common-smoke:bookworm-slim sh -lc \
'set -e; test "$(id -un)" = sandbox; node --version; pnpm --version'
u="$(timeout --kill-after=30s 2m docker run --rm openclaw-sandbox-common-smoke:bookworm-slim sh -lc 'id -un')"
test "$u" = "sandbox"

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

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

View File

@@ -37,11 +37,6 @@ on:
required: false
default: false
type: boolean
run_windows_ci:
description: "Run the focused Windows-native CI test shard after probing"
required: false
default: false
type: boolean
permissions:
contents: read
@@ -85,21 +80,10 @@ jobs:
env:
ENABLE_WSL2_FEATURES: ${{ inputs.enable_wsl2_features }}
IMPORT_UBUNTU_WSL2: ${{ inputs.import_ubuntu_wsl2 }}
UBUNTU_WSL_ROOTFS_URL: https://cloud-images.ubuntu.com/wsl/releases/24.04/current/ubuntu-noble-wsl-amd64-wsl.rootfs.tar.gz
run: |
$ErrorActionPreference = "Continue"
$ok = $false
$restartRequired = $false
function Resolve-UbuntuWslRootfsUrl {
$osArch = ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture).ToString().ToLowerInvariant()
switch ($osArch) {
"x64" { $wslArch = "amd64" }
"arm64" { $wslArch = "arm64" }
default { throw "Unsupported Windows architecture for Ubuntu WSL rootfs: $osArch" }
}
Write-Host "ubuntu_wsl_rootfs_arch=$wslArch"
"https://cloud-images.ubuntu.com/wsl/releases/24.04/current/ubuntu-noble-wsl-$wslArch-wsl.rootfs.tar.gz"
}
function Invoke-WslText {
param([string[]] $Arguments)
@@ -128,15 +112,9 @@ jobs:
Write-Host "wsl.exe=$($wsl.Source)"
if ($env:ENABLE_WSL2_FEATURES -eq "true") {
Write-Host "enable_wsl2_features=true"
foreach ($feature in @("Microsoft-Windows-Subsystem-Linux", "VirtualMachinePlatform", "HypervisorPlatform")) {
foreach ($feature in @("Microsoft-Windows-Subsystem-Linux", "VirtualMachinePlatform", "HypervisorPlatform", "Microsoft-Hyper-V-All")) {
dism.exe /online /enable-feature /featurename:$feature /all /norestart
Write-Host "enable_feature_${feature}_exit=$LASTEXITCODE"
if ($LASTEXITCODE -eq 3010) {
$restartRequired = $true
}
}
if ($restartRequired) {
Write-Warning "wsl2_restart_required=true; Windows optional feature changes require a runner reboot before WSL2 can be imported."
}
}
@@ -149,13 +127,12 @@ jobs:
Write-Host "wsl_list_exit=$($list.Code)"
$distros = @(Get-WslDistros)
if ($distros.Count -eq 0 -and $env:IMPORT_UBUNTU_WSL2 -eq "true" -and -not $restartRequired) {
if ($distros.Count -eq 0 -and $env:IMPORT_UBUNTU_WSL2 -eq "true") {
Write-Host "import_ubuntu_wsl2=true"
$wslRoot = "C:\wsl\UbuntuProbe"
$rootfs = "C:\wsl\ubuntu-noble-wsl.rootfs.tar.gz"
$rootfsUrl = Resolve-UbuntuWslRootfsUrl
New-Item -ItemType Directory -Force -Path @((Split-Path -Parent $rootfs), $wslRoot) | Out-Null
Invoke-WebRequest -Uri $rootfsUrl -OutFile $rootfs -UseBasicParsing
Invoke-WebRequest -Uri $env:UBUNTU_WSL_ROOTFS_URL -OutFile $rootfs -UseBasicParsing
$import = Invoke-WslText -Arguments @("--import", "UbuntuProbe", $wslRoot, $rootfs, "--version", "2")
Write-Host $import.Text
Write-Host "wsl_import_exit=$($import.Code)"
@@ -163,16 +140,12 @@ jobs:
Write-Host $list.Text
Write-Host "wsl_list_after_import_exit=$($list.Code)"
$distros = @(Get-WslDistros)
} elseif ($distros.Count -eq 0 -and $env:IMPORT_UBUNTU_WSL2 -eq "true" -and $restartRequired) {
Write-Warning "import_ubuntu_wsl2=skipped_restart_required"
}
if ($distros.Count -gt 0) {
$distro = $distros[0]
Write-Host "wsl_probe_distro=$distro"
$exec = Invoke-WslText -Arguments @("-d", $distro, "--exec", "bash", "-lc", 'set -euo pipefail; uname -a; if [ -f /etc/os-release ]; then sed -n "1,8p" /etc/os-release; fi')
} elseif ($restartRequired) {
$exec = [pscustomobject]@{ Code = 1; Text = "wsl_exec_skipped=restart_required" }
} else {
$exec = Invoke-WslText -Arguments @("--exec", "bash", "-lc", 'set -euo pipefail; uname -a; if [ -f /etc/os-release ]; then sed -n "1,8p" /etc/os-release; fi')
}
@@ -185,99 +158,17 @@ jobs:
if ($ok) {
"wsl2_ok=true" >> $env:GITHUB_OUTPUT
"wsl2_restart_required=false" >> $env:GITHUB_OUTPUT
"OPENCLAW_WSL2_PROBE_OK=true" >> $env:GITHUB_ENV
"OPENCLAW_WSL2_RESTART_REQUIRED=false" >> $env:GITHUB_ENV
Write-Host "wsl2_ok=true"
} else {
"wsl2_ok=false" >> $env:GITHUB_OUTPUT
"wsl2_restart_required=$($restartRequired.ToString().ToLowerInvariant())" >> $env:GITHUB_OUTPUT
"OPENCLAW_WSL2_PROBE_OK=false" >> $env:GITHUB_ENV
"OPENCLAW_WSL2_RESTART_REQUIRED=$($restartRequired.ToString().ToLowerInvariant())" >> $env:GITHUB_ENV
Write-Warning "wsl2_ok=false"
}
exit 0
- name: Try to exclude workspace from Windows Defender (best-effort)
if: ${{ inputs.run_windows_ci }}
shell: pwsh
run: |
$cmd = Get-Command Add-MpPreference -ErrorAction SilentlyContinue
if (-not $cmd) {
Write-Host "Add-MpPreference not available, skipping Defender exclusions."
exit 0
}
try {
Add-MpPreference -ExclusionPath "$env:GITHUB_WORKSPACE" -ErrorAction Stop
Add-MpPreference -ExclusionProcess "node.exe" -ErrorAction Stop
Write-Host "Defender exclusions applied."
} catch {
Write-Warning "Failed to apply Defender exclusions, continuing. $($_.Exception.Message)"
}
- name: Setup Node.js
if: ${{ inputs.run_windows_ci }}
shell: bash
env:
REQUESTED_NODE_VERSION: "22.x"
run: |
set -euo pipefail
source .github/actions/setup-pnpm-store-cache/ensure-node.sh
openclaw_ensure_node "$REQUESTED_NODE_VERSION"
- name: Setup pnpm
if: ${{ inputs.run_windows_ci }}
uses: ./.github/actions/setup-pnpm-store-cache
with:
node-version: 22.x
- name: Runtime versions
if: ${{ inputs.run_windows_ci }}
shell: bash
run: |
node -v
npm -v
pnpm -v
- name: Capture node path
if: ${{ inputs.run_windows_ci }}
shell: bash
run: |
node_bin="$(dirname "$(node -p 'process.execPath')")"
if command -v cygpath >/dev/null 2>&1; then
node_bin="$(cygpath -u "$node_bin")"
fi
echo "NODE_BIN=$node_bin" >> "$GITHUB_ENV"
- name: Install dependencies
if: ${{ inputs.run_windows_ci }}
shell: bash
env:
CI: true
run: |
export PATH="$NODE_BIN:$PATH"
which node
node -v
pnpm -v
pnpm install --frozen-lockfile --prefer-offline --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true --config.side-effects-cache=true || pnpm install --frozen-lockfile --prefer-offline --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true --config.side-effects-cache=true
- name: Run Windows CI tests
if: ${{ inputs.run_windows_ci }}
shell: bash
env:
CI: true
NODE_OPTIONS: --max-old-space-size=8192
OPENCLAW_TEST_SKIP_FULL_EXTENSIONS_SHARD: 1
OPENCLAW_VITEST_MAX_WORKERS: 1
run: |
set -euo pipefail
export PATH="$NODE_BIN:$PATH"
pnpm test:windows:ci
- name: Keep runner alive for SSH inspection
if: ${{ always() && !cancelled() }}
env:
KEEPALIVE_MINUTES: ${{ inputs.keepalive_minutes }}
run: |
@@ -294,13 +185,9 @@ jobs:
}
- name: Enforce WSL2 requirement
if: ${{ always() && !cancelled() && inputs.require_wsl2 }}
if: ${{ 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.
@@ -36,7 +35,7 @@ Skills own workflows; root owns hard policy and routing.
- One-sided fixes need sibling-surface proof, an explanation for why siblings are unaffected, or explicit follow-up work.
- Changelog findings: see Docs / Changelog.
- Public ClawSweeper comments prefer `https://docs.openclaw.ai/...` when a public docs page exists; structured evidence still cites repo files, lines, SHAs.
- Findings need current source, shipped/current behavior, tests/CI evidence, and dependency contract proof when dependency-backed behavior is involved. Validation is judged against touched and sibling surfaces plus this file's commands; clear evidence matters for user-visible changes, with Telegram/Desktop proof for Telegram-visible behavior when feasible.
- Findings need current source, shipped/current behavior, tests/CI evidence, and dependency contract proof when dependency-backed behavior is involved. Validation is judged against touched and sibling surfaces plus this file's commands; real behavior proof matters for user-visible changes, with Telegram/Desktop proof for Telegram-visible behavior when feasible.
- Prefer findings for concrete behavior regressions, missing changed-surface proof, owner-boundary violations, security/API contract issues, or docs/config mismatches.
- Do not file findings for repo policy preference when changed code follows the relevant scoped guide and no user-visible, runtime, security, or maintainer-risk impact is shown.
@@ -118,11 +117,11 @@ Skills own workflows; root owns hard policy and routing.
- Tests in a normal source checkout: `pnpm test <path-or-filter> [vitest args...]`, `pnpm test:changed`, `pnpm test:serial`, `pnpm test:coverage`; never raw `vitest`.
- If raw Vitest is unavoidable, use `vitest run ...`; bare `vitest ...` starts local watch mode and will not exit on its own.
- Tests in a Codex worktree or linked/sparse checkout: avoid direct local `pnpm test*`; use `node scripts/run-vitest.mjs <path-or-filter>` for tiny explicit-file proof, or Crabbox/Testbox for anything broader.
- Checks/lint in a normal source checkout: `pnpm check:changed` delegates to Crabbox/Testbox; lanes: `pnpm changed:lanes --json`; staged/path-scoped: `pnpm check:changed --staged` or `pnpm check:changed -- <files...>`; full `pnpm check`/`pnpm lint` only when required.
- Checks in a normal source checkout: `pnpm check:changed` delegates to Crabbox/Testbox; lanes: `pnpm changed:lanes --json`; staged: `pnpm check:changed --staged`; full: `pnpm check`.
- Checks in a Codex worktree or linked/sparse checkout: avoid direct local `pnpm check*`; use `node scripts/crabbox-wrapper.mjs run ... -- env OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed` so pnpm runs inside Testbox, not locally.
- Extension tests: `pnpm test:extensions`, `pnpm test extensions`, `pnpm test extensions/<id>`.
- Typecheck: `tsgo` lanes only (`pnpm tsgo*`, `pnpm check:test-types`); never add `tsc --noEmit`, `typecheck`, `check:types`.
- Formatting: `oxfmt`, not Prettier. Use repo wrappers (`pnpm format:*`, `scripts/run-oxlint.mjs`; full `pnpm lint:*` only when scope requires).
- Formatting: `oxfmt`, not Prettier. Use repo wrappers (`pnpm format:*`, `pnpm lint:*`, `scripts/run-oxlint.mjs`).
- Build before push when build output, packaging, lazy/module boundaries, dynamic imports, or published surfaces can change.
## Validation
@@ -143,9 +142,6 @@ Skills own workflows; root owns hard policy and routing.
## GitHub / PRs
- Fresh GitHub items: read `CONTRIBUTING.md`, the issue chooser/form, PR template, and `.github/CODEOWNERS`; blank issues are disabled; preserve templates and evidence requirements.
- Agent-authored/non-trivial work: create or reuse the issue first; tiny fixes may go direct. PRs use the template, link context, and keep durable problem/impact/evidence sections.
- Route support to Discord and security through `SECURITY.md`. Use listed maintainer areas/`CODEOWNERS`; never guess mentions.
- Use `$openclaw-pr-maintainer` immediately for maintainer-side OpenClaw issue/PR review, triage, duplicates, labels, comments, close, land, or evidence. Contributor PR creation/refresh follows the requested contributor workflow; linked refs alone do not require maintainer archive tooling.
- Issue/PR start: `git status -sb`; if clean, `git pull --ff-only`; if dirty, yell before pull/rebase.
- PR refs: `gh pr view/diff` or `gh api`, not web search. Prefer `gitcrawl` for maintainer discovery; missing/stale `gitcrawl` falls through to live `gh`, not contributor setup. Verify live with `gh` before mutation.
@@ -169,12 +165,13 @@ Skills own workflows; root owns hard policy and routing.
- Representing user: if user already has a comment/thread for the point, update/reply there when possible; avoid duplicate PR/issue comments.
- No surprise GH writes: chat must mention every posted/updated public comment with URL.
- GH comments with backticks, `$`, or shell snippets: use heredoc/body file, not inline double-quoted `--body`.
- PR create: real body required. Use the current template: `What Problem This Solves`, `Why This Change Was Made`, `User Impact`, and `Evidence`; include visible refs, behavior, and validation.
- PR create: real body required. Include Summary + Verification; mention refs, behavior, and proof.
- PR create/refresh: keep PR branches takeover-ready. Use a branch maintainers can push to, or for fork PRs ensure `maintainer_can_modify` / GitHub's `Allow edits by maintainers` is enabled unless explicitly told otherwise or GitHub's Actions/secrets warning makes that unsafe.
- GitHub issue/PR create: read `$agent-transcript`; ask about sanitized transcript logs when available.
- Contributor PRs: parsed context requires authored `What Problem This Solves` and `Evidence` sections. Do not require field-level proof forms; reviewers inspect code, tests, and CI for correctness.
- Contributor PRs: parsed `Real behavior proof` uses exact `field: value` labels: `Behavior addressed`, `Real environment tested`, `Exact steps or command run after this patch`, `Evidence after fix`, `Observed result after fix`, `What was not tested`.
- PR artifacts/screenshots: attach to PR/comment/external artifact store. Never push screenshots, videos, proof images, or proof assets to OpenClaw or any product repo branch, including temp artifact branches. Use Crabbox artifact publishing plus the manifest URL. Do not commit `.github/pr-assets`.
- CI polling: exact SHA, relevant checks only, minimal fields. Skip routine noise (`Auto response`, `Labeler`, docs agents, performance/stale). Logs only after failure/completion or concrete need.
- OpenClaw write-access maintainers may skip `Real behavior proof` when local tests or Crabbox verified behavior; record proof in PR verification.
- Agent PR landing to `main`: use only the repo-native `scripts/pr` wrapper: run `scripts/pr review-init <PR>`, follow its emitted checkout/guard guidance, initialize and complete review artifacts with `scripts/pr review-artifacts-init <PR>`, validate them with `scripts/pr review-validate-artifacts <PR>`, then run `scripts/pr prepare-run <PR>` and `scripts/pr merge-run <PR>`; do not idle on `auto-response` or `check-docs`.
## Code

File diff suppressed because it is too large Load Diff

View File

@@ -97,23 +97,6 @@ Welcome to the lobster tank! 🦞
4. **Test/CI-only PRs for known `main` failures** → Don't open a PR. The Maintainer team is already tracking those failures, and PRs that only tweak tests or CI to chase them will be closed unless they are required to validate a new fix.
5. **Questions** → Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828)
## Issue, PR, and Contact Routing
Start from this routing map before creating GitHub items:
| Situation | Use | Required evidence |
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- |
| Product bug, regression, crash, or behavior defect | [Bug report](https://github.com/openclaw/openclaw/issues/new?template=bug_report.yml) | Repro steps, expected vs actual behavior, version, OS, model/provider route when relevant, logs/screenshots, impact |
| Documentation bug or missing/contradictory docs | [Docs bug report](https://github.com/openclaw/openclaw/issues/new?template=docs_bug_report.yml) | Affected docs path or URL, verification steps, expected docs content, actual docs content, impact, evidence |
| New feature, architecture change, or product improvement | [Feature request](https://github.com/openclaw/openclaw/issues/new?template=feature_request.yml) or Discord first | Problem, proposed solution, alternatives, impact, examples or prior art |
| Onboarding, setup help, or general support question | Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828) | Do not open a GitHub issue unless there is a concrete product defect or docs gap |
| Security vulnerability | See [Report a Vulnerability](#report-a-vulnerability) below | Do not file public issues for private security reports |
| PR for an existing or newly filed issue | Use the [PR template](.github/pull_request_template.md) | Visible `Closes #<issue>` or `Related: #<issue>`, problem, shipped solution, user impact, validation evidence |
For agent-authored or otherwise non-trivial work, create or reuse the issue first, then open the PR against it. Bugs and very small fixes may go straight to PR, but still link existing context when it exists and fill out the PR template.
Do not guess who to tag. Let issue forms, labels/automation, `.github/CODEOWNERS`, and the maintainer areas above route the work. Mention a maintainer only when their listed area or owned path is directly relevant and you need a decision; otherwise rely on normal review. For coordinated change sets, ask in **#clawtributors** before opening more than the PR limit.
## PR Limits
We cap at **20 open PRs per author**. If you exceed this, the `r: too-many-prs` label is added and your PR is auto-closed. This is a hard limit.
@@ -123,8 +106,7 @@ For coordinated change sets that genuinely need more than 20 PRs, join the **#cl
## Before You PR
- Test locally with your OpenClaw instance
- External PRs must describe the user, product, or operational problem in **What Problem This Solves** and include useful validation in **Evidence**. Focused tests, CI results, screenshots, recordings, terminal output, live observations, redacted logs, and artifact links all count. Reviewers will inspect the code, tests, and CI; use the PR body to explain intent and make validation easy to understand.
- When ClawSweeper, Codex, Barnacle, or a maintainer asks for more context or evidence, edit the PR description instead of only replying in a new comment. Keep **What Problem This Solves**, **Why This Change Was Made**, **User Impact**, and **Evidence** current; a short comment can point reviewers to the update, but the PR body should remain the durable explanation for maintainers and bots.
- External PRs must include a filled **Real behavior proof** section in the PR body. Show the real setup you tested, the exact command or steps you ran after the patch, after-fix evidence, the observed result, and anything you did not test. Screenshots, recordings, terminal screenshots, console output, copied live output, linked artifacts, and redacted runtime logs all count. Unit tests, mocks, snapshots, lint, typechecks, and CI are useful but do not satisfy this requirement by themselves. Maintainers may apply `proof: override` only when the proof gate should not apply.
- Keep PRs takeover-ready: open them from a branch maintainers can push to. For fork PRs, leave GitHub's **Allow edits by maintainers** option enabled so maintainers can finish urgent fixes, changelog entries, or merge prep when needed. If GitHub shows **Allow edits and access to secrets by maintainers**, enable it only when that workflow/secrets access is acceptable and say so in the PR.
- Do not edit `CHANGELOG.md` in contributor PRs. Maintainers or ClawSweeper add the changelog entry when landing user-facing changes.
- Run tests: `pnpm build && pnpm check && pnpm test`
@@ -187,7 +169,7 @@ Built with Codex, Claude, or other AI tools? **Awesome - just mark it!**
Please include in your PR:
- [ ] Mark as AI-assisted in the PR title or description
- [ ] Include a concise **Evidence** section with the most useful validation. Reviewers will inspect the code, tests, and CI rather than relying on the PR body alone.
- [ ] Include human-run real behavior proof from your own setup. AI-generated tests, mocks, lint, typechecks, and CI output are supplemental only; they do not prove the fix works for users.
- [ ] Include prompts or session logs if possible (super helpful!)
- [ ] Confirm you understand what the code does
- [ ] If you have access to Codex, run `codex review --base origin/main` locally and address the findings before asking for review

View File

@@ -304,9 +304,6 @@ by Peter Steinberger and the community.
## Community
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
Use the [issue chooser](https://github.com/openclaw/openclaw/issues/new/choose) for bugs, docs bugs, and feature requests;
ask setup/support questions in [Discord](https://discord.gg/clawd); and report vulnerabilities through [SECURITY.md](SECURITY.md).
PRs should link the relevant issue when possible and follow the [PR template](.github/pull_request_template.md) with problem, impact, and evidence.
AI/vibe-coded PRs welcome! 🤖
Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and for

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,53 +2,6 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>OpenClaw</title>
<item>
<title>2026.6.10</title>
<pubDate>Fri, 26 Jun 2026 23:37:36 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2606001090</sparkle:version>
<sparkle:shortVersionString>2026.6.10</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.6.10</h2>
<h3>Highlights</h3>
<ul>
<li><strong>Automatic fast mode for talks:</strong> 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.</li>
<li><strong>More reliable model routing:</strong> 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.</li>
<li><strong>Safer session and channel state:</strong> 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.</li>
<li><strong>Trusted policies survive hook composition:</strong> composed hook registries keep the trusted tool policies required by approval-sensitive flows. (#94545) Thanks @jesse-merhi.</li>
</ul>
<h3>Changes</h3>
<ul>
<li><strong>Agent and channel runtime:</strong> 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.</li>
<li><strong>Provider behavior:</strong> 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.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li><strong>Fast-mode and policy correctness:</strong> 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.</li>
<li><strong>Model and delivery edge cases:</strong> 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.</li>
<li><strong>Provider plugin onboarding:</strong> 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.</li>
</ul>
<h3>Complete contribution record</h3>
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.
<h4>Pull requests</h4>
<ul>
<li><strong>PR #86627</strong> Keep core doctor health in contribution order. Thanks @giodl73-repo.</li>
<li><strong>PR #93580</strong> fix: preserve cron delivery awareness for target sessions. Thanks @scotthuang and @jalehman.</li>
<li><strong>PR #95030</strong> refactor: add SDK transcript identity target API. Thanks @jalehman.</li>
<li><strong>PR #94838</strong> refactor(copilot): complete harness lifecycle parity. Thanks @vincentkoc.</li>
<li><strong>PR #95328</strong> fix(sessions): reset stale per-channel origin fields on channel switch. Related #95325. Thanks @ZengWen-DT and @jalehman and @gorkem2020.</li>
<li><strong>PR #94461</strong> fix(zai): fall back to manifest baseUrl for synthesized GLM-5 models. Related #94269. Thanks @Pandah97 and @chrysb.</li>
<li><strong>PR #93241</strong> fix(agents): classify Zhipu GLM overload as overloaded for failover. Related #93211. Thanks @0xghost42 and @zhengli0922.</li>
<li><strong>PR #94067</strong> fix(channels): resolve native /think menu levels via runtime catalog for live-discovered models. Related #93835. Thanks @openperf and @civiltox.</li>
<li><strong>PR #94136</strong> fix(zai): expose GLM-5.2 reasoning levels [AI-assisted]. Thanks @BorClaw.</li>
<li><strong>PR #85104</strong> feat: fast talks auto mode. Related #85087. Thanks @alexph-dev.</li>
<li><strong>PR #94545</strong> fix: keep trusted policies with hook registry. Thanks @jesse-merhi.</li>
<li><strong>PR #95792</strong> fix(onboard): refresh provider plugin registry after setup installs. Related #95765. Thanks @snowzlmbot.</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.6.10/OpenClaw-2026.6.10.zip" length="56115790" type="application/octet-stream" sparkle:edSignature="MEeGG8+WePhUg9uDShznmdhhAgy/WWe7bAwr4XRTauNdrM441iziQYIlwhfNrtHDHX+uE1/tkRtIMcELfuekAg=="/>
</item>
<item>
<title>2026.6.8</title>
<pubDate>Tue, 16 Jun 2026 17:17:20 +0000</pubDate>
@@ -171,5 +124,132 @@ This audited record covers the complete v2026.6.9..HEAD history: 12 merged PRs.
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.6.5/OpenClaw-2026.6.5.zip" length="55725877" type="application/octet-stream" sparkle:edSignature="EKr7gCfpEVStis9HSADJk1CWYbmH2MHMqSgNfZvLbBFCBWmk3pjBJS6K2qkxkq5lIbTj4H+Lo7Iri6ip/xTGDA=="/>
</item>
<item>
<title>2026.6.1</title>
<pubDate>Wed, 03 Jun 2026 21:26:22 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026060190</sparkle:version>
<sparkle:shortVersionString>2026.6.1</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.6.1</h2>
<h3>Highlights</h3>
<ul>
<li>Agents and CLI-backed runtimes recover more cleanly from interrupted tool calls, stale session bindings, compaction handoffs, and media delivery retries. (#88129, #88136, #88141, #88162, #88182)</li>
<li>Channels and mobile delivery are steadier across Telegram, WhatsApp, iMessage, Slack, Discord, Microsoft Teams, Google Chat, Google Meet, and iOS realtime Talk. (#88096, #88105, #88183, #88231)</li>
<li>Provider and plugin requests now bound more timers, retries, OAuth/device-code lifetimes, media downloads, local service probes, and generated-content polling paths before they can hang a run.</li>
<li>Skills, session metadata, gateway runtime state, plugin metadata, memory watchers, and store writes do less repeated work on hot paths while keeping config, dispatch, and Linux file-watch behavior stable. (#89185, #89188, #85351) Thanks @RomneyDa and @NianJiuZst.</li>
<li>Skills and plugin loading now handle stale disabled snapshots and loader failures more clearly, so channel turns avoid disabled SecretRefs and operators get better recovery guidance. (#79072, #79173) Thanks @zeus1959.</li>
<li>Workboard, SecretRef plugin manifests, hosted iOS push relay, and external Copilot/Tokenjuice packaging add broader orchestration, integration, and plugin delivery surfaces. (#82326, #87469, #87796, #88107, #88117)</li>
<li>Skill Workshop now has a fuller Control UI flow with proposal lists, today actions, revision handoff, searchable file previews, review states, locale coverage, and reusable session routing.</li>
<li>Chat and Control UI startup paths keep sends alive through history loading, stream deltas incrementally, skip markdown work while streaming, keep drafts local while typing, clear the composer after sends, trace first-output latency, prioritize first connect, and expose calmer composer controls. (#88772, #88825, #88998, #89030, #89106) Thanks @vincentkoc and @sallyom.</li>
<li>Provider coverage and model metadata now include MiniMax M3, account OAuth endpoints, Google/Vertex catalog fixes, OpenRouter SQLite model caching, Copilot Claude 1M capabilities, Foundry reasoning alignment, and OpenAI response replay guards. (#88480, #88512, #88851, #88860)</li>
<li>iMessage monitor state, inbound queues, and plugin install ledgers moved toward SQLite-backed state so restarts and local monitors recover with less duplicate filesystem scanning. (#88794, #88797)</li>
<li>Release, CI, Docker, E2E, plugin install, and diagnostics lanes now cap more logs, response bodies, readiness probes, artifact checks, status polling, child workflow waits, docker package cleanup, quiet test stalls, and rollback snapshots so failures report bounded proof instead of stalling. (#88966) Thanks @RomneyDa.</li>
</ul>
<h3>Changes</h3>
<ul>
<li>Docs: add a dedicated Skill Workshop guide covering governed skill creation, reviewable proposals, CLI, Gateway, agent tool behavior, approval policy, support files, and recovery, and refresh the ClawHub showcase cards. (#88734) Thanks @shakkernerd and @vyctorbrzezowski.</li>
<li>Skills: let the <code>skill_workshop</code> agent tool apply, reject, and quarantine explicit proposals through the guarded review flow. Thanks @shakkernerd.</li>
<li>Skills: let proposals carry approved support files under standard skill folders, with scanner, hash, and rollback safeguards. Thanks @shakkernerd.</li>
<li>Skills: let pending proposals be revised in place with versioned, dated proposal frontmatter before approval. Thanks @shakkernerd.</li>
<li>Skills: add Skill Workshop with pending proposals, CLI/Gateway review actions, rollback metadata, and the <code>skill_workshop</code> agent tool. Thanks @shakkernerd.</li>
<li>Skill Workshop: add the Control UI navigation, styled dashboard, proposal today view, revision dialog, file preview modal, searchable preview files, reusable session handoff, and localized strings.</li>
<li>Plugins: externalize Tokenjuice as the official <code>@openclaw/tokenjuice</code> plugin with npm and ClawHub publish metadata.</li>
<li>Plugins: externalize the GitHub Copilot agent runtime as the official <code>@openclaw/copilot</code> plugin with npm and ClawHub publish metadata.</li>
<li>iOS: add hosted push relay defaults, realtime Talk playback, and a guarded WebSocket ping path for more reliable mobile sessions. (#88096, #88105, #88231)</li>
<li>iOS: support native iPad display layouts.</li>
<li>Workboard: add orchestration primitives and agent coordination tools for multi-agent planning and run tracking. (#87469)</li>
<li>Workboard: wire task-backed board runs and show task comments in the edit modal.</li>
<li>Code mode: add internal namespaces for scoped agent/global sessions and exact namespace tool dispatch. (#88043)</li>
<li>Code mode: add MCP API files and docs for code-mode integrations.</li>
<li>Control UI: add a Dreaming-tab agent selector and propagate the selected agent through Dreaming status, diary, and diary actions. (#78748) Thanks @stevenepalmer.</li>
<li>Control UI: add calmer chat composer controls, local draft typing state, and first-output latency instrumentation for active chat entry. (#88772, #88998) Thanks @vincentkoc.</li>
<li>Plugins: add a SecretRef provider integration manifest contract and extract shared LLM core packages for provider/plugin reuse. (#82326, #88117)</li>
<li>Plugins: persist the plugin install index in SQLite so installed package lookup survives reloads with less filesystem scanning. (#88794)</li>
<li>Providers: add MiniMax M3 model support. (#88860)</li>
<li>Doctor: add disk space health checks and stabilize post-upgrade JSON probes.</li>
<li>Channels: store inbound queues in SQLite and migrate iMessage monitor state to SQLite-backed tracking. (#88797)</li>
<li>Skills: add the core skills index and centralize skills runtime loading, status, filtering, and prompt formatting.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Release/CI/E2E: fail early when Crabbox sparse-sync full checkouts do not have enough local disk, with guidance for moving the sync root.</li>
<li>Build: render independent CLI startup metadata help snapshots concurrently to cut cold build-all metadata time.</li>
<li>Plugins: stop timed-out package-boundary prep steps by process group so descendant TypeScript/helper processes do not survive local check cleanup.</li>
<li>Control UI: serve static assets asynchronously after safe-open checks so large UI files do not block Gateway request handling.</li>
<li>Scripts/UI: forward direct wrapper SIGHUP shutdown to child processes so terminal hangups do not leave wrapped dev commands running.</li>
<li>Gateway: return the post-expiration pending-work revision from node drains so reconnecting nodes do not observe stale queue revisions after expired items are pruned.</li>
<li>Release/CI/E2E: keep temporary full-sync checkouts alive while slow Crabbox leases boot, so sparse worktree runs do not lose their sync source before file-list generation.</li>
<li>Release/CI/E2E: normalize inherited Linux <code>C.UTF-8</code> locale settings before raw AWS macOS Crabbox bootstrap commands, avoiding macOS locale warnings during package-manager hydration.</li>
<li>Release/CI/E2E: keep gateway watch regression checks from copying large static plugin assets inside the measured idle window.</li>
<li>Update: keep core updates nonblocking when a missing external plugin repair download stalls, while still blocking installed active plugin payload smoke failures.</li>
<li>Agents/providers: keep streaming tool-call argument parsing record-shaped when providers emit valid non-object JSON such as <code>null</code> or arrays.</li>
<li>Release/CI/E2E: reset incremental log readers when watched log files rotate without shrinking, so same-size replacements do not hide new readiness or RPC lines.</li>
<li>Talk: preserve explicit <code>null</code> payloads on controller-created turn and output-audio lifecycle events.</li>
<li>Agents/TUI: keep local custom provider runs from loading plugin runtime and auth alias metadata when plugins are disabled.</li>
<li>Agents/TUI: restore in-flight TUI run switch-back behavior, keep no-policy native hook fallback available, guard vanished workspaces, and keep lightweight isolated subagents lightweight.</li>
<li>Agents/media: keep async image, music, and video generation starts from ending the Codex turn, so mixed requests can continue with summaries or other work while media renders in the background.</li>
<li>Agents/Codex: keep public OpenAI API-key profiles from being treated as native Codex app-server auth while preserving persisted Codex OAuth sessions.</li>
<li>Agents/Codex: stream Codex app-server final-answer partials to live reply previews, preserve ACP metadata in SQLite, prefer real tool results over synthetic repair output, prevent aborted app-server turn handles from lingering, migrate legacy OpenAI Codex <code>lastGood</code> auth state, and preserve workspace/session metadata through ACP runtime refactors. (#88405, #88724, #88730) Thanks @vincentkoc.</li>
<li>Control UI: keep collapsed tool cards labeled with the tool name and action instead of generic output text. Thanks @shakkernerd.</li>
<li>Agents/Codex: surface Skill Workshop guidance in Codex app-server prompts when <code>skill_workshop</code> is available. Thanks @shakkernerd.</li>
<li>Skill Workshop: restore and localize the Control UI board/today view switcher so review workflows keep their intended layout toggle across locales. Thanks @shakkernerd.</li>
<li>Agents/auth: write auth profiles atomically, dispatch auth failures by type, add force re-login recovery, preserve workspaces during state-only uninstall, and compact before oversized turns so recovery paths avoid partial state. (#89181) Thanks @RomneyDa.</li>
<li>Skills: skip disabled skill env overrides from stale persisted snapshots so disabled skill <code>apiKey</code> SecretRefs cannot abort embedded or channel turns. (#79072, #79173) Thanks @zeus1959.</li>
<li>Skill Workshop: render the Control UI tab from filtered navigation state and keep filtered fallback routing stable.</li>
<li>CLI: avoid live catalog validation during <code>openclaw agents add</code>, so adding a secondary agent no longer depends on provider catalog availability. (#76284, #88314) Thanks @zhangguiping-xydt.</li>
<li>CLI: keep <code>plugins list --json</code> on the snapshot-only path so plugin sweeps avoid loading the full runtime status graph.</li>
<li>CLI/desktop: bridge WSL clipboard operations through the shell, recognize manual-update launchd jobs, and keep machine-readable startup output parseable during progress setup. (#88764, #88689) Thanks @alexzhu0.</li>
<li>Plugins: make PixVerse external-plugin ClawHub metadata explicit and keep it out of bundled dist builds.</li>
<li>Plugins: clarify plugin loader failure guidance so missing or incompatible plugin packages point operators at the right repair path.</li>
<li>Plugins: preserve npm plugin roots after blocked installs, skip plugin-local <code>openclaw</code> peer symlinks during rollback snapshots, relink those peers after restore, isolate cached tool runtime siblings, and isolate web-provider factory failures so one bad plugin does not poison sibling runtime paths. (#77237, #88807)</li>
<li>Cron: keep SQLite cron migrations compatible with legacy run-log tables, archived job stores, diagnostic cron names, and legacy one-shot delete-after-run behavior. (#88285)</li>
<li>Cron: keep update delivery validation scoped, harden restart state, and retire MCP runtimes on isolated cron cleanup.</li>
<li>Memory: serialize QMD update/embed writes per store, reduce Linux watcher fan-out, retry transient FileProvider-backed reads, preserve phase signals on read errors, harden envelope metadata sanitization, reattach Linux native watchers when directories are recreated, and rewrite generated transcript paths on rollover so memory/search state survives concurrent gateway and CLI activity. (#66339, #85931, #89185, #89188, #85351) Thanks @openperf, @amittell, @RomneyDa, and @NianJiuZst.</li>
<li>Memory: keep vector-disabled FTS indexes from resolving embedding providers during sync and search.</li>
<li>Providers: bound generated media downloads from OpenAI, Runway, xAI, MiniMax, BytePlus, DashScope-compatible, FAL, OpenRouter, Google, Vydra, and Comfy providers.</li>
<li>Providers: resolve Google defaults to <code>google-generative-ai</code>, register Vertex static catalog rows, align Foundry reasoning metadata, skip DeepSeek V4 thinking params on Foundry fallback, use MiniMax account OAuth endpoints, preserve Copilot Claude 1M capabilities, suppress disabled Ollama reasoning output, forward Gemini stop sequences, strip Kimi-incompatible Anthropic cache markers, keep OpenAI stop-finished tool calls, and avoid replay ids when the Responses store is disabled. (#88480, #88512, #76612) Thanks @coder999999999, @BryanTegomoh, and @vliuyt.</li>
<li>Providers: cap GitHub Copilot OAuth request timeouts before creating abort signals.</li>
<li>Cron: retry recurring jobs after transient model rate limits before waiting for the next scheduled slot.</li>
<li>Agents/Codex: keep live session locks during cleanup, recover interrupted CLI tool transcripts, preserve Codex auth and compaction session identity, clear orphan tool state, cap app-server idle timers, and keep media completion delivery retryable. (#88129, #88136, #88141, #88162, #88182)</li>
<li>Chat/UI: show Gateway chat failures as visible assistant messages in the Control UI instead of only setting an invisible error state.</li>
<li>Channels: cap Telegram, Discord, WhatsApp, Signal, Feishu, Google Chat, Microsoft Teams, QQBot, Nostr, Zalo, Zalouser, and Nextcloud-style request/retry timers; preserve SMS approval reply routes; and retry WhatsApp QR login 408 timeouts. (#88183)</li>
<li>Security/config parsing: reject unsafe OAuth/token lifetimes, retry-after delays, inbound timestamps, response body sizes, command timeout config, sandbox observer token TTLs, and gateway WebSocket calls after close.</li>
<li>Providers/media: cap local service, model, usage, queue, generated media, TTS, music, workflow polling, and provider OAuth request timers across hosted and local providers.</li>
<li>Release/CI/E2E: bound release candidate reads, beta smoke REST calls, plugin npm verification commands, changelog restore, cross-OS process groups, kitchen-sink and bundled plugin readiness probes, secret-provider probes, Telegram credential timeouts, Control UI i18n and CLI startup metadata generation, Vitest routing, dependency guard admin approvals, child workflow failure detection, quiet Node test shard stalls, docker package cleanup, and mainline test flakes. (#88127, #88137, #88155, #88160, #88966) Thanks @RomneyDa.</li>
<li>Release/CI/E2E: keep Kitchen Sink live plugin MCP probes resolving source-checkout workspace packages and align the live gauntlet with current Kitchen Sink diagnostics.</li>
<li>Release/CI/E2E: run the secret-provider integration proof through the repo pnpm runner so native macOS and Windows validation use the hydrated package-manager shim.</li>
<li>Release/CI/E2E: run the Telegram desktop proof gateway through the repo pnpm runner so native macOS proof uses the hydrated package-manager shim.</li>
<li>Docs/CI: run Mintlify anchor checks through the repo pnpm runner so docs link validation works when pnpm is only available through the hydrated package-manager shim.</li>
<li>Agents: keep configured fallback model metadata typed so provider params, context-token caps, and media input limits do not break changed-gate typechecks.</li>
<li>Agents: accept hidden <code>sessions_send</code> body aliases before validation while keeping the model-facing <code>message</code> schema canonical. (#88229) Thanks @zhangguiping-xydt.</li>
<li>Chat/UI: preserve startup chat sends during history loading, unblock the initial Control UI chat send, stream chat deltas incrementally, skip markdown parsing while streaming, keep drafts local while typing, guard composer rerenders, honor Chromium executable overrides, and detect system Chromium for E2E. (#88998) Thanks @vincentkoc.</li>
<li>Channels: stop schema-padded poll modifiers from turning normal <code>send</code> actions into invalid poll sends. (#89601) Thanks @codezz.</li>
<li>Channels: preserve long Feishu streaming replies, send visible fallbacks when accepted Feishu turns produce no final reply, tolerate iMessage self-chat timestamp skew, preserve colon-prefixed slash commands in mention parsing, decode Nostr <code>npub</code> allowlists correctly, and suppress raw provider errors during channel delivery. (#87896)</li>
<li>Config/status/doctor: skip unresolved shell references in state-dir dotenv files, resolve gateway auth secrets during deep status audits, respect explicit PI runtime policy, report runtime tool-schema errors, and keep post-upgrade JSON stable. (#88288)</li>
<li>Gateway/session state: list commands from the Gateway plugin registry, harden MCP loopback tool schemas, hide phantom agent-store rows from <code>sessions.list</code>, make task persistence failures explicit, and carry session UUIDs on interactive dispatch events.</li>
<li>Gateway/plugins: narrow plugin lookup memoization to the stable plugin/runtime inputs, avoiding repeated lookup work without mixing disabled or filtered plugin state.</li>
<li>OpenAI/TTS: handle speed directives for OpenAI TTS voices. (#74089)</li>
<li>CI/Crabbox: keep default runner capacity on the Azure credit-backed on-demand D4 lane with the Azure SSH port and a Git-independent full check job, so broad validation avoids low-priority spot quota stalls, hydrate port mismatches, non-Git hydrated workspaces, and stale AWS region hints.</li>
<li>CI/Crabbox: route Crabbox wrapper and Testbox workflow edits to their regression tests so changed-test gates do not silently run zero specs.</li>
<li>CI/workflows: route workflow sanity helper edits to their guard tests and cover composite-action input interpolation checks.</li>
<li>CI/tooling: route CI scope, dependency, changelog, and docs helper edits to their owner tests instead of silently skipping changed-test coverage.</li>
<li>CI/tooling: route package, release, and install helper edits to their owner tests so changed-test gates cover publish and installer script changes.</li>
<li>CI/tooling: route shared script library edits through their owner tests so lock, process, safety, and scan helpers do not skip changed-test coverage.</li>
<li>CI/tooling: skip expensive import-graph scans once a changed diff already requires broad fallback, keeping local changed-test planning fast while still collecting explicit owner tests.</li>
<li>CI/tooling: route script edits through conventional owner tests when matching <code>test/scripts</code> or <code>src/scripts</code> coverage already exists.</li>
<li>CI/tooling: honor option terminators in the memory FD repro script so follow-on arguments are not reparsed.</li>
<li>Release/CI/E2E: assert plugin lifecycle runtime inspect output instead of only capturing it.</li>
<li>Release/CI/E2E: make gateway-network prove the advertised health RPC and retry early WebSocket closes without burning full open timeouts.</li>
<li>Release/CI/E2E: honor option terminators across release, Parallels smoke, plugin gauntlet, and extension-memory scripts.</li>
<li>Release/CI/E2E: fail plugin gateway gauntlet QA chunks when the requested suite summary is missing or invalid.</li>
<li>Performance: prebuild QA runtime probes with generated plugin assets but without CLI startup metadata.</li>
<li>Performance: skip declaration bundling for runtime-only CLI startup and gateway watch build profiles.</li>
<li>Performance: reuse prepared provider handles, strict tool schemas, gateway runtime metadata, session maintenance config, plugin metadata, bundled skill allowlists, package-local plugin artifacts, single-entry store writes, and validated/serialized session prompt blobs.</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.6.1/OpenClaw-2026.6.1.zip" length="55062100" type="application/octet-stream" sparkle:edSignature="PVp8E2HBCvikB/0LCr36lFEyHPAzoFA2ScT6LW27FlzvP+m4r1AEuVN2UrtgWlpkGSsn4Eav0kPJe32u4ObNBw=="/>
</item>
</channel>
</rss>

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

@@ -69,17 +69,6 @@ Generate raw Google Play screenshots:
pnpm android:screenshots
```
To make screenshot capture own emulator startup, pass a named AVD:
```bash
ANDROID_SCREENSHOT_AVD=OpenClaw_QA_API35 pnpm android:screenshots
```
The screenshot script uses one connected ADB device when available. If none is
connected and `ANDROID_SCREENSHOT_AVD` is set, it boots that emulator
headlessly, waits for Android to finish booting, disables animations, captures
the screenshots, then shuts down the emulator it started.
`pnpm android:release:archive` builds signed release artifacts into `apps/android/build/release-artifacts/` and writes `.sha256` checksum files:
- Play build: `openclaw-<version>-play-release.aab`

View File

@@ -49,45 +49,13 @@ Recommended workflow:
3. Update `apps/android/CHANGELOG.md`, then run `pnpm android:version:sync` again if needed.
4. Run `MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:sync:pull` to materialize encrypted Android signing assets from `apps-signing`.
5. Run `pnpm android:release:preflight` to validate Play auth, signing, synced versioning, and release notes.
6. Run `ANDROID_SCREENSHOT_AVD=<avd-name> pnpm android:screenshots` to refresh raw Google Play screenshots with a script-managed emulator, or run `pnpm android:screenshots` when exactly one ADB device is already connected.
6. Run `pnpm android:screenshots` to refresh raw Google Play screenshots.
7. Run `pnpm android:release:archive` to produce the signed Play AAB and third-party APK.
8. Run `pnpm android:release:upload` to upload metadata, screenshots, and the Play AAB to Google Play internal testing.
9. Promote to production manually in Google Play Console.
The third-party flavor is archived as a signed APK for non-Play distribution. It is not uploaded by the Play release lane.
## Release SHA tracking
Successful Play build uploads create a non-tag Git ref that records the source
commit for the uploaded store build:
```text
refs/openclaw/mobile-releases/android/<versionName>-<versionCode>
```
Example:
```text
refs/openclaw/mobile-releases/android/2026.6.10-2026061008
```
These refs are intentionally outside `refs/tags/*` and `refs/heads/*`. They do
not appear on GitHub release or tag pages, and they do not participate in the
core OpenClaw release machinery.
`pnpm android:release:upload` checks the ref before uploading the Play build and
records it only after `upload_to_play_store` succeeds. Existing refs are
immutable: the same ref at the same SHA is accepted, while the same ref at a
different SHA fails. `GOOGLE_PLAY_VALIDATE_ONLY=1` still checks the ref but does
not record it because no Play build is published.
Useful direct commands:
```bash
pnpm mobile:release:preflight -- --platform android --version 2026.6.10 --version-code 2026061008
pnpm mobile:release:resolve -- --platform android --version 2026.6.10 --version-code 2026061008
```
## Signing model
`apps/android/Config/ReleaseSigning.json` pins the Android signing assets in the shared private `apps-signing` repo. The Android pipeline uses the same `MATCH_PASSWORD` release-owner secret as iOS, but the Android files are managed by `scripts/android-release-signing.mjs` instead of Fastlane `match`.

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

@@ -223,11 +223,10 @@ class NodeForegroundService : Service() {
internal fun foregroundServiceTypesForVoiceMode(mode: VoiceCaptureMode): Int {
val base = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
return when (mode) {
VoiceCaptureMode.Off -> base
VoiceCaptureMode.ManualMic,
VoiceCaptureMode.TalkMode,
-> base or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
return if (mode == VoiceCaptureMode.TalkMode) {
base or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
} else {
base
}
}

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)
@@ -1439,9 +1396,8 @@ class NodeRuntime(
mode: VoiceCaptureMode,
persistManualMic: Boolean = true,
) {
if (mode.requiresMicrophonePermission && !hasRecordAudioPermission()) {
if (mode == VoiceCaptureMode.TalkMode && !hasRecordAudioPermission()) {
_voiceCaptureMode.value = VoiceCaptureMode.Off
prefs.setVoiceMicEnabled(false)
externalAudioCaptureActive.value = false
return
}
@@ -1512,9 +1468,6 @@ class NodeRuntime(
}
}
private val VoiceCaptureMode.requiresMicrophonePermission: Boolean
get() = this == VoiceCaptureMode.ManualMic || this == VoiceCaptureMode.TalkMode
fun refreshGatewayConnection() {
val endpoint = connectedEndpoint
if (endpoint == null) {
@@ -1867,47 +1820,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 +1839,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 +2080,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 +2194,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 +2217,7 @@ class NodeRuntime(
?: root["message"].asStringOrNull()
?: line
val normalizedMessage =
sanitizeGatewayLogText(message)
message
.trim()
.replace(Regex("\\s+"), " ")
.take(240)
@@ -2496,9 +2225,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 +2315,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 +2765,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 +2842,6 @@ data class GatewaySkillsSummary(
)
data class GatewaySkillSummary(
val skillKey: String,
val name: String,
val description: String?,
val source: String,
@@ -3308,19 +3039,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

@@ -114,12 +114,16 @@ private fun ConnectScene() {
@Composable
private fun ChatScene() {
ChatBubble(label = "You", text = "Hi Molty, are you there?")
ChatBubble(label = "You", text = "Summarize the launch checklist before I start the release.")
ChatBubble(
label = "Molty",
text = "Always. Lurking in the shadows, exfoliating.",
label = "OpenClaw",
text = "Android archive, Play metadata, and internal testing upload are ready. Screenshots are being refreshed now.",
raised = true,
)
CompactList(
title = "Working set",
rows = listOf("Release notes", "Play bundle", "Device screenshots"),
)
}
@Composable

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()

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