Compare commits

..

16 Commits

Author SHA1 Message Date
Peter Steinberger
6af574c587 docs: document QQBot groups and Yuanbao 2026-04-28 02:59:04 +01:00
Peter Steinberger
1158e80099 test(macos): stabilize gateway control test 2026-04-28 02:48:45 +01:00
Peter Steinberger
be8c24633a docs: consolidate 2026.4.26 changelog 2026-04-28 02:12:58 +01:00
Peter Steinberger
414b68df6f chore(release): prepare 2026.4.26 stable 2026-04-28 02:10:33 +01:00
Peter Steinberger
2c7f65e025 docs: record npm provenance in release evidence 2026-04-28 01:57:17 +01:00
Peter Steinberger
7c44230fda ci: support ref-backed telegram package e2e 2026-04-28 01:45:13 +01:00
Peter Steinberger
09d044e0f4 test: tolerate old Windows updater restart handoff 2026-04-28 00:59:29 +01:00
Peter Steinberger
a230f0f558 test: tolerate transient release validation output 2026-04-27 23:56:18 +01:00
Peter Steinberger
aed6ccf090 test: stabilize live release checks 2026-04-27 23:34:20 +01:00
Peter Steinberger
17596fc484 fix(update): restart Windows startup gateway after update 2026-04-27 23:14:17 +01:00
Peter Steinberger
42220c929a ci: record package proof in release evidence 2026-04-27 21:58:17 +01:00
Peter Steinberger
0518ff6f35 chore(release): refresh config baseline after coven removal 2026-04-27 19:54:51 +01:00
Peter Steinberger
6b97dee4c1 chore(release): refresh plugin sdk api baseline 2026-04-27 19:50:53 +01:00
Peter Steinberger
4623bba650 chore(release): refresh config docs baseline 2026-04-27 19:50:53 +01:00
Peter Steinberger
f02ced0fab chore(release): refresh bundled channel metadata 2026-04-27 19:50:53 +01:00
Peter Steinberger
49aa1d07b0 chore(release): prepare 2026.4.26 beta 1 2026-04-27 19:50:53 +01:00
2827 changed files with 24127 additions and 88775 deletions

View File

@@ -293,15 +293,9 @@ checks that need parity or remote state.
5. If tests fail, fix code and re-run against the same warm box.
6. If you changed dependency manifests (package.json, etc.), prepend
the install command: `blacksmith testbox run --id <ID> "npm install && npm test"`
7. If a narrow PR reports a full sync or the box was reused/expired, sanity
check the remote copy before a slow gate:
`blacksmith testbox run --id <ID> "pnpm testbox:sanity"`.
If it reports missing root files or mass tracked deletions, stop the box and
warm a fresh one. Use `OPENCLAW_TESTBOX_ALLOW_MASS_DELETIONS=1` only for an
intentional large deletion PR.
8. If you need artifacts (coverage reports, build outputs, etc.), download them:
7. If you need artifacts (coverage reports, build outputs, etc.), download them:
`blacksmith testbox download --id <ID> coverage/ ./coverage/`
9. Once green, commit and push.
8. Once green, commit and push.
## OpenClaw full test suite
@@ -320,12 +314,6 @@ When validating before commit/push in maintainer Testbox mode, run
`pnpm check:changed` inside the warmed box first when appropriate, then the full
suite with the profile above if broad confidence is needed.
Run `pnpm testbox:sanity` inside the warmed box before the broad command when
the sync looks suspicious. It checks that root files such as `pnpm-lock.yaml`
still exist and fails on 200 or more tracked deletions. That catches stale or
corrupted rsync state before dependency install or Vitest failures hide the real
problem.
## Examples
blacksmith testbox warmup ci-check-testbox.yml

View File

@@ -1,68 +0,0 @@
---
name: gitcrawl
description: Use gitcrawl for OpenClaw issue and PR archive search, duplicate discovery, related-thread clustering, and local GitHub mirror freshness checks.
metadata:
openclaw:
requires:
bins:
- gitcrawl
---
# Gitcrawl
Use this skill before live GitHub search when triaging OpenClaw issues or PRs.
`gitcrawl` is the local candidate-discovery layer. It is fast, includes open and closed threads, and can surface duplicate attempts, related issues, and already-landed fixes. It is not the final source of truth for comments, labels, merges, closes, or current CI.
## Default Flow
1. Check local state:
```bash
gitcrawl doctor --json
```
2. Read the target from the local archive:
```bash
gitcrawl threads openclaw/openclaw --numbers <issue-or-pr-number> --include-closed --json
```
3. Find related candidates:
```bash
gitcrawl neighbors openclaw/openclaw --number <issue-or-pr-number> --limit 12 --json
gitcrawl search openclaw/openclaw --query "<scope or title keywords>" --mode hybrid --limit 20 --json
```
4. Inspect relevant clusters:
```bash
gitcrawl cluster-detail openclaw/openclaw --id <cluster-id> --member-limit 20 --body-chars 280 --json
```
5. Verify anything actionable with live GitHub and the checkout:
```bash
gh pr view <number> --json number,title,state,mergedAt,body,files,comments,reviews,statusCheckRollup
gh issue view <number> --json number,title,state,body,comments,closedAt
```
## Freshness Rules
- Treat `gitcrawl` as stale if `doctor` shows no target thread, an old `last_sync_at`, missing embeddings for neighbor/search commands, or a clearly wrong open/closed state.
- If stale data blocks the decision, refresh the portable store first:
```bash
gitcrawl init --portable-store git@github.com:openclaw/gitcrawl-store.git --json
```
- Run expensive update commands such as `gitcrawl sync --include-comments` only when the user asked to update the local store or stale data is blocking the decision.
- The sync default is all GitHub thread states; pass `--state open`, `--state closed`, or `--state all` only when a task requires a narrower or explicit scope.
## Boundaries
- Use `gitcrawl` for candidates, clusters, and historical context.
- Use `gh`, `gh api`, and the current checkout for live state before commenting, labeling, closing, reopening, merging, or filing a PR review.
- Do not close or label based only on `gitcrawl` similarity. Require matching problem intent plus live verification.
- If `gitcrawl` is unavailable, say so and fall back to targeted `gh search` rather than blocking normal maintainer work.

View File

@@ -1,4 +0,0 @@
interface:
display_name: "Gitcrawl"
short_description: "Search local OpenClaw issue and PR history before live GitHub triage"
default_prompt: "Use $gitcrawl to inspect OpenClaw issue and PR history, find related threads and duplicate candidates, then verify actionable decisions with live GitHub."

View File

@@ -9,8 +9,7 @@ Use this skill for maintainer-facing GitHub workflow, not for ordinary code chan
## Start issue and PR triage with gitcrawl
- Use `$gitcrawl` first anytime you inspect OpenClaw issues or PRs.
- Check local `gitcrawl` data first for related threads, duplicate attempts, and already-landed fixes.
- Anytime you inspect OpenClaw issues or PRs, check local `gitcrawl` data first for related threads, duplicate attempts, and already-landed fixes.
- Use `gitcrawl` for candidate discovery and clustering; use `gh`, `gh api`, and the current checkout to verify live state before commenting, labeling, closing, or landing.
- If `gitcrawl` is missing, stale, lacks the target thread, or has no embeddings for neighbor/search commands, fall back to the GitHub search workflow below.
- Do not run expensive/update commands such as `gitcrawl sync --include-comments`, future enrichment commands, or broad reclustering unless the user asked to update the local store or stale data is blocking the decision.

View File

@@ -181,9 +181,6 @@ live`; keep it clearly beta and avoid implying stable promotion.
compact launch post, then publish one focused feature explainer per reply.
Follow-up replies should not repeat "new in VERSION" or the version number
when the thread context already makes it obvious.
- Peter's preferred thread workflow: first agree on the generic launch tweet,
then proceed through follow-up tweets one by one. When he says `next`, provide
or copy the next follow-up only; do not dump the full thread again unless asked.
- Every follow-up tweet should include a docs URL for that specific feature.
Prefer a bare URL over `Docs: <url>` unless the label is needed for clarity.
Keep follow-ups concise: around 160-220 raw characters is usually the sweet
@@ -327,12 +324,14 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
- install/update smoke against the published beta channel
- Docker install/update coverage that exercises the published beta package
- published npm Telegram proof: dispatch Actions > `NPM Telegram Beta E2E`
from `main` with `package_spec=openclaw@<beta-version>` and
`provider_mode=mock-openai`, and require success. This workflow is
from `main` with `source=npm`, `package_spec=openclaw@<beta-version>`,
and `provider_mode=mock-openai`, and require success. Before publishing,
use the same workflow with `source=ref` and `package_ref=<branch-or-sha>`
for focused tarball-backed Telegram preflight. This workflow is
maintainer-dispatched and intentionally has no `npm-release` approval gate;
`qa-live-shared` only supplies the shared QA secrets. This is the default
button path for installed-package onboarding, Telegram setup, and real
Telegram E2E against the published npm package.
`qa-live-shared` only supplies the shared QA secrets. The npm source is the
default button path for installed-package onboarding, Telegram setup, and
real Telegram E2E against the exact published npm package.
Use the local `pnpm test:docker:npm-telegram-live` lane with the matching
`OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC` and Convex CI env only as a fallback
or debugging path.
@@ -383,6 +382,27 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
retesting.
- If any required build, packaging step, or release workflow is red, do not say the release is ready.
## Record release evidence with npm provenance
- Every release validation evidence report should identify whether it matches a
published npm package. When dispatching `.github/workflows/full-release-validation.yml`
for a package that is already published or expected to be published before
evidence is finalized, pass `evidence_package_spec=openclaw@<version>`.
- When the post-publish Telegram npm lane is part of the same full validation,
also pass `npm_telegram_package_spec=openclaw@<version>` so the validation
proves the exact registry package, not only a branch/ref tarball.
- If a full validation was started before the npm package existed, regenerate
the private evidence after publish with
`openclaw/releases-private/.github/workflows/openclaw-release-evidence-from-full-validation.yml`
and pass `package_spec=openclaw@<version>`, the original
`full_validation_run_id`, and a human release id such as `YYYY.M.D`.
- Use SHA evidence ids for immutable debugging, but also create/update the
human stable evidence bucket (`evidence/YYYY.M.D/`) after stable publish so
maintainers can find the final npm/release proof quickly.
- Do not claim npm proof from a ref-backed or local tarball-backed run. Label
those as pre-publish package/tarball proof, and keep the npm registry proof
tied to `source=npm` or `package_spec=openclaw@<published-version>`.
## Use the right auth flow
- OpenClaw publish uses GitHub trusted publishing.
@@ -613,6 +633,11 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
`appcast.xml` artifact and do not update the shared production feed unless a
separate beta feed exists.
32. After publish, verify npm and the attached release artifacts.
33. After any beta or stable npm publish, ensure the private release evidence
report includes `package_spec=openclaw@<published-version>` and shows the
npm package match. If the original full validation omitted the package spec,
rerun the private evidence generation workflow with the same
`full_validation_run_id` and the published package spec.
## GHSA advisory work

View File

@@ -76,9 +76,6 @@ Use targeted file paths whenever possible. Avoid raw `vitest`; use the repo
- Direct test edits run themselves. Source edits prefer explicit mappings,
sibling `*.test.ts`, then import-graph dependents. Shared harness/config/root
edits are skipped by default unless they have precise mapped tests.
- Shared group-room delivery config and source-reply prompt edits are precise
mapped tests: they run the core auto-reply regressions plus Discord and Slack
delivery tests so cross-channel default changes fail before a PR push.
- Public SDK or contract edits do not automatically run every plugin test.
`check:changed` proves extension type contracts; the agent chooses the
smallest plugin/contract Vitest proof that matches the actual risk.
@@ -126,21 +123,13 @@ gh workflow run full-release-validation.yml \
--ref main \
-f ref=<branch-or-sha> \
-f provider=openai \
-f mode=both \
-f release_profile=stable
-f mode=both
```
Run the workflow itself from the trusted current ref, normally `--ref main`;
child workflows are dispatched from that same ref even when `ref` points at an
older release branch or tag. Full Release Validation has no separate child
workflow ref input; choose the trusted harness by choosing the workflow run ref.
Use `release_profile=minimum|stable|full` to control live/provider breadth:
`minimum` keeps the fastest OpenAI/core release-critical set, `stable` adds the
stable provider/backend set, and `full` adds the broad advisory provider/media
matrix. Do not make `full` faster by silently dropping suites; optimize setup,
artifact reuse, and sharding instead. The parent verifier job appends
slowest-job tables for child runs; rerun only that verifier after a child rerun
turns green.
If a full run is already active on a newer `origin/main`, prefer watching that
run over dispatching a duplicate. If you accidentally dispatch a stale duplicate,
@@ -152,11 +141,6 @@ parent gate. If a child workflow failed but was later rerun successfully, rerun
only the failed parent verifier job; do not dispatch a new full umbrella unless
the release evidence is stale.
For bounded recovery after a focused fix, pass `-f rerun_group=<group>`.
Supported umbrella groups are `all`, `ci`, `release-checks`, `install-smoke`,
`cross-os`, `live-e2e`, `package`, `qa`, `qa-parity`, `qa-live`, and
`npm-telegram`. Use the narrowest group that covers the failed box.
### Release Evidence
After release-candidate validation or before a release decision, record the
@@ -208,32 +192,9 @@ gh workflow run openclaw-release-checks.yml \
--ref main \
-f ref=<branch-or-sha> \
-f provider=openai \
-f mode=both \
-f release_profile=stable \
-f rerun_group=all
-f mode=both
```
Release-check rerun groups are `all`, `install-smoke`, `cross-os`, `live-e2e`,
`package`, `qa`, `qa-parity`, and `qa-live`.
`OpenClaw Release Checks` uses the trusted workflow ref to resolve the selected
ref once as `release-package-under-test` and passes that artifact into cross-OS
release checks, release-path Docker live/E2E checks, and Package Acceptance.
When `Full Release Validation` dispatches release checks, it passes the requested
branch/tag plus an `expected_sha` so branch/tag refs resolve through the fast
remote-ref path while the package and QA jobs still validate the exact SHA.
The release Docker path intentionally shards the plugin/runtime tail. The
workflow uses `plugins-runtime-plugins`, `plugins-runtime-services`, and
`plugins-runtime-install-a` through `plugins-runtime-install-d`; aggregate
aliases such as `plugins-runtime-core`, `plugins-runtime`, and
`plugins-integrations` remain for manual reruns.
The release QA parity box is internally split into candidate and baseline lane
jobs, followed by a report job that downloads both artifacts and runs
`pnpm openclaw qa parity-report`. For parity failures, inspect the failed lane
first; inspect the report job when both lane summaries exist but the comparison
fails.
### QA Lab Matrix Profiles
`pnpm openclaw qa matrix` defaults to `--profile all`. Do not assume the CLI
@@ -275,9 +236,7 @@ gh workflow run openclaw-live-and-e2e-checks-reusable.yml \
Useful knobs:
- `docker_lanes='<lane[,lane]>'`: run selected Docker scheduler lanes against
prepared artifacts instead of the release chunk matrix. Multiple selected
lanes fan out as parallel targeted Docker jobs after one shared package/image
preparation step.
prepared artifacts instead of the three release chunks.
- `include_live_suites=false`: skip live/provider suites when testing Docker
scheduler or release packaging only.
- `live_models_only=true`: run only Docker live model coverage.
@@ -285,44 +244,18 @@ Useful knobs:
targeted Docker live model job instead of the full provider matrix.
- blank `live_model_providers`: run the full live-model provider matrix.
Release-path Docker chunks are currently `core`, `package-update-openai`,
`package-update-anthropic`, `package-update-core`,
`plugins-runtime-plugins`, `plugins-runtime-services`,
`plugins-runtime-install-a`, `plugins-runtime-install-b`,
`plugins-runtime-install-c`, `plugins-runtime-install-d`,
`bundled-channels-core`, `bundled-channels-update-a`,
`bundled-channels-update-b`, and `bundled-channels-contracts`. The aggregate
`bundled-channels`, `plugins-runtime-core`, `plugins-runtime`, and
`plugins-integrations` chunks remain valid for manual one-shot reruns, but
release checks use the split chunks.
When live suites are enabled, the workflow shards broad native `pnpm test:live`
coverage through `scripts/test-live-shard.mjs` instead of one serial `live-all`
job:
- `native-live-src-agents`
- `native-live-src-gateway-core`
- `native-live-src-gateway-profiles` (release CI runs this with provider
filters such as `OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic`)
- `native-live-src-gateway-backends`
- `native-live-src-gateway`
- `native-live-test`
- `native-live-extensions-a-k`
- `native-live-extensions-l-n`
- `native-live-extensions-openai`
- `native-live-extensions-o-z`
- `native-live-extensions-o-z-other`
- `native-live-extensions-xai`
- `native-live-extensions-media`
- `native-live-extensions-media-audio`
- `native-live-extensions-media-music`
- `native-live-extensions-media-music-google`
- `native-live-extensions-media-music-minimax`
- `native-live-extensions-media-video`
- `native-live-extensions-l-z`
Use `node scripts/test-live-shard.mjs <shard> --list` to see the exact files
before rerunning a failed native live shard. The aggregate `o-z` and `media`
shards remain useful locally; release CI uses the smaller provider/media shards
so one live-provider flake does not force a broad native live rerun.
before rerunning a failed native live shard.
For model-list or provider-selection fixes, use `live_models_only=true` plus the
specific `live_model_providers` allowlist. Confirm logs show the expected
@@ -371,29 +304,19 @@ generated inside GitHub artifacts include `package_artifact_run_id`,
exact tarball and prepared images from the failed run. When the fix changes
package contents, omit those reuse inputs so the workflow packs a new tarball.
Live-only targeted reruns skip the E2E images and build only the live-test
image. Release-path normal mode fans out into smaller Docker chunk jobs:
image. Release-path normal mode fans out into four Docker chunk jobs:
- `core`
- `package-update-openai`
- `package-update-anthropic`
- `package-update-core`
- `plugins-runtime-plugins`
- `plugins-runtime-services`
- `plugins-runtime-install-a`
- `plugins-runtime-install-b`
- `plugins-runtime-install-c`
- `plugins-runtime-install-d`
- `package-update`
- `plugins-runtime`
- `bundled-channels`
OpenWebUI is folded into `plugins-runtime-services` for full release-path
coverage and keeps a standalone `openwebui` chunk only for OpenWebUI-only
dispatches. The legacy `package-update`, `plugins-runtime-core`,
`plugins-runtime`, and `plugins-integrations` chunks still work as aggregate
aliases for manual reruns, but the release workflow uses the split chunks so
provider installer checks, plugin runtime checks, bundled plugin
install/uninstall shards, and bundled-channel checks can run on separate
machines. The bundled-channel runtime-dependency coverage
inside `bundled-channels`
OpenWebUI is folded into `plugins-runtime` for full release-path coverage and
keeps a standalone `openwebui` chunk only for OpenWebUI-only dispatches. The
legacy `plugins-integrations` chunk still works as an aggregate alias for manual
reruns, but the release workflow uses the split chunks so plugin runtime checks
and bundled-channel checks can run on separate machines. The bundled-channel
runtime-dependency coverage inside `bundled-channels`
uses the split `bundled-channel-*` and `bundled-channel-update-*` lanes rather
than the serial `bundled-channel-deps` lane, so failures produce cheap targeted
reruns for the exact channel/update scenario. The bundled plugin

View File

@@ -1,6 +1,6 @@
---
name: tag-duplicate-prs-issues
description: Use gitcrawl to search duplicate OpenClaw PRs/issues, group related work in prtags, and sync duplicate state to GitHub.
description: Search duplicate OpenClaw PRs/issues, group related work in prtags, and sync duplicate state to GitHub.
---
# Tag Duplicate PRs and Issues
@@ -12,25 +12,43 @@ It is not for reviewing the implementation quality of a PR.
## Required Setup
Do not write duplicate groups or annotations until this setup is complete.
Read-only discovery can still proceed with `gitcrawl` and live `gh`.
Do not start duplicate triage until this setup is complete.
### Companion Skills
### Install the companion skills
Use `$gitcrawl` first for local candidate discovery.
Use the `prtags` skill from the `prtags` repo at `skills/prtags/SKILL.md` when it is available.
Install these skills first because they teach the agent how to use the two main CLIs correctly:
- `ghreplica` skill from the `ghreplica` repo at `skills/ghreplica/SKILL.md`
- `prtags` skill from the `prtags` repo at `skills/prtags/SKILL.md`
This skill assumes those two skills are available and can be used during the same run.
### Install the CLIs
Install `prtags` from its latest GitHub release.
Install `ghreplica` and `prtags` from their latest GitHub releases.
Do not rely on an old local build unless the maintainer explicitly wants to test unreleased behavior.
`ghreplica` CLI install path:
```bash
curl -fsSL https://raw.githubusercontent.com/dutifuldev/ghreplica/main/scripts/install-ghr.sh | bash -s -- --bin-dir "$HOME/.local/bin"
```
`prtags` CLI install path:
```bash
curl -fsSL https://raw.githubusercontent.com/dutifuldev/prtags/main/scripts/install-prtags.sh | bash -s -- --bin-dir "$HOME/.local/bin"
```
Use the `pr-search-cli` project with `uvx`.
The command itself is `pr-search`.
Do not require a permanent install unless the maintainer explicitly wants one.
```bash
uvx --from pr-search-cli pr-search status
uvx --from pr-search-cli pr-search code similar 67144
```
### Authenticate prtags
`prtags` should be logged in with the maintainer's own GitHub account through OAuth device flow.
@@ -48,15 +66,20 @@ The expected outcome is that `prtags` stores the logged-in maintainer identity l
Do not require an up-front preflight before starting the workflow.
Proceed with the normal steps until you actually need a tool or account state.
As soon as you discover that `prtags` is missing or not logged in at the write step, stop immediately.
Do not continue in a partial write mode after that point.
As soon as you discover that a required CLI is missing or `prtags` is not logged in, stop immediately.
Do not continue in a partial mode after that point.
If `prtags` is missing, ask the user to run:
If `ghr` is missing, ask the user to run the `ghreplica` install command.
If `prtags` is missing, ask the user to run both CLI install commands:
```bash
curl -fsSL https://raw.githubusercontent.com/dutifuldev/ghreplica/main/scripts/install-ghr.sh | bash -s -- --bin-dir "$HOME/.local/bin"
curl -fsSL https://raw.githubusercontent.com/dutifuldev/prtags/main/scripts/install-prtags.sh | bash -s -- --bin-dir "$HOME/.local/bin"
```
If `uvx --from pr-search-cli pr-search ...` fails because `uvx` or the `pr-search` launcher is not available, ask the user to make that command work before continuing.
If `prtags auth status` shows that the user is not logged in, ask the user to run:
```bash
@@ -67,19 +90,19 @@ Resume only after the missing tool or login state has been fixed.
## Read-Path Default
For candidate discovery in this workflow, use `gitcrawl` first.
Treat it as the local history and clustering layer for related issues, duplicate attempts, and closed threads.
For read-only GitHub operations in this workflow, use `ghr` as the default CLI.
Treat it as a drop-in replacement for the `gh` read operations you would normally use for PRs, issues, comments, reviews, and duplicate-search evidence.
Use live `gh` or `gh api` for the target thread and for any candidate before making an actionable judgment.
Use live GitHub when `gitcrawl` is missing or stale for a concrete reason, such as:
Only fall back to `gh` when `ghr` is failing for a concrete reason, such as:
- the target or candidate is not present yet
- the local data is clearly stale or incomplete for the decision you need to make
- `gitcrawl` errors, times out, or lacks the needed neighbor/search data
- the mirrored object is not present yet
- the mirror data is clearly stale or incomplete for the decision you need to make
- the `ghr` command errors, times out, or does not expose the specific read you need
When you fall back to live GitHub search, note that you did so and why.
When you fall back to `gh`, note that you did so and why.
If a later `prtags` target-level write fails because its own mirror has not caught up, stop and report that the curation backend is missing the target object instead of forcing a fallback write.
If `ghr` is missing a fresh PR or issue but `gh` can read it, you may use `gh` for the read-side judgment.
If a later `prtags` target-level write fails because the same object is still missing from `ghreplica`, stop and report that the mirror has not caught up yet instead of forcing the write.
## Goal
@@ -95,12 +118,14 @@ For each target PR or issue:
Use the tools with these boundaries:
- `gitcrawl` is candidate generation and historical context
- use it first for local title/body search, neighbors, clusters, and closed-thread discovery
- treat every candidate as a lead until live GitHub confirms it
- `gh` is live GitHub truth
- use it for target state, body, comments, reviews, files, linked issues, and current open/closed/merged status
- use `gh search` only when `gitcrawl` is stale, missing data, or cannot express the needed query
- `ghreplica` is the raw evidence source
- use `ghr` first for normal GitHub read operations in this workflow
- use it for title/body/comment search, related PRs, overlapping files, overlapping ranges, and current PR or issue status
- resort to `gh` only when `ghr` cannot provide the needed read cleanly
- `pr-search-cli` is candidate generation and ranking
- use it to suggest likely duplicate PRs or issue-cluster context
- do not treat it as final truth
- do not create or expand a duplicate group only because `pr-search-cli` put multiple PRs in the same issue or duplicate cluster
- `prtags` is the maintainer curation layer
- use it to create or reuse one duplicate group
- use it to save the duplicate status, confidence, rationale, and group summary
@@ -157,7 +182,7 @@ Examples:
## Evidence Checklist
Before declaring a duplicate, gather evidence from at least two categories.
`gitcrawl` neighbors, search hits, and cluster membership count as candidate generation, not as enough proof by themselves.
Same-issue or same-cluster output from `pr-search-cli` counts only as candidate generation, not as one of the required proof categories by itself.
For PRs:
@@ -180,18 +205,21 @@ If you only have wording similarity, that is not enough.
## Step 1: Read The Target
Start by reading the target itself.
Use live GitHub for current target state.
Use `ghr` first for this step even if you would normally reach for `gh`.
For a PR:
```bash
gh pr view <number> --json number,title,state,mergedAt,body,closingIssuesReferences,files,comments,reviews,statusCheckRollup
ghr pr view -R openclaw/openclaw <number> --comments
ghr pr reviews -R openclaw/openclaw <number>
ghr pr comments -R openclaw/openclaw <number>
```
For an issue:
```bash
gh issue view <number> --json number,title,state,body,comments,closedAt
ghr issue view -R openclaw/openclaw <number> --comments
ghr issue comments -R openclaw/openclaw <number>
```
Record:
@@ -204,56 +232,74 @@ Record:
- whether it is open, closed, or merged
- whether there is already a likely duplicate thread mentioned by humans
## Step 2: Search Broadly With Gitcrawl
## Step 2: Search Broadly With ghreplica
Use `gitcrawl` first because it is the local OpenClaw history and clustering source.
Do not switch to broad live GitHub search unless `gitcrawl` is missing data, stale, or failing.
Use `ghreplica` first because it is the most direct evidence source.
Do not switch to `gh` for ordinary reads unless `ghr` is missing data or failing.
Start with the target and nearby threads:
### PR duplicate search
Run all of these when the target is a PR:
```bash
gitcrawl threads openclaw/openclaw --numbers <issue-or-pr-number> --include-closed --json
gitcrawl neighbors openclaw/openclaw --number <issue-or-pr-number> --limit 20 --json
ghr search related-prs -R openclaw/openclaw <pr-number> --mode path_overlap --state all
ghr search related-prs -R openclaw/openclaw <pr-number> --mode range_overlap --state all
ghr search mentions -R openclaw/openclaw --query "<key phrase from title or body>" --mode fts --scope pull_requests --state all
ghr search mentions -R openclaw/openclaw --query "<subsystem or error phrase>" --mode fts --scope issues --state all
```
Then search key phrases and subsystem terms:
Use `prs-by-paths` or `prs-by-ranges` when the likely duplicate surface is already known:
```bash
gitcrawl search openclaw/openclaw --query "<key phrase from title or body>" --mode hybrid --limit 20 --json
gitcrawl search openclaw/openclaw --query "<subsystem or error phrase>" --mode hybrid --limit 20 --json
ghr search prs-by-paths -R openclaw/openclaw --path src/example.ts --state all
ghr search prs-by-ranges -R openclaw/openclaw --path src/example.ts --start 20 --end 80 --state all
```
Inspect likely clusters:
### Issue duplicate search
`ghreplica` does not have a special issue-to-issue “related issues” command.
For issues, search mirrored text and linked PR context instead.
Run targeted text searches:
```bash
gitcrawl cluster-detail openclaw/openclaw --id <cluster-id> --member-limit 20 --body-chars 280 --json
ghr search mentions -R openclaw/openclaw --query "<issue title phrase>" --mode fts --scope issues --state all
ghr search mentions -R openclaw/openclaw --query "<error message or symptom>" --mode fts --scope issues --state all
ghr search mentions -R openclaw/openclaw --query "<subsystem phrase>" --mode fts --scope pull_requests --state all
```
For PRs, verify likely code overlap with live file data:
Then inspect the candidate PRs or issues those searches uncover.
## Step 3: Use pr-search-cli As A Hint Layer
Use `pr-search-cli` after `ghreplica`.
It is good at surfacing candidates quickly, but it is not the final decision-maker.
Run it through the `pr-search` command.
For a PR:
```bash
gh pr view <candidate-pr> --json number,title,state,mergedAt,files,body,comments,reviews
uvx --from pr-search-cli pr-search -R openclaw/openclaw code similar <pr-number>
uvx --from pr-search-cli pr-search -R openclaw/openclaw code clusters for-pr <pr-number>
uvx --from pr-search-cli pr-search -R openclaw/openclaw issues for-pr <pr-number>
uvx --from pr-search-cli pr-search -R openclaw/openclaw issues duplicate-prs
```
For issues, verify likely duplicate issue state and comments live:
Interpretation:
```bash
gh issue view <candidate-issue> --json number,title,state,body,comments,closedAt
```
- `code similar` suggests PRs with similar change shape
- `code clusters for-pr` shows the PRs nearby code cluster
- `issues for-pr` shows which issue clusters the PR appears to belong to
- `issues duplicate-prs` is useful for spotting already-known duplicate PR patterns
## Step 3: Use Live GitHub Search For Gaps
Treat every `pr-search-cli` result as a hint to investigate, not as enough evidence to create or widen a duplicate group.
Multiple PRs can share the same issue or issue cluster while still taking meaningfully different fix paths.
Use targeted live GitHub search after `gitcrawl` when:
For an issue:
- the target is too new for the local store
- comments or reviews matter and the local store lacks them
- the exact phrase did not appear in local results but the issue/PR is current enough that GitHub should know it
```bash
gh search prs --repo openclaw/openclaw --match title,body --limit 50 -- "<key phrase>"
gh search issues --repo openclaw/openclaw --match title,body --limit 50 -- "<key phrase>"
gh search issues --repo openclaw/openclaw --match comments --limit 50 -- "<error or maintainer phrase>"
```
- use `ghreplica` first to find candidate PRs or issue wording
- if the issue has linked PRs or a likely implementation PR, run `pr-search-cli` on those PRs
- treat issue-cluster output as supporting context, not as enough by itself to call the issue a duplicate
## Step 4: Decide The Outcome
@@ -298,7 +344,7 @@ Reuse an existing group when:
- it already contains clearly related members
- adding the target would keep the group coherent
Do not widen an existing group just because `gitcrawl` placed several PRs or issues near each other.
Do not widen an existing group just because `pr-search-cli` placed several PRs under the same issue or duplicate cluster.
Confirm that the actual implementation path and maintainer intent still match before adding the new member.
Create a new group only when no existing group clearly fits.
@@ -377,8 +423,8 @@ prtags annotation group set <group-id> \
When the evidence is incomplete, set `duplicate_status=candidate` and lower the confidence.
If a per-PR or per-issue annotation write fails because `prtags` cannot resolve the target, do not force a fallback write path.
Keep the group state you were able to write, report that the curation backend is still missing the target object, and defer the target-level annotation until `prtags` catches up.
If a per-PR or per-issue annotation write fails because `prtags` cannot resolve the target through `ghreplica`, do not force a fallback write path.
Keep the group state you were able to write, report that the mirror is still missing the target object, and defer the target-level annotation until `ghreplica` catches up.
## Step 8: Let prtags Sync The Group Comment

View File

@@ -1,4 +1,4 @@
interface:
display_name: "Tag Duplicate PRs and Issues"
short_description: "Find duplicate PRs and issues with gitcrawl, group them in prtags, and let prtags sync the GitHub comment"
default_prompt: "Use $tag-duplicate-prs-issues to decide whether an OpenClaw PR or issue is a duplicate, gather candidates with gitcrawl, verify live state with GitHub, group related items in prtags, and save the duplicate judgment."
short_description: "Find duplicate PRs and issues, group them in prtags, and let prtags sync the GitHub comment"
default_prompt: "Use $tag-duplicate-prs-issues to decide whether an OpenClaw PR or issue is a duplicate, gather evidence with ghreplica and pr-search-cli, group related items in prtags, and save the duplicate judgment."

1
.github/CODEOWNERS vendored
View File

@@ -9,7 +9,6 @@
/.github/dependabot.yml @openclaw/secops
/.github/codeql/ @openclaw/secops
/.github/workflows/codeql.yml @openclaw/secops
/.github/workflows/codeql-critical-quality.yml @openclaw/secops
/src/security/ @openclaw/secops
/src/secrets/ @openclaw/secops
/src/config/*secret*.ts @openclaw/secops

View File

@@ -1,8 +0,0 @@
name: openclaw-codeql-actions-critical-security
paths:
- .github/actions
- .github/workflows
paths-ignore:
- .github/workflows/stale.yml

View File

@@ -1,30 +0,0 @@
name: openclaw-codeql-android-critical-security
disable-default-queries: true
queries:
- uses: security-extended
query-filters:
# Android canvas intentionally runs trusted A2UI JavaScript; keep this profile focused on exploitable WebView edges.
- exclude:
id: java/android/websettings-javascript-enabled
# Gateway TLS already pins verified certificate SHA-256 fingerprints. OkHttp CertificatePinner pins SPKI hashes,
# so this query is noisy for OpenClaw's TOFU/local-gateway trust model and does not belong in the critical profile.
- exclude:
id: java/android/missing-certificate-pinning
paths:
- apps/android/app/src/main
paths-ignore:
- "**/.gradle"
- "**/build"
- "**/node_modules"
- "**/coverage"
- "**/*.generated.*"
- "**/*Test.kt"
- "**/*Test.java"
- "**/*Benchmark.kt"
- apps/android/app/src/test
- apps/android/benchmark

View File

@@ -1,54 +0,0 @@
name: openclaw-codeql-javascript-typescript-critical-quality
disable-default-queries: true
queries:
- uses: security-and-quality
query-filters:
- include:
problem.severity:
- error
- exclude:
tags:
- security
paths:
- src/agents/*auth*.ts
- src/agents/**/*auth*.ts
- src/agents/auth-health*.ts
- src/agents/auth-profiles
- src/agents/bash-tools.exec-host-shared.ts
- src/agents/sandbox
- src/agents/sandbox.ts
- src/agents/sandbox-*.ts
- src/config
- src/cron/service/jobs.ts
- src/cron/stagger.ts
- src/gateway/*auth*.ts
- src/gateway/**/*auth*.ts
- src/gateway/*secret*.ts
- src/gateway/**/*secret*.ts
- src/gateway/protocol/**/*secret*.ts
- src/gateway/resolve-configured-secret-input-string*.ts
- src/gateway/security-path*.ts
- src/gateway/server-methods/secrets*.ts
- src/infra/secret-file*.ts
- src/secrets
- src/security
paths-ignore:
- "**/node_modules"
- "**/coverage"
- "**/*.generated.ts"
- "**/*.bundle.js"
- "**/*-runtime.js"
- "**/*.test.ts"
- "**/*.test.tsx"
- "**/*.e2e.test.ts"
- "**/*.e2e.test.tsx"
- "**/*test-support*"
- "**/*test-helper*"
- "**/*mock*"
- "**/*fixture*"
- "**/*bench*"

View File

@@ -1,57 +0,0 @@
name: openclaw-codeql-javascript-typescript-critical-security
disable-default-queries: true
queries:
- uses: security-extended
query-filters:
- include:
precision:
- high
- very-high
- exclude:
problem.severity:
- recommendation
- warning
paths:
- src/agents/*auth*.ts
- src/agents/**/*auth*.ts
- src/agents/auth-health*.ts
- src/agents/auth-profiles
- src/agents/bash-tools.exec-host-shared.ts
- src/agents/sandbox
- src/agents/sandbox.ts
- src/agents/sandbox-*.ts
- src/config/*secret*.ts
- src/config/**/*secret*.ts
- src/cron/service/jobs.ts
- src/cron/stagger.ts
- src/gateway/*auth*.ts
- src/gateway/**/*auth*.ts
- src/gateway/*secret*.ts
- src/gateway/**/*secret*.ts
- src/gateway/protocol/**/*secret*.ts
- src/gateway/resolve-configured-secret-input-string*.ts
- src/gateway/security-path*.ts
- src/gateway/server-methods/secrets*.ts
- src/infra/secret-file*.ts
- src/secrets
- src/security
paths-ignore:
- "**/node_modules"
- "**/coverage"
- "**/*.generated.ts"
- "**/*.bundle.js"
- "**/*-runtime.js"
- "**/*.test.ts"
- "**/*.test.tsx"
- "**/*.e2e.test.ts"
- "**/*.e2e.test.tsx"
- "**/*test-support*"
- "**/*test-helper*"
- "**/*mock*"
- "**/*fixture*"
- "**/*bench*"

View File

@@ -0,0 +1,21 @@
name: openclaw-codeql-javascript-typescript
paths:
- src
- extensions
- ui/src
- skills
paths-ignore:
- apps
- dist
- docs
- "**/node_modules"
- "**/coverage"
- "**/*.generated.ts"
- "**/*.bundle.js"
- "**/*-runtime.js"
- "**/*.test.ts"
- "**/*.test.tsx"
- "**/*.e2e.test.ts"
- "**/*.e2e.test.tsx"

View File

@@ -1,17 +0,0 @@
name: openclaw-codeql-macos-critical-security
disable-default-queries: true
queries:
- uses: security-extended
paths:
- apps/macos/Sources
paths-ignore:
- "**/.build"
- "**/.build/**"
- "**/DerivedData"
- "**/DerivedData/**"
- "**/*.generated.swift"
- "**/*Tests.swift"

5
.github/labeler.yml vendored
View File

@@ -314,11 +314,6 @@
- changed-files:
- any-glob-to-any-file:
- "extensions/deepseek/**"
"extensions: deepinfra":
- changed-files:
- any-glob-to-any-file:
- "extensions/deepinfra/**"
- "docs/providers/deepinfra.md"
"extensions: tencent":
- changed-files:
- any-glob-to-any-file:

View File

@@ -191,32 +191,6 @@ jobs:
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
sudo ln -sf "$pnpm_bin" /usr/local/bin/pnpm
- name: Hydrate Testbox provider env helper
shell: bash
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_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 }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
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 }}
TOGETHER_API_KEY: ${{ secrets.TOGETHER_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 }}
run: bash scripts/ci-hydrate-testbox-env.sh
- name: Run Testbox
uses: useblacksmith/run-testbox@v2
if: always()

View File

@@ -93,33 +93,6 @@ jobs:
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
sudo ln -sf "$pnpm_bin" /usr/local/bin/pnpm
- name: Hydrate Testbox provider env helper
shell: bash
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_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 }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
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 }}
TOGETHER_API_KEY: ${{ secrets.TOGETHER_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 }}
run: bash scripts/ci-hydrate-testbox-env.sh
- name: Run Testbox
uses: useblacksmith/run-testbox@v2
if: always()

View File

@@ -1,50 +0,0 @@
name: ClawSweeper Dispatch
on:
issues:
types: [opened, reopened, edited, labeled, unlabeled]
pull_request_target: # zizmor: ignore[dangerous-triggers] maintainer-owned external dispatch; no checkout or untrusted PR code execution
types: [opened, reopened, synchronize, ready_for_review, edited, labeled, unlabeled]
permissions:
contents: read
jobs:
dispatch:
runs-on: ubuntu-latest
env:
HAS_CLAWSWEEPER_APP_PRIVATE_KEY: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY != '' }}
steps:
- name: Create ClawSweeper dispatch token
id: token
if: ${{ env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true' }}
uses: actions/create-github-app-token@v2
with:
app-id: 3306130
private-key: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }}
owner: openclaw
repositories: clawsweeper
- name: Dispatch exact ClawSweeper review
env:
GH_TOKEN: ${{ steps.token.outputs.token || secrets.OPENCLAW_GH_TOKEN }}
TARGET_REPO: ${{ github.repository }}
ITEM_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }}
ITEM_KIND: ${{ github.event_name == 'pull_request_target' && 'pull_request' || 'issue' }}
run: |
if [ -z "$GH_TOKEN" ]; then
echo "::notice::Skipping ClawSweeper dispatch because no dispatch credential is configured."
exit 0
fi
payload="$(jq -nc \
--arg target_repo "$TARGET_REPO" \
--argjson item_number "$ITEM_NUMBER" \
--arg item_kind "$ITEM_KIND" \
'{event_type:"clawsweeper_item",client_payload:{target_repo:$target_repo,item_number:$item_number,item_kind:$item_kind}}')"
if gh api repos/openclaw/clawsweeper/dispatches \
--method POST \
--input - <<< "$payload"; then
echo "Dispatched ClawSweeper review."
else
echo "::warning::Skipping ClawSweeper dispatch because the configured credential could not dispatch to openclaw/clawsweeper."
fi

View File

@@ -1,40 +0,0 @@
name: CodeQL Critical Quality
on:
workflow_dispatch:
schedule:
- cron: "30 6 * * *"
concurrency:
group: codeql-critical-quality-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.sha }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
permissions:
actions: read
contents: read
security-events: write
jobs:
javascript-typescript:
name: Critical Quality (javascript-typescript)
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 25
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-javascript-typescript-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
category: "/codeql-critical-quality/javascript-typescript"

View File

@@ -2,23 +2,12 @@ name: CodeQL
on:
workflow_dispatch:
inputs:
profile:
description: CodeQL security profile to run
required: false
default: all
type: choice
options:
- all
- security
- android-security
- macos-security
schedule:
- cron: "0 6 * * *"
concurrency:
group: codeql-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.sha }}
cancel-in-progress: false
group: codeql-${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
@@ -29,140 +18,121 @@ permissions:
security-events: write
jobs:
critical-security:
name: Critical Security (${{ matrix.language }})
if: ${{ github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'security' }}
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ${{ matrix.runs_on }}
timeout-minutes: ${{ matrix.timeout_minutes }}
strategy:
fail-fast: false
matrix:
include:
- language: javascript-typescript
runs_on: blacksmith-8vcpu-ubuntu-2404
timeout_minutes: 25
config_file: ./.github/codeql/codeql-javascript-typescript-critical-security.yml
runs_on: blacksmith-32vcpu-ubuntu-2404
needs_node: true
needs_python: false
needs_java: false
needs_swift_tools: false
needs_manual_build: false
needs_autobuild: false
config_file: ./.github/codeql/codeql-javascript-typescript.yml
- language: actions
runs_on: blacksmith-8vcpu-ubuntu-2404
timeout_minutes: 10
config_file: ./.github/codeql/codeql-actions-critical-security.yml
runs_on: blacksmith-16vcpu-ubuntu-2404
needs_node: false
needs_python: false
needs_java: false
needs_swift_tools: false
needs_manual_build: false
needs_autobuild: false
config_file: ""
- language: python
runs_on: blacksmith-16vcpu-ubuntu-2404
needs_node: false
needs_python: true
needs_java: false
needs_swift_tools: false
needs_manual_build: false
needs_autobuild: false
config_file: ""
- language: java-kotlin
runs_on: blacksmith-16vcpu-ubuntu-2404
needs_node: false
needs_python: false
needs_java: true
needs_swift_tools: false
needs_manual_build: true
needs_autobuild: false
config_file: ""
- language: swift
runs_on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-12vcpu-macos-latest' || 'macos-latest' }}
needs_node: false
needs_python: false
needs_java: false
needs_swift_tools: true
needs_manual_build: true
needs_autobuild: false
config_file: ""
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
- name: Setup Node environment
if: matrix.needs_node
uses: ./.github/actions/setup-node-env
with:
languages: ${{ matrix.language }}
config-file: ${{ matrix.config_file }}
install-bun: "false"
- name: Analyze
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
- name: Setup Python
if: matrix.needs_python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
category: "/codeql-critical-security/${{ matrix.language }}"
android-security:
name: Critical Security (android)
if: ${{ github.event_name == 'workflow_dispatch' && inputs.profile == 'android-security' }}
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
submodules: false
python-version: "3.12"
- name: Setup Java
if: matrix.needs_java
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
with:
distribution: temurin
java-version: "21"
- name: Initialize CodeQL
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
languages: java-kotlin
build-mode: manual
config-file: ./.github/codeql/codeql-android-critical-security.yml
- name: Build Android for CodeQL
working-directory: apps/android
run: ./gradlew --no-daemon :app:assemblePlayDebug
- name: Analyze
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
category: "/codeql-critical-security/android"
macos-security:
name: Critical Security (macOS)
if: ${{ github.event_name == 'workflow_dispatch' && inputs.profile == 'macos-security' }}
runs-on: blacksmith-6vcpu-macos-latest
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
submodules: false
- name: Select Xcode
- name: Setup Swift build tools
if: matrix.needs_swift_tools
run: |
sudo xcode-select -s /Applications/Xcode_26.1.app
xcodebuild -version
brew install xcodegen swiftlint swiftformat
swift --version
- name: Initialize CodeQL
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
uses: github/codeql-action/init@b25d0ebf40e5b63ee81e1bd6e5d2a12b7c2aeb61 # v4
with:
languages: swift
build-mode: manual
config-file: ./.github/codeql/codeql-macos-critical-security.yml
languages: ${{ matrix.language }}
queries: security-and-quality
config-file: ${{ matrix.config_file || '' }}
- name: Build macOS for CodeQL
run: swift build --package-path apps/macos --product OpenClaw
- name: Autobuild
if: matrix.needs_autobuild
uses: github/codeql-action/autobuild@b25d0ebf40e5b63ee81e1bd6e5d2a12b7c2aeb61 # v4
- name: Analyze
id: analyze
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
output: sarif-results
upload: failure-only
category: "/codeql-critical-security/macos"
- name: Build Android for CodeQL
if: matrix.language == 'java-kotlin'
working-directory: apps/android
run: ./gradlew --no-daemon :app:assemblePlayDebug
- name: Remove dependency build results
env:
SARIF_OUTPUT: sarif-results
- name: Build Swift for CodeQL
if: matrix.language == 'swift'
run: |
set -euo pipefail
shopt -s nullglob
swift build --package-path apps/macos --configuration release
cd apps/ios
xcodegen generate
xcodebuild build \
-project OpenClaw.xcodeproj \
-scheme OpenClaw \
-destination "generic/platform=iOS Simulator" \
CODE_SIGNING_ALLOWED=NO
if [ ! -d "$SARIF_OUTPUT" ]; then
echo "SARIF output directory not found: $SARIF_OUTPUT" >&2
exit 1
fi
mkdir -p sarif-results-filtered
files=("$SARIF_OUTPUT"/*.sarif)
if [ "${#files[@]}" -eq 0 ]; then
echo "No SARIF files found in $SARIF_OUTPUT" >&2
exit 1
fi
for file in "${files[@]}"; do
jq '
def in_dependency_build:
((.locations // []) | length > 0)
and all(.locations[]; (.physicalLocation.artifactLocation.uri? // "") | test("^apps/macos/\\.build/"));
.runs |= map(.results = ((.results // []) | map(select(in_dependency_build | not))))
' "$file" > "sarif-results-filtered/$(basename "$file")"
done
- name: Upload filtered SARIF
uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
- name: Analyze
uses: github/codeql-action/analyze@b25d0ebf40e5b63ee81e1bd6e5d2a12b7c2aeb61 # v4
with:
sarif_file: sarif-results-filtered
category: "/codeql-critical-security/macos"
category: "/language:${{ matrix.language }}"

View File

@@ -55,7 +55,6 @@ jobs:
# WARNING: KEEP MANUAL BACKFILLS GATED BY THE docker-release ENVIRONMENT.
runs-on: ubuntu-24.04
environment: docker-release
permissions: {}
steps:
- name: Approve Docker backfill
env:

View File

@@ -26,32 +26,6 @@ on:
- fresh
- upgrade
- both
release_profile:
description: Release coverage profile for live/Docker/provider breadth
required: false
default: full
type: choice
options:
- minimum
- stable
- full
rerun_group:
description: Validation group to run
required: false
default: all
type: choice
options:
- all
- ci
- release-checks
- install-smoke
- cross-os
- live-e2e
- package
- qa
- qa-parity
- qa-live
- npm-telegram
npm_telegram_package_spec:
description: Optional published package spec for the post-publish Telegram E2E lane
required: false
@@ -96,23 +70,17 @@ jobs:
outputs:
sha: ${{ steps.resolve.outputs.sha }}
steps:
- name: Checkout trusted workflow helper
- name: Checkout target ref
uses: actions/checkout@v6
with:
ref: ${{ github.ref_name }}
path: workflow
fetch-depth: 1
ref: ${{ inputs.ref }}
fetch-depth: 0
persist-credentials: false
submodules: false
- name: Resolve target SHA
id: resolve
env:
TARGET_REF: ${{ inputs.ref }}
run: |
bash workflow/scripts/github/resolve-openclaw-ref.sh \
--ref "$TARGET_REF" \
--github-output "$GITHUB_OUTPUT"
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Summarize target
env:
@@ -121,7 +89,6 @@ jobs:
CHILD_WORKFLOW_REF: ${{ github.ref_name }}
NPM_TELEGRAM_PACKAGE_SPEC: ${{ inputs.npm_telegram_package_spec }}
EVIDENCE_PACKAGE_SPEC: ${{ inputs.evidence_package_spec }}
RERUN_GROUP: ${{ inputs.rerun_group }}
run: |
{
echo "## Full release validation"
@@ -129,17 +96,9 @@ jobs:
echo "- Target ref: \`${TARGET_REF}\`"
echo "- Target SHA: \`${TARGET_SHA}\`"
echo "- Child workflow ref: \`${CHILD_WORKFLOW_REF}\`"
echo "- Rerun group: \`${RERUN_GROUP}\`"
if [[ "$RERUN_GROUP" == "all" || "$RERUN_GROUP" == "ci" ]]; then
echo "- Normal CI: \`CI\` with \`target_ref=${TARGET_SHA}\`"
else
echo "- Normal CI: skipped by rerun group"
fi
if [[ "$RERUN_GROUP" != "ci" && "$RERUN_GROUP" != "npm-telegram" ]]; then
echo "- Release/live/Docker/package/QA: \`OpenClaw Release Checks\`"
else
echo "- Release/live/Docker/package/QA: skipped by rerun group"
fi
echo "- Normal CI: \`CI\` with \`target_ref=${TARGET_SHA}\`"
echo "- Release/live/Docker/package/QA: \`OpenClaw Release Checks\`"
echo "- Pre-publish Telegram package E2E: included through \`OpenClaw Release Checks\` Package Acceptance for \`${TARGET_SHA}\`"
if [[ -n "${NPM_TELEGRAM_PACKAGE_SPEC// }" ]]; then
echo "- Post-publish Telegram E2E: \`${NPM_TELEGRAM_PACKAGE_SPEC}\`"
else
@@ -153,7 +112,6 @@ jobs:
normal_ci:
name: Run normal full CI
needs: [resolve_target]
if: contains(fromJSON('["all","ci"]'), inputs.rerun_group)
runs-on: ubuntu-24.04
timeout-minutes: 240
outputs:
@@ -237,7 +195,6 @@ jobs:
release_checks:
name: Run release/live/Docker/QA validation
needs: [resolve_target]
if: contains(fromJSON('["all","release-checks","install-smoke","cross-os","live-e2e","package","qa","qa-parity","qa-live"]'), inputs.rerun_group)
runs-on: ubuntu-24.04
timeout-minutes: 720
outputs:
@@ -254,8 +211,6 @@ jobs:
CHILD_WORKFLOW_REF: ${{ github.ref_name }}
PROVIDER: ${{ inputs.provider }}
MODE: ${{ inputs.mode }}
RELEASE_PROFILE: ${{ inputs.release_profile }}
RERUN_GROUP: ${{ inputs.rerun_group }}
run: |
set -euo pipefail
@@ -320,27 +275,17 @@ jobs:
echo "- Target SHA: \`${TARGET_SHA}\`"
echo "- Provider: \`${PROVIDER}\`"
echo "- Cross-OS mode: \`${MODE}\`"
echo "- Release profile: \`${RELEASE_PROFILE}\`"
echo "- Rerun group: \`${RERUN_GROUP}\`"
} >> "$GITHUB_STEP_SUMMARY"
child_rerun_group="$RERUN_GROUP"
if [[ "$child_rerun_group" == "release-checks" ]]; then
child_rerun_group=all
fi
dispatch_and_wait openclaw-release-checks.yml \
-f ref="$TARGET_REF" \
-f expected_sha="$TARGET_SHA" \
-f ref="$TARGET_SHA" \
-f provider="$PROVIDER" \
-f mode="$MODE" \
-f release_profile="$RELEASE_PROFILE" \
-f rerun_group="$child_rerun_group"
-f mode="$MODE"
npm_telegram:
name: Run post-publish Telegram E2E
needs: [resolve_target]
if: inputs.npm_telegram_package_spec != '' && contains(fromJSON('["all","npm-telegram"]'), inputs.rerun_group)
if: inputs.npm_telegram_package_spec != ''
runs-on: ubuntu-24.04
timeout-minutes: 120
outputs:
@@ -419,13 +364,8 @@ jobs:
TARGET_REF: ${{ inputs.ref }}
PACKAGE_SPEC: ${{ inputs.evidence_package_spec || inputs.npm_telegram_package_spec }}
GITHUB_RUN_ID_VALUE: ${{ github.run_id }}
RELEASE_CHECKS_RESULT: ${{ needs.release_checks.result }}
run: |
set -euo pipefail
if [[ "$RELEASE_CHECKS_RESULT" == "skipped" ]]; then
echo "Release checks were skipped by rerun group; skipping automatic private evidence update."
exit 0
fi
if [[ -z "${RELEASE_PRIVATE_DISPATCH_TOKEN// }" ]]; then
echo "OPENCLAW_RELEASES_PRIVATE_DISPATCH_TOKEN is not configured; skipping automatic private evidence update."
exit 0
@@ -475,8 +415,6 @@ jobs:
NORMAL_CI_RUN_ID: ${{ needs.normal_ci.outputs.run_id }}
RELEASE_CHECKS_RUN_ID: ${{ needs.release_checks.outputs.run_id }}
NPM_TELEGRAM_RUN_ID: ${{ needs.npm_telegram.outputs.run_id }}
NORMAL_CI_RESULT: ${{ needs.normal_ci.result }}
RELEASE_CHECKS_RESULT: ${{ needs.release_checks.result }}
NPM_TELEGRAM_RESULT: ${{ needs.npm_telegram.result }}
run: |
set -euo pipefail
@@ -509,47 +447,10 @@ jobs:
fi
}
summarize_child_timing() {
local label="$1"
local run_id="$2"
if [[ -z "${run_id// }" ]]; then
return 0
fi
{
echo
echo "### Slowest jobs: ${label}"
echo
gh run view "$run_id" --json jobs --jq '
def ts: fromdateiso8601;
"| Job | Result | Minutes |",
"| --- | --- | ---: |",
([.jobs[]
| select(.startedAt != "0001-01-01T00:00:00Z" and .completedAt != "0001-01-01T00:00:00Z")
| . + {durationMin: ((((.completedAt | ts) - (.startedAt | ts)) / 60) * 10 | round / 10)}
| {name, conclusion, durationMin}]
| sort_by(.durationMin)
| reverse
| .[0:10]
| map("| `" + (.name | gsub("\\|"; "\\|")) + "` | `" + ((.conclusion // "") | tostring) + "` | " + (.durationMin | tostring) + " |")
| .[])
' || echo "_Unable to summarize jobs for run ${run_id}._"
} >> "$GITHUB_STEP_SUMMARY"
}
failed=0
if [[ "$NORMAL_CI_RESULT" == "skipped" && -z "${NORMAL_CI_RUN_ID// }" ]]; then
check_child "normal_ci" "" 0 || failed=1
else
check_child "normal_ci" "$NORMAL_CI_RUN_ID" 1 || failed=1
fi
if [[ "$RELEASE_CHECKS_RESULT" == "skipped" && -z "${RELEASE_CHECKS_RUN_ID// }" ]]; then
check_child "release_checks" "" 0 || failed=1
else
check_child "release_checks" "$RELEASE_CHECKS_RUN_ID" 1 || failed=1
fi
check_child "normal_ci" "$NORMAL_CI_RUN_ID" 1 || failed=1
check_child "release_checks" "$RELEASE_CHECKS_RUN_ID" 1 || failed=1
if [[ "$NPM_TELEGRAM_RESULT" == "skipped" && -z "${NPM_TELEGRAM_RUN_ID// }" ]]; then
check_child "npm_telegram" "" 0 || failed=1
@@ -557,8 +458,4 @@ jobs:
check_child "npm_telegram" "$NPM_TELEGRAM_RUN_ID" 1 || failed=1
fi
summarize_child_timing "normal_ci" "$NORMAL_CI_RUN_ID"
summarize_child_timing "release_checks" "$RELEASE_CHECKS_RUN_ID"
summarize_child_timing "npm_telegram" "$NPM_TELEGRAM_RUN_ID"
exit "$failed"

View File

@@ -5,9 +5,44 @@ on:
inputs:
package_spec:
description: Published OpenClaw package spec to test when no artifact is supplied
required: true
required: false
default: openclaw@beta
type: string
source:
description: Package candidate source when no package-under-test artifact is supplied
required: true
default: npm
type: choice
options:
- npm
- ref
- url
- artifact
package_ref:
description: Trusted package source ref when source=ref
required: false
default: main
type: string
package_url:
description: HTTPS .tgz URL when source=url
required: false
default: ""
type: string
package_sha256:
description: Expected package SHA-256 for source=url or source=artifact
required: false
default: ""
type: string
artifact_run_id:
description: GitHub Actions run id when source=artifact
required: false
default: ""
type: string
artifact_name:
description: Artifact name containing one .tgz when source=artifact
required: false
default: package-under-test
type: string
package_label:
description: Optional display label for an artifact-backed package candidate
required: false
@@ -39,7 +74,38 @@ on:
inputs:
package_spec:
description: Published OpenClaw package spec to test when no artifact is supplied
required: true
required: false
default: openclaw@beta
type: string
source:
description: "Package candidate source when no package-under-test artifact is supplied: npm, ref, url, or artifact"
required: false
default: npm
type: string
package_ref:
description: Trusted package source ref when source=ref
required: false
default: main
type: string
package_url:
description: HTTPS .tgz URL when source=url
required: false
default: ""
type: string
package_sha256:
description: Expected package SHA-256 for source=url or source=artifact
required: false
default: ""
type: string
artifact_run_id:
description: GitHub Actions run id when source=artifact
required: false
default: ""
type: string
artifact_name:
description: Artifact name containing one .tgz when source=artifact
required: false
default: package-under-test
type: string
package_artifact_name:
description: Optional package-under-test artifact from the current workflow run
@@ -75,6 +141,7 @@ on:
required: false
permissions:
actions: read
contents: read
concurrency:
@@ -93,6 +160,7 @@ jobs:
timeout-minutes: 60
environment: qa-live-shared
permissions:
actions: read
contents: read
env:
DOCKER_BUILD_SUMMARY: "false"
@@ -102,7 +170,7 @@ jobs:
uses: actions/checkout@v6
with:
ref: ${{ inputs.harness_ref || github.sha }}
fetch-depth: 1
fetch-depth: 0
- name: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@ac083cc84672d01c60d5e8561d0a939b697de542 # v1
@@ -131,6 +199,12 @@ jobs:
- name: Validate inputs and secrets
env:
PACKAGE_SPEC: ${{ inputs.package_spec }}
SOURCE: ${{ inputs.source }}
PACKAGE_REF: ${{ inputs.package_ref }}
PACKAGE_URL: ${{ inputs.package_url }}
PACKAGE_SHA256: ${{ inputs.package_sha256 }}
ARTIFACT_RUN_ID: ${{ inputs.artifact_run_id }}
ARTIFACT_NAME: ${{ inputs.artifact_name }}
PACKAGE_ARTIFACT_NAME: ${{ inputs.package_artifact_name || '' }}
PROVIDER_MODE: ${{ inputs.provider_mode }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
@@ -140,12 +214,36 @@ jobs:
run: |
set -euo pipefail
if [[ -z "${PACKAGE_ARTIFACT_NAME// }" ]]; then
case "${SOURCE}" in
npm | ref | url | artifact) ;;
*)
echo "source must be npm, ref, url, or artifact; got: ${SOURCE}" >&2
exit 1
;;
esac
if [[ -z "${PACKAGE_ARTIFACT_NAME// }" && "${SOURCE}" == "npm" ]]; then
if [[ ! "${PACKAGE_SPEC}" =~ ^openclaw@(beta|latest|[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*|-beta\.[1-9][0-9]*)?)$ ]]; then
echo "package_spec must be openclaw@beta, openclaw@latest, or an exact OpenClaw release version; got: ${PACKAGE_SPEC}" >&2
exit 1
fi
fi
if [[ -z "${PACKAGE_ARTIFACT_NAME// }" && "${SOURCE}" == "ref" && -z "${PACKAGE_REF// }" ]]; then
echo "package_ref is required when source=ref." >&2
exit 1
fi
if [[ -z "${PACKAGE_ARTIFACT_NAME// }" && "${SOURCE}" == "url" ]]; then
if [[ -z "${PACKAGE_URL// }" || -z "${PACKAGE_SHA256// }" ]]; then
echo "package_url and package_sha256 are required when source=url." >&2
exit 1
fi
fi
if [[ -z "${PACKAGE_ARTIFACT_NAME// }" && "${SOURCE}" == "artifact" ]]; then
if [[ -z "${ARTIFACT_RUN_ID// }" || -z "${ARTIFACT_NAME// }" ]]; then
echo "artifact_run_id and artifact_name are required when source=artifact." >&2
exit 1
fi
fi
case "${PROVIDER_MODE}" in
mock-openai | live-frontier) ;;
*)
@@ -175,6 +273,46 @@ jobs:
name: ${{ inputs.package_artifact_name }}
path: .artifacts/telegram-package-under-test
- name: Download package artifact input
if: inputs.package_artifact_name == '' && inputs.source == 'artifact'
env:
GH_TOKEN: ${{ github.token }}
ARTIFACT_RUN_ID: ${{ inputs.artifact_run_id }}
ARTIFACT_NAME: ${{ inputs.artifact_name }}
shell: bash
run: |
set -euo pipefail
mkdir -p .artifacts/telegram-package-input
gh run download "$ARTIFACT_RUN_ID" -n "$ARTIFACT_NAME" -D .artifacts/telegram-package-input
- name: Resolve package-under-test candidate
if: inputs.package_artifact_name == '' && inputs.source != 'npm'
id: resolve_candidate
env:
SOURCE: ${{ inputs.source }}
PACKAGE_REF: ${{ inputs.package_ref }}
PACKAGE_SPEC: ${{ inputs.package_spec }}
PACKAGE_URL: ${{ inputs.package_url }}
PACKAGE_SHA256: ${{ inputs.package_sha256 }}
shell: bash
run: |
set -euo pipefail
artifact_dir="."
if [[ "${SOURCE}" == "artifact" ]]; then
artifact_dir=".artifacts/telegram-package-input"
fi
node scripts/resolve-openclaw-package-candidate.mjs \
--source "$SOURCE" \
--package-ref "$PACKAGE_REF" \
--package-spec "$PACKAGE_SPEC" \
--package-url "$PACKAGE_URL" \
--package-sha256 "$PACKAGE_SHA256" \
--artifact-dir "$artifact_dir" \
--output-dir .artifacts/telegram-package-under-test \
--output-name openclaw-current.tgz \
--metadata .artifacts/telegram-package-under-test/package-candidate.json \
--github-output "$GITHUB_OUTPUT"
- name: Run package Telegram E2E
id: run_lane
shell: bash
@@ -200,10 +338,15 @@ jobs:
echo "output_dir=${output_dir}" >> "$GITHUB_OUTPUT"
export OPENCLAW_NPM_TELEGRAM_OUTPUT_DIR="${output_dir}"
if [[ -n "${PACKAGE_ARTIFACT_NAME// }" ]]; then
mapfile -t package_tgzs < <(find .artifacts/telegram-package-under-test -type f -name "*.tgz" | sort)
package_dir=".artifacts/telegram-package-under-test"
if [[ -d "${package_dir}" ]]; then
mapfile -t package_tgzs < <(find "${package_dir}" -type f -name "*.tgz" | sort)
else
package_tgzs=()
fi
if [[ "${#package_tgzs[@]}" -gt 0 ]]; then
if [[ "${#package_tgzs[@]}" -ne 1 ]]; then
echo "package artifact ${PACKAGE_ARTIFACT_NAME} must contain exactly one .tgz; found ${#package_tgzs[@]}" >&2
echo "package-under-test candidate must contain exactly one .tgz; found ${#package_tgzs[@]}" >&2
exit 1
fi
export OPENCLAW_NPM_TELEGRAM_PACKAGE_TGZ="${package_tgzs[0]}"
@@ -225,6 +368,6 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: npm-telegram-beta-e2e-${{ github.run_id }}-${{ github.run_attempt }}
path: .artifacts/qa-e2e/
path: ${{ steps.run_lane.outputs.output_dir }}
retention-days: 14
if-no-files-found: warn

View File

@@ -51,31 +51,6 @@ on:
required: false
default: ""
type: string
candidate_artifact_name:
description: Optional current-run artifact name containing the candidate OpenClaw tarball
required: false
default: ""
type: string
candidate_artifact_run_id:
description: Optional workflow run id for candidate_artifact_name
required: false
default: ""
type: string
candidate_file_name:
description: Optional candidate tarball file name inside candidate_artifact_name
required: false
default: ""
type: string
candidate_version:
description: Optional candidate OpenClaw package version
required: false
default: ""
type: string
candidate_source_sha:
description: Optional source SHA used to build the candidate tarball
required: false
default: ""
type: string
workflow_call:
inputs:
ref:
@@ -115,31 +90,6 @@ on:
required: false
default: ""
type: string
candidate_artifact_name:
description: Optional current-run artifact name containing the candidate OpenClaw tarball
required: false
default: ""
type: string
candidate_artifact_run_id:
description: Optional workflow run id for candidate_artifact_name
required: false
default: ""
type: string
candidate_file_name:
description: Optional candidate tarball file name inside candidate_artifact_name
required: false
default: ""
type: string
candidate_version:
description: Optional candidate OpenClaw package version
required: false
default: ""
type: string
candidate_source_sha:
description: Optional source SHA used to build the candidate tarball
required: false
default: ""
type: string
secrets:
OPENAI_API_KEY:
required: false
@@ -169,7 +119,7 @@ env:
jobs:
prepare:
runs-on: blacksmith-8vcpu-ubuntu-2404
runs-on: ubuntu-latest
outputs:
baseline_file_name: ${{ steps.baseline_metadata.outputs.file_name }}
baseline_spec: ${{ steps.baseline.outputs.value }}
@@ -310,7 +260,6 @@ jobs:
persist-credentials: false
- name: Checkout public source ref
if: inputs.candidate_artifact_name == ''
uses: actions/checkout@v6
with:
repository: ${{ env.OPENCLAW_REPOSITORY }}
@@ -331,10 +280,9 @@ jobs:
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
cache-dependency-path: ${{ inputs.candidate_artifact_name == '' && 'source/pnpm-lock.yaml' || 'workflow/pnpm-lock.yaml' }}
cache-dependency-path: source/pnpm-lock.yaml
- name: Build candidate artifact once
if: inputs.candidate_artifact_name == ''
env:
OUTPUT_DIR: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare
run: |
@@ -343,52 +291,6 @@ jobs:
--source-dir source \
--output-dir "${OUTPUT_DIR}"
- name: Download provided candidate artifact
if: inputs.candidate_artifact_name != ''
uses: actions/download-artifact@v8
with:
name: ${{ inputs.candidate_artifact_name }}
run-id: ${{ inputs.candidate_artifact_run_id || github.run_id }}
github-token: ${{ github.token }}
path: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/package
- name: Capture provided candidate artifact metadata
if: inputs.candidate_artifact_name != ''
env:
PACKAGE_DIR: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/package
INPUT_CANDIDATE_FILE_NAME: ${{ inputs.candidate_file_name }}
INPUT_CANDIDATE_VERSION: ${{ inputs.candidate_version }}
INPUT_CANDIDATE_SOURCE_SHA: ${{ inputs.candidate_source_sha }}
CANDIDATE_JSON: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/candidate.json
run: |
node <<'NODE'
const fs = require("node:fs");
const path = require("node:path");
const packageDir = process.env.PACKAGE_DIR;
const requestedFileName = process.env.INPUT_CANDIDATE_FILE_NAME.trim();
const files = fs.readdirSync(packageDir).filter((file) => file.endsWith(".tgz"));
const candidateFileName = requestedFileName || (files.length === 1 ? files[0] : "");
if (!candidateFileName) {
throw new Error(`Expected exactly one candidate .tgz in ${packageDir}; found ${files.length}.`);
}
if (!fs.existsSync(path.join(packageDir, candidateFileName))) {
throw new Error(`Provided candidate artifact does not contain ${candidateFileName}.`);
}
const candidateVersion = process.env.INPUT_CANDIDATE_VERSION.trim();
if (!candidateVersion) {
throw new Error("candidate_version is required when candidate_artifact_name is provided.");
}
const sourceSha = process.env.INPUT_CANDIDATE_SOURCE_SHA.trim();
if (!/^[0-9a-f]{40}$/iu.test(sourceSha)) {
throw new Error("candidate_source_sha must be a full commit SHA when candidate_artifact_name is provided.");
}
fs.writeFileSync(
process.env.CANDIDATE_JSON,
`${JSON.stringify({ candidateFileName, candidateVersion, sourceSha }, null, 2)}\n`,
);
NODE
- name: Resolve baseline package spec
if: ${{ inputs.mode != 'fresh' }}
id: baseline

View File

@@ -63,15 +63,6 @@ on:
required: false
default: ""
type: string
release_test_profile:
description: Release coverage profile for live/Docker/provider breadth
required: false
default: full
type: choice
options:
- minimum
- stable
- full
workflow_call:
inputs:
ref:
@@ -133,11 +124,6 @@ on:
required: false
default: ""
type: string
release_test_profile:
description: Release coverage profile for live/Docker/provider breadth
required: false
default: full
type: string
secrets:
OPENAI_API_KEY:
required: false
@@ -153,8 +139,6 @@ on:
required: false
CEREBRAS_API_KEY:
required: false
DEEPINFRA_API_KEY:
required: false
DASHSCOPE_API_KEY:
required: false
GROQ_API_KEY:
@@ -448,48 +432,15 @@ jobs:
- chunk_id: core
label: core
timeout_minutes: 120
- chunk_id: package-update-openai
label: package/update OpenAI install
- chunk_id: package-update
label: package/update
timeout_minutes: 180
- chunk_id: package-update-anthropic
label: package/update Anthropic install
- chunk_id: plugins-runtime
label: plugins/runtime
timeout_minutes: 180
- chunk_id: bundled-channels
label: bundled channels
timeout_minutes: 180
- chunk_id: package-update-core
label: package/update core
timeout_minutes: 120
- chunk_id: plugins-runtime-plugins
label: plugins/runtime plugins
timeout_minutes: 120
- chunk_id: plugins-runtime-services
label: plugins/runtime services
timeout_minutes: 120
- chunk_id: plugins-runtime-install-a
label: plugins/runtime install A
timeout_minutes: 120
- chunk_id: plugins-runtime-install-b
label: plugins/runtime install B
timeout_minutes: 120
- chunk_id: plugins-runtime-install-c
label: plugins/runtime install C
timeout_minutes: 120
- chunk_id: plugins-runtime-install-d
label: plugins/runtime install D
timeout_minutes: 120
- chunk_id: bundled-channels-core
label: bundled channels core
timeout_minutes: 90
- chunk_id: bundled-channels-update-a
label: bundled channels update A
timeout_minutes: 45
- chunk_id: bundled-channels-update-discord
label: bundled channels update Discord
timeout_minutes: 30
- chunk_id: bundled-channels-update-b
label: bundled channels update B
timeout_minutes: 45
- chunk_id: bundled-channels-contracts
label: bundled channels contracts
timeout_minutes: 90
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
@@ -498,7 +449,6 @@ jobs:
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
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 }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
@@ -540,7 +490,6 @@ jobs:
OPENCLAW_DOCKER_E2E_BARE_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.bare_image }}
OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.functional_image }}
OPENCLAW_DOCKER_E2E_PACKAGE_ARTIFACT_NAME: ${{ inputs.package_artifact_name || 'docker-e2e-package' }}
OPENCLAW_DOCKER_E2E_REPO_ROOT: ${{ github.workspace }}
OPENCLAW_DOCKER_E2E_SELECTED_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }}
OPENCLAW_CURRENT_PACKAGE_TGZ: .artifacts/docker-e2e-package/openclaw-current.tgz
OPENCLAW_SKIP_DOCKER_BUILD: "1"
@@ -553,13 +502,6 @@ jobs:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
fetch-depth: 1
- name: Checkout trusted release harness
uses: actions/checkout@v6
with:
ref: ${{ github.sha }}
fetch-depth: 1
path: .release-harness
- name: Log in to GHCR for shared Docker E2E image
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
with:
@@ -596,8 +538,8 @@ jobs:
export OPENCLAW_DOCKER_ALL_INCLUDE_OPENWEBUI="$INCLUDE_OPENWEBUI"
plan_path=".artifacts/docker-tests/release-${CHUNK}-plan.json"
node .release-harness/scripts/test-docker-all.mjs --plan-json > "$plan_path"
node .release-harness/scripts/docker-e2e.mjs github-outputs "$plan_path" >> "$GITHUB_OUTPUT"
node scripts/test-docker-all.mjs --plan-json > "$plan_path"
node scripts/docker-e2e.mjs github-outputs "$plan_path" >> "$GITHUB_OUTPUT"
echo "plan_json=$plan_path" >> "$GITHUB_OUTPUT"
- name: Download OpenClaw Docker E2E package
@@ -612,14 +554,14 @@ jobs:
shell: bash
run: |
set -euo pipefail
bash .release-harness/scripts/ci-docker-pull-retry.sh "${OPENCLAW_DOCKER_E2E_BARE_IMAGE}"
docker pull "${OPENCLAW_DOCKER_E2E_BARE_IMAGE}"
- name: Pull shared functional Docker E2E image
if: steps.plan.outputs.needs_functional_image == '1'
shell: bash
run: |
set -euo pipefail
bash .release-harness/scripts/ci-docker-pull-retry.sh "${OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE}"
docker pull "${OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE}"
- name: Validate Docker E2E credentials
shell: bash
@@ -653,7 +595,7 @@ jobs:
export OPENCLAW_DOCKER_ALL_TIMINGS_FILE=".artifacts/docker-tests/release-${DOCKER_E2E_CHUNK}-timings.json"
export OPENCLAW_DOCKER_ALL_PNPM_COMMAND="$(command -v pnpm)"
node .release-harness/scripts/test-docker-all.mjs
pnpm test:docker:all
- name: Summarize Docker E2E chunk
if: always()
@@ -665,7 +607,7 @@ jobs:
echo "Docker chunk summary missing: \`$summary\`" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
node .release-harness/scripts/docker-e2e.mjs summary "$summary" "Docker E2E chunk: ${DOCKER_E2E_CHUNK:-unknown}" >> "$GITHUB_STEP_SUMMARY"
node scripts/docker-e2e.mjs summary "$summary" "Docker E2E chunk: ${DOCKER_E2E_CHUNK:-unknown}" >> "$GITHUB_STEP_SUMMARY"
- name: Upload Docker E2E chunk artifacts
if: always()
@@ -675,43 +617,12 @@ jobs:
path: .artifacts/docker-tests/
if-no-files-found: ignore
plan_docker_lane_groups:
needs: validate_selected_ref
if: inputs.docker_lanes != ''
runs-on: ubuntu-24.04
timeout-minutes: 5
outputs:
groups_json: ${{ steps.plan.outputs.groups_json }}
steps:
- name: Plan targeted Docker lane groups
id: plan
shell: bash
env:
LANES: ${{ inputs.docker_lanes }}
run: |
set -euo pipefail
groups_json="$(
LANES="$LANES" node <<'NODE'
const lanes = [...new Set(String(process.env.LANES || "").split(/[,\s]+/u).map((lane) => lane.trim()).filter(Boolean))];
if (lanes.length === 0) {
throw new Error("docker_lanes is required when planning targeted Docker lane groups.");
}
const sanitize = (lane) => lane.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "targeted";
process.stdout.write(JSON.stringify(lanes.map((lane) => ({ label: sanitize(lane), docker_lanes: lane }))));
NODE
)"
echo "groups_json=${groups_json}" >> "$GITHUB_OUTPUT"
validate_docker_lanes:
needs: [validate_selected_ref, prepare_docker_e2e_image, plan_docker_lane_groups]
needs: [validate_selected_ref, prepare_docker_e2e_image]
if: inputs.docker_lanes != ''
name: Docker E2E targeted lanes (${{ matrix.group.label }})
name: Docker E2E targeted lanes
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 180
strategy:
fail-fast: false
matrix:
group: ${{ fromJson(needs.plan_docker_lane_groups.outputs.groups_json) }}
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
@@ -720,7 +631,6 @@ jobs:
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
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 }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
@@ -762,12 +672,11 @@ jobs:
OPENCLAW_DOCKER_E2E_BARE_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.bare_image }}
OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.functional_image }}
OPENCLAW_DOCKER_E2E_PACKAGE_ARTIFACT_NAME: ${{ inputs.package_artifact_name || 'docker-e2e-package' }}
OPENCLAW_DOCKER_E2E_REPO_ROOT: ${{ github.workspace }}
OPENCLAW_DOCKER_E2E_SELECTED_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }}
OPENCLAW_CURRENT_PACKAGE_TGZ: .artifacts/docker-e2e-package/openclaw-current.tgz
OPENCLAW_SKIP_DOCKER_BUILD: "1"
INCLUDE_OPENWEBUI: ${{ inputs.include_openwebui }}
DOCKER_E2E_LANES: ${{ matrix.group.docker_lanes }}
DOCKER_E2E_LANES: ${{ inputs.docker_lanes }}
steps:
- name: Checkout selected ref
uses: actions/checkout@v6
@@ -775,13 +684,6 @@ jobs:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
fetch-depth: 1
- name: Checkout trusted release harness
uses: actions/checkout@v6
with:
ref: ${{ github.sha }}
fetch-depth: 1
path: .release-harness
- name: Log in to GHCR for shared Docker E2E image
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
with:
@@ -803,7 +705,7 @@ jobs:
id: plan
shell: bash
env:
LANES: ${{ matrix.group.docker_lanes }}
LANES: ${{ inputs.docker_lanes }}
INCLUDE_OPENWEBUI: ${{ inputs.include_openwebui }}
run: |
set -euo pipefail
@@ -817,10 +719,8 @@ jobs:
export OPENCLAW_DOCKER_ALL_INCLUDE_OPENWEBUI="$INCLUDE_OPENWEBUI"
plan_path=".artifacts/docker-tests/targeted-plan.json"
node .release-harness/scripts/test-docker-all.mjs --plan-json > "$plan_path"
node .release-harness/scripts/docker-e2e.mjs github-outputs "$plan_path" >> "$GITHUB_OUTPUT"
suffix="$(printf '%s' "$LANES" | tr ',[:space:]' '-' | tr -cd 'A-Za-z0-9._-' | sed -E 's/-+/-/g; s/^-//; s/-$//')"
echo "artifact_suffix=${suffix:-targeted}" >> "$GITHUB_OUTPUT"
node scripts/test-docker-all.mjs --plan-json > "$plan_path"
node scripts/docker-e2e.mjs github-outputs "$plan_path" >> "$GITHUB_OUTPUT"
echo "plan_json=$plan_path" >> "$GITHUB_OUTPUT"
- name: Download OpenClaw Docker E2E package
@@ -835,14 +735,14 @@ jobs:
shell: bash
run: |
set -euo pipefail
bash .release-harness/scripts/ci-docker-pull-retry.sh "${OPENCLAW_DOCKER_E2E_BARE_IMAGE}"
docker pull "${OPENCLAW_DOCKER_E2E_BARE_IMAGE}"
- name: Pull shared functional Docker E2E image
if: steps.plan.outputs.needs_functional_image == '1'
shell: bash
run: |
set -euo pipefail
bash .release-harness/scripts/ci-docker-pull-retry.sh "${OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE}"
docker pull "${OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE}"
- name: Validate Docker E2E credentials
shell: bash
@@ -870,33 +770,33 @@ jobs:
export OPENCLAW_DOCKER_ALL_PREFLIGHT=0
export OPENCLAW_DOCKER_ALL_FAIL_FAST=0
export OPENCLAW_DOCKER_ALL_INCLUDE_OPENWEBUI="${INCLUDE_OPENWEBUI}"
export OPENCLAW_DOCKER_ALL_LOG_DIR=".artifacts/docker-tests/targeted-${{ steps.plan.outputs.artifact_suffix }}"
export OPENCLAW_DOCKER_ALL_TIMINGS_FILE=".artifacts/docker-tests/targeted-${{ steps.plan.outputs.artifact_suffix }}-timings.json"
export OPENCLAW_DOCKER_ALL_LOG_DIR=".artifacts/docker-tests/targeted"
export OPENCLAW_DOCKER_ALL_TIMINGS_FILE=".artifacts/docker-tests/targeted-timings.json"
export OPENCLAW_DOCKER_ALL_PNPM_COMMAND="$(command -v pnpm)"
if [[ "${{ steps.plan.outputs.needs_live_image }}" == "1" ]]; then
OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" bash .release-harness/scripts/test-live-build-docker.sh
pnpm test:docker:live-build
fi
export OPENCLAW_DOCKER_ALL_BUILD=0
node .release-harness/scripts/test-docker-all.mjs
pnpm test:docker:all
- name: Summarize targeted Docker E2E lanes
if: always()
shell: bash
run: |
set -euo pipefail
summary=".artifacts/docker-tests/targeted-${{ steps.plan.outputs.artifact_suffix }}/summary.json"
summary=".artifacts/docker-tests/targeted/summary.json"
if [[ ! -f "$summary" ]]; then
echo "Docker targeted summary missing: \`$summary\`" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
node .release-harness/scripts/docker-e2e.mjs summary "$summary" "Docker E2E targeted lanes" >> "$GITHUB_STEP_SUMMARY"
node scripts/docker-e2e.mjs summary "$summary" "Docker E2E targeted lanes" >> "$GITHUB_STEP_SUMMARY"
- name: Upload targeted Docker E2E artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: docker-e2e-${{ steps.plan.outputs.artifact_suffix }}
name: docker-e2e-targeted
path: .artifacts/docker-tests/
if-no-files-found: ignore
@@ -912,7 +812,6 @@ jobs:
OPENCLAW_DOCKER_E2E_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.image }}
OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.functional_image }}
OPENCLAW_DOCKER_E2E_PACKAGE_ARTIFACT_NAME: ${{ inputs.package_artifact_name || 'docker-e2e-package' }}
OPENCLAW_DOCKER_E2E_REPO_ROOT: ${{ github.workspace }}
OPENCLAW_DOCKER_E2E_SELECTED_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }}
OPENCLAW_CURRENT_PACKAGE_TGZ: .artifacts/docker-e2e-package/openclaw-current.tgz
OPENCLAW_SKIP_DOCKER_BUILD: "1"
@@ -923,13 +822,6 @@ jobs:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
fetch-depth: 1
- name: Checkout trusted release harness
uses: actions/checkout@v6
with:
ref: ${{ github.sha }}
fetch-depth: 1
path: .release-harness
- name: Log in to GHCR for shared Docker E2E image
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
with:
@@ -964,8 +856,8 @@ jobs:
export OPENCLAW_DOCKER_ALL_INCLUDE_OPENWEBUI=true
plan_path=".artifacts/docker-tests/release-openwebui-plan.json"
node .release-harness/scripts/test-docker-all.mjs --plan-json > "$plan_path"
node .release-harness/scripts/docker-e2e.mjs github-outputs "$plan_path" >> "$GITHUB_OUTPUT"
node scripts/test-docker-all.mjs --plan-json > "$plan_path"
node scripts/docker-e2e.mjs github-outputs "$plan_path" >> "$GITHUB_OUTPUT"
echo "plan_json=$plan_path" >> "$GITHUB_OUTPUT"
- name: Download OpenClaw Docker E2E package
@@ -980,14 +872,14 @@ jobs:
shell: bash
run: |
set -euo pipefail
bash .release-harness/scripts/ci-docker-pull-retry.sh "${OPENCLAW_DOCKER_E2E_BARE_IMAGE}"
docker pull "${OPENCLAW_DOCKER_E2E_BARE_IMAGE}"
- name: Pull shared functional Docker E2E image
if: steps.plan.outputs.needs_functional_image == '1'
shell: bash
run: |
set -euo pipefail
bash .release-harness/scripts/ci-docker-pull-retry.sh "${OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE}"
docker pull "${OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE}"
- name: Run Open WebUI Docker E2E chunk
shell: bash
@@ -1003,7 +895,7 @@ jobs:
export OPENCLAW_DOCKER_ALL_TIMINGS_FILE=".artifacts/docker-tests/release-openwebui-timings.json"
export OPENCLAW_DOCKER_ALL_PNPM_COMMAND="$(command -v pnpm)"
node .release-harness/scripts/test-docker-all.mjs
pnpm test:docker:all
- name: Summarize Open WebUI Docker E2E chunk
if: always()
@@ -1015,7 +907,7 @@ jobs:
echo "Docker Open WebUI summary missing: \`$summary\`" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
node .release-harness/scripts/docker-e2e.mjs summary "$summary" "Docker E2E chunk: openwebui" >> "$GITHUB_STEP_SUMMARY"
node scripts/docker-e2e.mjs summary "$summary" "Docker E2E chunk: openwebui" >> "$GITHUB_STEP_SUMMARY"
- name: Upload Open WebUI Docker E2E artifacts
if: always()
@@ -1046,7 +938,6 @@ jobs:
env:
DOCKER_BUILD_SUMMARY: "false"
DOCKER_BUILD_RECORD_UPLOAD: "false"
OPENCLAW_DOCKER_E2E_REPO_ROOT: ${{ github.workspace }}
steps:
- name: Checkout selected ref
uses: actions/checkout@v6
@@ -1054,13 +945,6 @@ jobs:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
fetch-depth: 1
- name: Checkout trusted release harness
uses: actions/checkout@v6
with:
ref: ${{ github.sha }}
fetch-depth: 1
path: .release-harness
- name: Plan Docker E2E images
id: plan
shell: bash
@@ -1083,8 +967,8 @@ jobs:
export OPENCLAW_DOCKER_ALL_INCLUDE_OPENWEBUI="$INCLUDE_OPENWEBUI"
plan_path=".artifacts/docker-tests/plan.json"
node .release-harness/scripts/test-docker-all.mjs --plan-json > "$plan_path"
node .release-harness/scripts/docker-e2e.mjs github-outputs "$plan_path" >> "$GITHUB_OUTPUT"
node scripts/test-docker-all.mjs --plan-json > "$plan_path"
node scripts/docker-e2e.mjs github-outputs "$plan_path" >> "$GITHUB_OUTPUT"
echo "plan_json=$plan_path" >> "$GITHUB_OUTPUT"
- name: Setup Node environment
@@ -1270,31 +1154,22 @@ jobs:
include:
- provider_label: Anthropic
providers: anthropic
profiles: stable full
- provider_label: Google
providers: google
profiles: stable full
- provider_label: MiniMax
providers: minimax
profiles: stable full
- provider_label: OpenAI
providers: openai
profiles: minimum stable full
- provider_label: OpenCode
providers: opencode-go
profiles: full
- provider_label: OpenRouter
providers: openrouter
profiles: full
- provider_label: xAI
providers: xai
profiles: full
- provider_label: Z.ai
providers: zai
profiles: full
- provider_label: Fireworks
providers: fireworks
profiles: full
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
@@ -1303,7 +1178,6 @@ jobs:
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
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 }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
@@ -1333,22 +1207,12 @@ jobs:
OPENCLAW_VITEST_MAX_WORKERS: "2"
steps:
- name: Checkout selected ref
if: contains(matrix.profiles, inputs.release_test_profile)
uses: actions/checkout@v6
with:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
fetch-depth: 1
- name: Checkout trusted live Docker harness
if: contains(matrix.profiles, inputs.release_test_profile)
uses: actions/checkout@v6
with:
ref: ${{ github.sha }}
fetch-depth: 1
path: .release-harness
- name: Setup Node environment
if: contains(matrix.profiles, inputs.release_test_profile)
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
@@ -1356,11 +1220,9 @@ jobs:
install-bun: "true"
- name: Hydrate live auth/profile inputs
if: contains(matrix.profiles, inputs.release_test_profile)
run: bash scripts/ci-hydrate-live-auth.sh
- name: Validate provider credential
if: contains(matrix.profiles, inputs.release_test_profile)
shell: bash
run: |
set -euo pipefail
@@ -1395,8 +1257,7 @@ jobs:
esac
- name: Run Docker live model sweep
if: contains(matrix.profiles, inputs.release_test_profile)
run: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" bash .release-harness/scripts/test-live-models-docker.sh
run: pnpm test:docker:live-models
validate_live_models_docker_targeted:
name: Docker live models (selected providers)
@@ -1412,7 +1273,6 @@ jobs:
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
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 }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
@@ -1447,13 +1307,6 @@ jobs:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
fetch-depth: 1
- name: Checkout trusted live Docker harness
uses: actions/checkout@v6
with:
ref: ${{ github.sha }}
fetch-depth: 1
path: .release-harness
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
@@ -1561,7 +1414,7 @@ jobs:
done
- name: Run Docker live model sweep
run: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" bash .release-harness/scripts/test-live-models-docker.sh
run: pnpm test:docker:live-models
validate_live_provider_suites:
needs: validate_selected_ref
@@ -1574,200 +1427,58 @@ jobs:
include:
- suite_id: native-live-src-agents
label: Native live agents
command: node .release-harness/scripts/test-live-shard.mjs native-live-src-agents
command: node scripts/test-live-shard.mjs native-live-src-agents
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: stable full
- suite_id: native-live-src-gateway-core
label: Native live gateway core
command: node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-core
- suite_id: native-live-src-gateway
label: Native live gateway
command: node scripts/test-live-shard.mjs native-live-src-gateway
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: minimum stable full
- suite_id: native-live-src-gateway-profiles-anthropic
label: Native live gateway profiles Anthropic
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: stable full
- suite_id: native-live-src-gateway-profiles-google
label: Native live gateway profiles Google
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=google OPENCLAW_LIVE_GATEWAY_MODELS=google/gemini-3.1-pro-preview,google/gemini-3-flash-preview node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
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 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: stable full
- suite_id: native-live-src-gateway-profiles-openai
label: Native live gateway profiles OpenAI
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=openai OPENCLAW_LIVE_GATEWAY_MODELS=openai/gpt-5.5 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: minimum stable full
- suite_id: native-live-src-gateway-profiles-fireworks
label: Native live gateway profiles Fireworks
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=fireworks node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: full
- suite_id: native-live-src-gateway-profiles-deepseek
label: Native live gateway profiles DeepSeek
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=deepseek node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: full
- suite_id: native-live-src-gateway-profiles-opencode-go
label: Native live gateway profiles OpenCode Go deep
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=opencode-go node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: full
- suite_id: native-live-src-gateway-profiles-opencode-go-smoke
label: Native live gateway profiles OpenCode Go smoke
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=opencode-go OPENCLAW_LIVE_GATEWAY_SMOKE=1 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 45
needs_ffmpeg: false
profile_env_only: false
profiles: stable
- suite_id: native-live-src-gateway-profiles-openrouter
label: Native live gateway profiles OpenRouter
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=openrouter node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: full
- suite_id: native-live-src-gateway-profiles-xai
label: Native live gateway profiles xAI
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=xai node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: full
- suite_id: native-live-src-gateway-profiles-zai
label: Native live gateway profiles Z.ai
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=zai node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: full
- suite_id: native-live-src-gateway-backends
label: Native live gateway backends
command: node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-backends
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: stable full
- suite_id: native-live-test
label: Native live test harnesses
command: node .release-harness/scripts/test-live-shard.mjs native-live-test
command: node scripts/test-live-shard.mjs native-live-test
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: stable full
- suite_id: native-live-extensions-a-k
label: Native live plugins A-K
command: node .release-harness/scripts/test-live-shard.mjs native-live-extensions-a-k
command: node scripts/test-live-shard.mjs native-live-extensions-a-k
timeout_minutes: 90
needs_ffmpeg: true
profile_env_only: false
profiles: full
- suite_id: native-live-extensions-l-n
label: Native live plugins L-N
command: node .release-harness/scripts/test-live-shard.mjs native-live-extensions-l-n
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: full
- suite_id: native-live-extensions-openai
label: Native live OpenAI plugin
command: node .release-harness/scripts/test-live-shard.mjs native-live-extensions-openai
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: minimum stable full
- suite_id: native-live-extensions-o-z-other
label: Native live plugins O-Z other
command: node .release-harness/scripts/test-live-shard.mjs native-live-extensions-o-z-other
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: full
- suite_id: native-live-extensions-xai
label: Native live xAI plugin
command: node .release-harness/scripts/test-live-shard.mjs native-live-extensions-xai
timeout_minutes: 90
needs_ffmpeg: false
profile_env_only: false
profiles: full
- suite_id: native-live-extensions-media-audio
label: Native live media audio plugins
command: node .release-harness/scripts/test-live-shard.mjs native-live-extensions-media-audio
- suite_id: native-live-extensions-l-z
label: Native live plugins L-Z
command: node scripts/test-live-shard.mjs native-live-extensions-l-z
timeout_minutes: 90
needs_ffmpeg: true
profile_env_only: false
profiles: full
- suite_id: native-live-extensions-media-music-google
label: Native live media music Google
command: OPENCLAW_LIVE_MUSIC_GENERATION_PROVIDERS=google node .release-harness/scripts/test-live-shard.mjs native-live-extensions-media-music-google
timeout_minutes: 90
needs_ffmpeg: true
profile_env_only: false
profiles: full
- suite_id: native-live-extensions-media-music-minimax
label: Native live media music MiniMax
command: OPENCLAW_LIVE_MUSIC_GENERATION_PROVIDERS=minimax node .release-harness/scripts/test-live-shard.mjs native-live-extensions-media-music-minimax
timeout_minutes: 90
needs_ffmpeg: true
profile_env_only: false
profiles: full
- suite_id: native-live-extensions-media-video
label: Native live media video plugins
command: node .release-harness/scripts/test-live-shard.mjs native-live-extensions-media-video
timeout_minutes: 90
needs_ffmpeg: true
profile_env_only: false
profiles: full
- suite_id: live-gateway-docker
label: Docker live gateway
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" bash .release-harness/scripts/test-live-gateway-models-docker.sh
command: pnpm test:docker:live-gateway
timeout_minutes: 120
needs_ffmpeg: false
profile_env_only: false
profiles: minimum stable full
- suite_id: live-cli-backend-docker
label: Docker live CLI backend
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" bash .release-harness/scripts/test-live-cli-backend-docker.sh
command: pnpm test:docker:live-cli-backend
timeout_minutes: 120
needs_ffmpeg: false
profile_env_only: false
profiles: stable full
- suite_id: live-acp-bind-docker
label: Docker live ACP bind
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" bash .release-harness/scripts/test-live-acp-bind-docker.sh
command: pnpm test:docker:live-acp-bind
timeout_minutes: 120
needs_ffmpeg: false
profile_env_only: false
profiles: stable full
- suite_id: live-codex-harness-docker
label: Docker live Codex harness
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" bash .release-harness/scripts/test-live-codex-harness-docker.sh
command: pnpm test:docker:live-codex-harness
timeout_minutes: 120
needs_ffmpeg: false
profile_env_only: false
profiles: stable full
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
@@ -1776,7 +1487,6 @@ jobs:
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
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 }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
@@ -1819,22 +1529,12 @@ jobs:
OPENCLAW_VITEST_MAX_WORKERS: "2"
steps:
- name: Checkout selected ref
if: contains(matrix.profiles, inputs.release_test_profile)
uses: actions/checkout@v6
with:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
fetch-depth: 1
- name: Checkout trusted live shard harness
if: contains(matrix.profiles, inputs.release_test_profile)
uses: actions/checkout@v6
with:
ref: ${{ github.sha }}
fetch-depth: 1
path: .release-harness
- name: Setup Node environment
if: contains(matrix.profiles, inputs.release_test_profile)
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
@@ -1842,11 +1542,10 @@ jobs:
install-bun: "true"
- name: Hydrate live auth/profile inputs
if: contains(matrix.profiles, inputs.release_test_profile)
run: bash scripts/ci-hydrate-live-auth.sh
- name: Install live media dependencies
if: matrix.needs_ffmpeg && contains(matrix.profiles, inputs.release_test_profile)
if: matrix.needs_ffmpeg
shell: bash
run: |
set -euo pipefail
@@ -1865,7 +1564,6 @@ jobs:
ffmpeg -version | head -1
- name: Configure suite-specific env
if: contains(matrix.profiles, inputs.release_test_profile)
shell: bash
run: |
set -euo pipefail
@@ -1912,5 +1610,4 @@ jobs:
esac
- name: Run ${{ matrix.label }}
if: contains(matrix.profiles, inputs.release_test_profile)
run: ${{ matrix.command }}

View File

@@ -7,11 +7,6 @@ on:
description: Branch, tag, or full commit SHA to validate
required: true
type: string
expected_sha:
description: Optional full SHA that ref must resolve to
required: false
default: ""
type: string
provider:
description: Provider lane for cross-OS onboarding and the end-to-end agent turn
required: false
@@ -30,29 +25,6 @@ on:
- fresh
- upgrade
- both
release_profile:
description: Release coverage profile for live/Docker/provider breadth
required: false
default: full
type: choice
options:
- minimum
- stable
- full
rerun_group:
description: Release check group to run
required: false
default: all
type: choice
options:
- all
- install-smoke
- cross-os
- live-e2e
- package
- qa
- qa-parity
- qa-live
concurrency:
group: openclaw-release-checks-${{ inputs.ref }}
@@ -75,8 +47,6 @@ jobs:
sha: ${{ steps.ref.outputs.sha }}
provider: ${{ steps.inputs.outputs.provider }}
mode: ${{ steps.inputs.outputs.mode }}
release_profile: ${{ steps.inputs.outputs.release_profile }}
rerun_group: ${{ steps.inputs.outputs.rerun_group }}
steps:
- name: Require main or release workflow ref for release checks
env:
@@ -91,54 +61,24 @@ jobs:
- name: Validate ref input
env:
RELEASE_REF: ${{ inputs.ref }}
EXPECTED_SHA: ${{ inputs.expected_sha }}
run: |
set -euo pipefail
if [[ -z "${RELEASE_REF// }" ]] || [[ "${RELEASE_REF}" == -* ]]; then
echo "Expected a branch, tag, or full commit SHA; got: ${RELEASE_REF}" >&2
exit 1
fi
if [[ -n "${EXPECTED_SHA// }" ]] && [[ ! "${EXPECTED_SHA}" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "Expected expected_sha to be a full commit SHA; got: ${EXPECTED_SHA}" >&2
exit 1
fi
- name: Checkout trusted workflow helper
uses: actions/checkout@v6
with:
ref: ${{ github.ref_name }}
path: workflow
fetch-depth: 1
- name: Fast-resolve selected ref
id: fast_ref
env:
RELEASE_REF: ${{ inputs.ref }}
EXPECTED_SHA: ${{ inputs.expected_sha }}
run: |
bash workflow/scripts/github/resolve-openclaw-ref.sh \
--ref "$RELEASE_REF" \
--expected-sha "$EXPECTED_SHA" \
--fallback-ok \
--github-output "$GITHUB_OUTPUT"
- name: Checkout selected ref for reachability fallback
if: steps.fast_ref.outputs.fallback == 'true'
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref }}
path: source
fetch-depth: 0
- name: Resolve checked-out fallback SHA
if: steps.fast_ref.outputs.fallback == 'true'
id: fallback_ref
working-directory: source
- name: Resolve checked-out SHA
id: ref
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Validate selected ref belongs to this repository
if: steps.fast_ref.outputs.fallback == 'true'
working-directory: source
env:
RELEASE_REF: ${{ inputs.ref }}
run: |
@@ -159,162 +99,54 @@ jobs:
echo "Secret-bearing release checks only run repository-owned branch/tag history, not arbitrary unreferenced commits." >&2
exit 1
- name: Finalize resolved SHA
id: ref
env:
FAST_SHA: ${{ steps.fast_ref.outputs.sha }}
FALLBACK_SHA: ${{ steps.fallback_ref.outputs.sha }}
EXPECTED_SHA: ${{ inputs.expected_sha }}
USED_FALLBACK: ${{ steps.fast_ref.outputs.fallback }}
run: |
set -euo pipefail
selected_sha="$FAST_SHA"
if [[ "$USED_FALLBACK" == "true" ]]; then
selected_sha="$FALLBACK_SHA"
fi
if [[ -z "$selected_sha" ]]; then
echo "Failed to resolve selected ref SHA." >&2
exit 1
fi
if [[ -n "${EXPECTED_SHA// }" ]] && [[ "${selected_sha,,}" != "${EXPECTED_SHA,,}" ]]; then
echo "Ref resolved to ${selected_sha}, expected ${EXPECTED_SHA}." >&2
exit 1
fi
echo "sha=${selected_sha,,}" >> "$GITHUB_OUTPUT"
- name: Capture selected inputs
id: inputs
env:
RELEASE_REF_INPUT: ${{ inputs.ref }}
RELEASE_PROVIDER_INPUT: ${{ inputs.provider }}
RELEASE_MODE_INPUT: ${{ inputs.mode }}
RELEASE_PROFILE_INPUT: ${{ inputs.release_profile }}
RELEASE_RERUN_GROUP_INPUT: ${{ inputs.rerun_group }}
run: |
set -euo pipefail
{
printf 'ref=%s\n' "$RELEASE_REF_INPUT"
printf 'provider=%s\n' "$RELEASE_PROVIDER_INPUT"
printf 'mode=%s\n' "$RELEASE_MODE_INPUT"
printf 'release_profile=%s\n' "$RELEASE_PROFILE_INPUT"
printf 'rerun_group=%s\n' "$RELEASE_RERUN_GROUP_INPUT"
} >> "$GITHUB_OUTPUT"
- name: Summarize validated ref
env:
RELEASE_REF: ${{ inputs.ref }}
RELEASE_SHA: ${{ steps.ref.outputs.sha }}
RELEASE_REF_FAST_PATH: ${{ steps.fast_ref.outputs.fast }}
RELEASE_PROVIDER: ${{ inputs.provider }}
RELEASE_MODE: ${{ inputs.mode }}
RELEASE_PROFILE: ${{ inputs.release_profile }}
RELEASE_RERUN_GROUP: ${{ inputs.rerun_group }}
run: |
{
echo "## Release checks"
echo
echo "- Requested ref: \`${RELEASE_REF}\`"
echo "- Validated SHA: \`${RELEASE_SHA}\`"
echo "- Ref resolution fast path: \`${RELEASE_REF_FAST_PATH}\`"
echo "- Cross-OS provider: \`${RELEASE_PROVIDER}\`"
echo "- Cross-OS mode: \`${RELEASE_MODE}\`"
echo "- Release profile: \`${RELEASE_PROFILE}\`"
echo "- Rerun group: \`${RELEASE_RERUN_GROUP}\`"
echo "- This run will execute cross-OS release validation, install smoke, QA Lab parity, Matrix, and Telegram lanes, and the non-Parallels Docker/live/openwebui coverage from the CI migration plan."
} >> "$GITHUB_STEP_SUMMARY"
prepare_release_package:
name: Prepare release package artifact
needs: [resolve_target]
if: contains(fromJSON('["all","cross-os","live-e2e","package"]'), needs.resolve_target.outputs.rerun_group)
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 60
permissions:
contents: read
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@v6
with:
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 }}
pnpm-version: ${{ env.PNPM_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@v7
with:
name: release-package-under-test
path: .artifacts/docker-e2e-package/openclaw-current.tgz
retention-days: 14
if-no-files-found: error
install_smoke_release_checks:
needs: [resolve_target]
if: contains(fromJSON('["all","install-smoke"]'), needs.resolve_target.outputs.rerun_group)
permissions:
contents: read
uses: ./.github/workflows/install-smoke.yml
with:
ref: ${{ needs.resolve_target.outputs.sha }}
ref: ${{ needs.resolve_target.outputs.ref }}
run_bun_global_install_smoke: true
cross_os_release_checks:
needs: [resolve_target, prepare_release_package]
if: contains(fromJSON('["all","cross-os"]'), needs.resolve_target.outputs.rerun_group)
needs: [resolve_target]
permissions: read-all
uses: ./.github/workflows/openclaw-cross-os-release-checks-reusable.yml
with:
ref: ${{ needs.resolve_target.outputs.ref }}
provider: ${{ needs.resolve_target.outputs.provider }}
mode: ${{ needs.resolve_target.outputs.mode }}
candidate_artifact_name: ${{ needs.prepare_release_package.outputs.artifact_name }}
candidate_artifact_run_id: ${{ github.run_id }}
candidate_file_name: openclaw-current.tgz
candidate_version: ${{ needs.prepare_release_package.outputs.package_version }}
candidate_source_sha: ${{ needs.prepare_release_package.outputs.source_sha }}
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
@@ -324,8 +156,7 @@ jobs:
OPENCLAW_DISCORD_SMOKE_CHANNEL_ID: ${{ secrets.OPENCLAW_DISCORD_SMOKE_CHANNEL_ID }}
live_and_e2e_release_checks:
needs: [resolve_target, prepare_release_package]
if: contains(fromJSON('["all","live-e2e"]'), needs.resolve_target.outputs.rerun_group)
needs: [resolve_target]
permissions:
actions: read
contents: read
@@ -333,14 +164,11 @@ jobs:
pull-requests: read
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
with:
ref: ${{ needs.resolve_target.outputs.sha }}
ref: ${{ needs.resolve_target.outputs.ref }}
include_repo_e2e: true
include_release_path_suites: true
include_openwebui: ${{ needs.resolve_target.outputs.release_profile != 'minimum' }}
include_openwebui: true
include_live_suites: true
release_test_profile: ${{ needs.resolve_target.outputs.release_profile }}
package_artifact_name: ${{ needs.prepare_release_package.outputs.artifact_name }}
package_artifact_run_id: ${{ github.run_id }}
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
@@ -349,7 +177,6 @@ jobs:
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 }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
@@ -390,8 +217,7 @@ jobs:
package_acceptance_release_checks:
name: Run package acceptance
needs: [resolve_target, prepare_release_package]
if: contains(fromJSON('["all","package"]'), needs.resolve_target.outputs.rerun_group)
needs: [resolve_target]
permissions:
actions: read
contents: read
@@ -400,14 +226,11 @@ jobs:
uses: ./.github/workflows/package-acceptance.yml
with:
workflow_ref: ${{ github.ref_name }}
source: artifact
artifact_run_id: ${{ github.run_id }}
artifact_name: ${{ needs.prepare_release_package.outputs.artifact_name }}
package_sha256: ${{ needs.prepare_release_package.outputs.package_sha256 }}
source: ref
package_ref: ${{ needs.resolve_target.outputs.ref }}
suite_profile: custom
docker_lanes: bundled-channel-deps-compat plugins-offline
telegram_mode: mock-openai
telegram_scenarios: telegram-help-command,telegram-commands-command,telegram-tools-compact-command,telegram-whoami-command,telegram-context-command,telegram-mention-gating
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
@@ -416,7 +239,6 @@ jobs:
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 }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
@@ -457,22 +279,13 @@ jobs:
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
qa_lab_parity_lane_release_checks:
name: Run QA Lab parity lane (${{ matrix.lane }})
qa_lab_parity_release_checks:
name: Run QA Lab parity gate
needs: [resolve_target]
if: contains(fromJSON('["all","qa","qa-parity"]'), needs.resolve_target.outputs.rerun_group)
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 30
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- lane: candidate
output_dir: gpt54
- lane: baseline
output_dir: opus46
env:
QA_PARITY_CONCURRENCY: "1"
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"
@@ -488,7 +301,7 @@ jobs:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ needs.resolve_target.outputs.sha }}
ref: ${{ needs.resolve_target.outputs.ref }}
fetch-depth: 1
- name: Setup Node environment
@@ -501,80 +314,25 @@ jobs:
- name: Build private QA runtime
run: pnpm build
- name: Run parity lane
env:
QA_PARITY_LANE: ${{ matrix.lane }}
QA_PARITY_OUTPUT_DIR: ${{ matrix.output_dir }}
- name: Run OpenAI candidate lane
run: |
set -euo pipefail
case "${QA_PARITY_LANE}" in
candidate)
model="${OPENCLAW_CI_OPENAI_MODEL}"
alt_model="openai/gpt-5.4-alt"
;;
baseline)
model="anthropic/claude-opus-4-6"
alt_model="anthropic/claude-sonnet-4-6"
;;
*)
echo "Unknown QA parity lane: ${QA_PARITY_LANE}" >&2
exit 1
;;
esac
pnpm openclaw qa suite \
--provider-mode mock-openai \
--parity-pack agentic \
--concurrency "${QA_PARITY_CONCURRENCY}" \
--model "${model}" \
--alt-model "${alt_model}" \
--output-dir ".artifacts/qa-e2e/${QA_PARITY_OUTPUT_DIR}"
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model openai/gpt-5.4-alt \
--output-dir .artifacts/qa-e2e/gpt54
- name: Upload parity lane artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: release-qa-parity-${{ matrix.lane }}-${{ needs.resolve_target.outputs.sha }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: warn
qa_lab_parity_report_release_checks:
name: Run QA Lab parity report
needs: [resolve_target, qa_lab_parity_lane_release_checks]
if: contains(fromJSON('["all","qa","qa-parity"]'), needs.resolve_target.outputs.rerun_group)
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 20
permissions:
contents: read
actions: read
env:
OPENCLAW_BUILD_PRIVATE_QA: "1"
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
steps:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ needs.resolve_target.outputs.sha }}
fetch-depth: 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "true"
- name: Download parity lane artifacts
uses: actions/download-artifact@v4
with:
pattern: release-qa-parity-*-${{ needs.resolve_target.outputs.sha }}
path: .artifacts/qa-e2e/
merge-multiple: true
- name: Build private QA runtime
run: pnpm build
- name: Run Opus 4.6 lane
run: |
pnpm openclaw qa suite \
--provider-mode mock-openai \
--parity-pack agentic \
--concurrency "${QA_PARITY_CONCURRENCY}" \
--model anthropic/claude-opus-4-6 \
--alt-model anthropic/claude-sonnet-4-6 \
--output-dir .artifacts/qa-e2e/opus46
- name: Generate parity report
run: |
@@ -598,7 +356,6 @@ jobs:
qa_live_matrix_release_checks:
name: Run QA Lab live Matrix lane
needs: [resolve_target]
if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group)
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 60
permissions:
@@ -612,7 +369,7 @@ jobs:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ needs.resolve_target.outputs.sha }}
ref: ${{ needs.resolve_target.outputs.ref }}
fetch-depth: 1
- name: Setup Node environment
@@ -670,14 +427,13 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: release-qa-live-matrix-${{ needs.resolve_target.outputs.sha }}
path: .artifacts/qa-e2e/
path: ${{ steps.run_lane.outputs.output_dir }}
retention-days: 14
if-no-files-found: warn
qa_live_telegram_release_checks:
name: Run QA Lab live Telegram lane
needs: [resolve_target]
if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group)
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 60
permissions:
@@ -691,7 +447,7 @@ jobs:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ needs.resolve_target.outputs.sha }}
ref: ${{ needs.resolve_target.outputs.ref }}
fetch-depth: 1
- name: Setup Node environment
@@ -755,25 +511,22 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: release-qa-live-telegram-${{ needs.resolve_target.outputs.sha }}
path: .artifacts/qa-e2e/
path: ${{ steps.run_lane.outputs.output_dir }}
retention-days: 14
if-no-files-found: warn
summary:
name: Verify release checks
needs:
- prepare_release_package
- install_smoke_release_checks
- cross_os_release_checks
- live_and_e2e_release_checks
- package_acceptance_release_checks
- qa_lab_parity_lane_release_checks
- qa_lab_parity_report_release_checks
- qa_lab_parity_release_checks
- qa_live_matrix_release_checks
- qa_live_telegram_release_checks
if: always()
runs-on: ubuntu-24.04
permissions: {}
timeout-minutes: 5
steps:
- name: Verify release check results
@@ -782,13 +535,11 @@ jobs:
set -euo pipefail
failed=0
for item in \
"prepare_release_package=${{ needs.prepare_release_package.result }}" \
"install_smoke_release_checks=${{ needs.install_smoke_release_checks.result }}" \
"cross_os_release_checks=${{ needs.cross_os_release_checks.result }}" \
"live_and_e2e_release_checks=${{ needs.live_and_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 }}" \
"qa_lab_parity_report_release_checks=${{ needs.qa_lab_parity_report_release_checks.result }}" \
"qa_lab_parity_release_checks=${{ needs.qa_lab_parity_release_checks.result }}" \
"qa_live_matrix_release_checks=${{ needs.qa_live_matrix_release_checks.result }}" \
"qa_live_telegram_release_checks=${{ needs.qa_live_telegram_release_checks.result }}"
do

View File

@@ -38,7 +38,6 @@ jobs:
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 }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}

View File

@@ -73,11 +73,6 @@ on:
- none
- mock-openai
- live-frontier
telegram_scenarios:
description: Optional comma-separated Telegram scenario ids
required: false
default: ""
type: string
workflow_call:
inputs:
workflow_ref:
@@ -134,11 +129,6 @@ on:
required: false
default: none
type: string
telegram_scenarios:
description: Optional comma-separated Telegram scenario ids
required: false
default: ""
type: string
secrets:
OPENAI_API_KEY:
required: false
@@ -154,8 +144,6 @@ on:
required: false
CEREBRAS_API_KEY:
required: false
DEEPINFRA_API_KEY:
required: false
DASHSCOPE_API_KEY:
required: false
GROQ_API_KEY:
@@ -445,7 +433,6 @@ jobs:
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 }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
@@ -495,7 +482,6 @@ jobs:
package_label: openclaw@${{ needs.resolve_package.outputs.package_version }}
harness_ref: ${{ inputs.source == 'ref' && inputs.package_ref || inputs.workflow_ref }}
provider_mode: ${{ needs.resolve_package.outputs.telegram_mode }}
scenario: ${{ inputs.telegram_scenarios }}
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}

2
.gitignore vendored
View File

@@ -102,8 +102,6 @@ USER.md
.agents/skills/*
!.agents/skills/blacksmith-testbox/
!.agents/skills/blacksmith-testbox/**
!.agents/skills/gitcrawl/
!.agents/skills/gitcrawl/**
!.agents/skills/openclaw-ghsa-maintainer/
!.agents/skills/openclaw-ghsa-maintainer/**
!.agents/skills/openclaw-parallels-smoke/

View File

@@ -55,8 +55,7 @@ Telegraph style. Root rules only. Read scoped `AGENTS.md` before subtree work.
- Formatting: use `oxfmt`, not Prettier. Prefer `pnpm format:check` / `pnpm format`; for targeted files use `pnpm exec oxfmt --check --threads=1 <files...>` or `pnpm exec oxfmt --write --threads=1 <files...>`.
- Linting: use repo wrappers (`pnpm lint:*`, `scripts/run-oxlint.mjs`); do not invoke generic JS formatters/lints unless a repo script uses them.
- Heavy checks: `OPENCLAW_LOCAL_CHECK=1`, mode `OPENCLAW_LOCAL_CHECK_MODE=throttled|full`; CI/shared use `OPENCLAW_LOCAL_CHECK=0`.
- Blacksmith/Testbox: on maintainer machines with Blacksmith access, broad/shared validation defaults to Testbox. This includes `pnpm check`, `pnpm check:changed`, `pnpm test`, `pnpm test:changed`, Docker/E2E/live/package/build gates, and any command likely to fan out across many Vitest projects. Do not start those broad gates locally unless the user explicitly asks for local proof or sets `OPENCLAW_LOCAL_CHECK_MODE=throttled|full`.
- Local validation: targeted edit loops only, such as `pnpm test <specific-file>`, targeted formatter checks, and small lint/type probes. If a local command expands beyond targeted proof, stop it and move the broad gate to Testbox.
- Blacksmith/Testbox is maintainer opt-in, not a repo-wide default. If Blacksmith access is available and `OPENCLAW_TESTBOX=1` is set, or a maintainer's personal AGENTS rules ask for it, use Testbox for broad, slow, Docker, live, E2E, full-suite, or CI-parity validation instead of running those heavy lanes locally. Use `OPENCLAW_LOCAL_CHECK_MODE=throttled|full` as the explicit local escape hatch.
- Testbox use: run from repo root, pre-warm early with `blacksmith testbox warmup ci-check-testbox.yml --ref main --idle-timeout 90`, reuse the returned `tbx_...` id for all `run`/`download` commands, and stop boxes you created before handoff. Timeout bins: `90` minutes default, `240` multi-hour, `720` all-day, `1440` overnight; anything above `1440` needs explicit approval and cleanup.
- Testbox full-suite profile: `blacksmith testbox run --id <ID> "env NODE_OPTIONS=--max-old-space-size=4096 OPENCLAW_TEST_PROJECTS_PARALLEL=6 OPENCLAW_VITEST_MAX_WORKERS=1 pnpm test"`. For installable package proof, prefer the GitHub `Package Acceptance` workflow over ad hoc Testbox commands.
@@ -92,8 +91,7 @@ Telegraph style. Root rules only. Read scoped `AGENTS.md` before subtree work.
- extension tests: extension test typecheck/tests
- public SDK/plugin contract: extension prod/test too
- unknown root/config: all lanes
- Before handoff/push for code/test/runtime/config changes: run `pnpm check:changed` in Testbox by default on maintainer machines. Tests-only: run `pnpm test:changed` in Testbox by default. Full prod sweep: run `pnpm check` in Testbox. Use local only for narrow targeted proof or when explicitly requested.
- If `pnpm test:changed` or `pnpm check:changed` selects broad/shared lanes, it belongs in Testbox; do not let it continue locally after it fans out.
- Before handoff/push for code/test/runtime/config changes: `pnpm check:changed`. Tests-only: `pnpm test:changed`. Full prod sweep: `pnpm check`.
- Docs/changelog-only and CI/workflow metadata-only changes are not changed-gate work by default. Use `git diff --check` plus the relevant formatter/docs/workflow sanity check; escalate to `pnpm check:changed` only when scripts, test config, generated docs/API, package metadata, or runtime/build behavior changed.
- Rebase sanity: after a green `pnpm check:changed`, a clean rebase onto current
`origin/main` does not require rerunning the full changed gate when the rebase

View File

@@ -4,274 +4,37 @@ Docs: https://docs.openclaw.ai
## Unreleased
### Changes
- iOS/Gateway: add an authenticated `node.presence.alive` protocol event and `node.list` last-seen fields so background iOS wakes can mark paired nodes recently alive without treating them as connected. Carries forward #63123. Thanks @ngutman.
- Android: publish authenticated `node.presence.alive` events after node connect and background transitions so paired Android nodes retain durable last-seen metadata after disconnects. Carries forward #63123. Thanks @ngutman.
- Gateway/chat: accept non-image attachments through `chat.send` by staging them as agent-readable media paths, while keeping unsupported RPC attachment paths explicit instead of silently dropping files. Fixes #48123. (#67572) Thanks @samzong.
- Security/networking: add opt-in operator-managed outbound proxy routing (proxy.enabled + proxy.proxyUrl/OPENCLAW_PROXY_URL) with strict http:// forward-proxy validation, loopback-only Gateway bypass, and cleanup of proxy env/dispatcher state on exit. (#70044) Thanks @jesse-merhi and @joshavant.
### Fixes
- Plugins/startup: precompute bundled runtime mirror fingerprints before taking the mirror lock, including dist-runtime canonical roots, so Docker Desktop/WSL cold starts no longer hold `.openclaw-runtime-mirror.lock` while scanning slow persisted volumes. Fixes #73339. Thanks @1yihui.
- Channels/LINE: persist inbound image, video, audio, and file downloads in `~/.openclaw/media/inbound/` instead of temporary files so agents can still read LINE media after `/tmp` cleanup. Fixes #73370. Thanks @hijirii and @wenxu007.
- Control UI/WebChat: keep large attachment payloads out of Lit state and optimistic chat messages, using object URL previews plus send-time payload serialization so PDF/image uploads no longer trigger `RangeError: Maximum call stack size exceeded`. Fixes #73360; refs #54378 and #63432. Thanks @hejunhui-73, @Ansub, and @christianhernandez3-afk.
- Agents/Anthropic: cancel stalled Anthropic Messages SSE body reads when abort signals fire, so active-memory timeouts release transport resources instead of leaving hidden recall runs parked on `reader.read()`. Refs #72965 and #73120. Thanks @wdeveloper16.
- Agents/models: keep per-agent primary models strict when `fallbacks` is omitted, so probe-only custom providers are not tried as hidden fallback candidates unless the agent explicitly opts in. Fixes #73332. Thanks @haumanto.
- Gateway/models: add `models.pricing.enabled` so offline or restricted-network installs can skip startup OpenRouter and LiteLLM pricing-catalog fetches while keeping explicit model costs working. Fixes #53639. Thanks @callebtc, @palewire, and @rjdjohnston.
- Onboarding: pin interactive and non-interactive health checks to the just-configured setup token/password so stale `OPENCLAW_GATEWAY_TOKEN` or `OPENCLAW_GATEWAY_PASSWORD` values do not produce false gateway-token-mismatch failures after setup. Fixes #72203. Thanks @galiniliev.
- Doctor/state: require an interactive confirmation before archiving orphan transcript files, so `openclaw doctor --fix` no longer silently renames recoverable session history after upgrades regenerate `sessions.json`. Fixes #73106. Thanks @scottgl9.
- Cron/Telegram: preserve explicit `:topic:` delivery targets over stale session-derived thread IDs when isolated cron announces to Telegram forum topics. Carries forward #59069; refs #49704 and #43808. Thanks @roytong9.
- Build/runtime: write the runtime-postbuild stamp after `pnpm build` writes the build stamp, so the next CLI invocation does not re-sync runtime artifacts after a successful build. Fixes #73151. Thanks @bittoby.
- Build/runtime: preserve staged bundled-plugin runtime dependency caches across source-checkout tsdown rebuilds, so local CLI and gateway-watch rebuilds no longer recreate large plugin dependency trees before starting. Refs #73205. Thanks @SymbolStar.
- CLI/channels: list configured chat channel accounts from read-only setup metadata even when the standalone CLI has not loaded the runtime channel registry, so `openclaw channels list` shows Telegram accounts before auth providers. Fixes #73319 and #73322. Thanks @mlaihk.
- CLI/model probes: keep `infer model run --gateway` raw by skipping prior session transcript, bootstrap context, context-engine assembly, tools, and bundled MCP servers, so local backends can be tested without full agent-context overhead. Fixes #73308. Thanks @ScientificProgrammer.
- CLI/model probes: reject empty or whitespace-only `infer model run --prompt` values before calling local providers or the Gateway, so smoke checks do not spend provider calls on invalid turns. Fixes #73185. Thanks @iot2edge.
- Gateway/media: route text-only `chat.send` image offloads through media-understanding fields so `agents.defaults.imageModel` can describe WebChat attachments instead of leaving only an opaque `media://inbound` marker. Fixes #72968. Thanks @vorajeeah.
- Gateway/Windows: route no-listener restart handoffs through the Windows supervisor without leaving restart tokens in flight, so failed task scheduling can be retried and successful handoffs do not coalesce later restart requests. (#69056) Thanks @Thatgfsj.
- Gateway/model pricing: skip plugin manifest discovery during background pricing refreshes when `plugins.enabled: false`, so disabled-plugin setups do not keep rebuilding plugin metadata from the Gateway hot path. Fixes #73291. Thanks @slideshow-dingo and @fishgills.
- Ollama/thinking: validate `/think` commands against live Ollama catalog reasoning metadata, so models whose `/api/show` capabilities include `thinking` expose `low`, `medium`, `high`, and `max` instead of being stuck on `off`. Fixes #73366. Thanks @cymise.
- Gateway/sessions: remove automatic oversized `sessions.json` rotation backups, deprecate `session.maintenance.rotateBytes`, and teach `openclaw doctor --fix` to remove the ignored key so hot session writes no longer copy multi-MB stores. Refs #72338. Thanks @midhunmonachan and @DougButdorf.
- Channels/Telegram: fail fast when Telegram rejects the startup `getMe` token probe with 401, so invalid or stale BotFather tokens are reported as token auth failures instead of misleading `deleteWebhook` cleanup failures. Fixes #47674. Thanks @samaedan-arch.
- ACPX: keep generated Codex and Claude ACP wrapper startup paths working when remote or special state filesystems reject chmod, since OpenClaw invokes the wrappers through Node instead of executing them directly. Fixes #73333. Thanks @david-garcia-garcia.
- CLI/onboarding: infer image input for common custom-provider vision model IDs, ask only for unknown models, and keep `--custom-image-input`/`--custom-text-input` overrides so vision-capable proxies do not get saved as text-only configs. Fixes #51869. Thanks @Antsoldier1974.
- Models/OpenAI Codex: stop listing or resolving unsupported `openai-codex/gpt-5.4-mini` rows through Codex OAuth, keep stale discovery rows suppressed with a clear API-key-route hint, and leave direct `openai/gpt-5.4-mini` available. Fixes #73242. Thanks @0xCyda.
- Plugin SDK: restore the root `stringEnum` and `optionalStringEnum` exports on both the published SDK entry and runtime root-alias bridge, so older external plugins can keep building and loading while migrating to focused SDK subpaths. Fixes #68279. Thanks @marzliak.
- Plugin SDK: restore the root-alias bridge for `registerContextEngine` and expose missing legacy compat helpers `normalizeAccountId` and `resolvePreferredOpenClawTmpDir` so older external plugins such as `openclaw-weixin` can keep loading while migrating to focused SDK subpaths. Fixes #53497. Thanks @alanxchen85.
- Auth profiles: make `openclaw doctor --fix` migrate legacy flat `auth-profiles.json` files such as `{ "ollama-windows": { "apiKey": "ollama-local" } }` to canonical provider default API-key profiles with a backup, so custom Ollama/OpenAI-compatible providers recover cleanly after upgrading. Fixes #59629; supersedes #59642. Thanks @Xsanders555 and @Linux2010.
- Memory/Dreaming: retry Dream Diary once with the session default when a configured dreaming model is unavailable, while leaving subagent trust and allowlist errors visible instead of silently masking configuration problems. Refs #67409 and #69209. Thanks @Ghiggins18 and @everySympathy.
- Feishu/inbound files: recover CJK filenames from plain `Content-Disposition: filename=` download headers when Feishu exposes UTF-8 bytes through Latin-1 header decoding, while leaving valid Latin-1 and JSON-derived names unchanged. (#48578, #50435, #59431) Thanks @alex-xuweilong, @lishuaigit, and @DoChaoing.
- Channels/Telegram: normalize accidental full `/bot<TOKEN>` Telegram `apiRoot` values at runtime and teach `openclaw doctor --fix` to remove the suffix, so startup control calls no longer 404 when direct Bot API curl commands work. Fixes #55387. Thanks @brendanmatthewjones-cmyk, @techfindubai-ux, and @Sivlerback-Chris.
- Zalo Personal: persist refreshed `zca-js` session cookies after QR login, session restore, and successful API calls so gateway restarts restore the freshest local session. (#73277) Thanks @darkamenosa.
- Logging/security: redact sensitive tokens (sk-\* keys, Bearer/Authorization values, etc.) at the subsystem console sink so `createSubsystemLogger().info/warn/error` output that bypasses the patched console-capture handler still applies the same redaction the file transport already does. Fixes #73284; refs #67953 and #64046. Thanks @edwin-rivera-dev.
- Plugins/runtime deps: reuse enclosing versioned cache roots when bundled plugins resolve from nested staged paths, so plugin-runtime-deps no longer mints `openclaw-unknown-*` directories or loops on `ENOTEMPTY`. Fixes #72956. (#73205) Thanks @SymbolStar.
- Agents/failover: classify CJK provider transport, quota, billing, auth, and overload error text so Chinese-language provider failures trigger fallback and user-facing transport copy instead of surfacing as unclassified raw errors. (#56242) Thanks @tomcatzh.
- Agents/failover: seed non-claude-cli fallback prompts with Claude Code session context when a claude-cli attempt fails, so fallback models do not restart cold after billing or quota failover. (#72069) Thanks @stainlu.
## 2026.4.27
### Changes
- Dependencies: refresh provider and tooling dependencies, including AWS SDK, PI runtime packages, AJV, Feishu SDK, Anthropic SDK, tokenjuice, and native TypeScript/oxlint tooling. Thanks @dependabot.
- Matrix/QA: add live Matrix approval scenarios for exec metadata, chunked fallback, plugin approvals, deny reactions, thread targeting, and `target: "both"` delivery, with redacted artifacts preserving safe approval summaries. Thanks @gumadeiras.
- Codex: add Computer Use setup for Codex-mode agents, including `/codex computer-use status/install`, marketplace discovery, optional auto-install, and fail-closed MCP server checks before Codex-mode turns start. Fixes #72094. (#71842) Thanks @pash-openai.
- Apps: consume Peekaboo 3.0.0-beta4 and ElevenLabsKit 0.1.1, align Swabble on Commander 0.2.2, and refresh macOS/iOS SwiftPM resolutions against the released dependency graph. Thanks @Blaizzy.
- Plugin SDK: expose shared channel route normalization, parser-driven target resolution, raw-target compact keys, parsed-target types, and route comparison helpers through `openclaw/plugin-sdk/channel-route`, switch native approval origin matching onto that route contract with optional delivery and match-only target normalization, and retire the internal channel-route shim behind dated compatibility aliases for legacy key/comparable-target helpers. Thanks @vincentkoc.
- Docs/Codex: document how Codex Computer Use, direct `cua-driver mcp`, and OpenClaw.app's PeekabooBridge fit together so desktop-control setup choices are clearer. Thanks @pash-openai and @trycua.
- Matrix/streaming: stream tool-progress updates into live Matrix preview edits by default when preview streaming is active, with `streaming.preview.toolProgress: false` to keep answer previews while hiding interim tool lines. Thanks @gumadeiras.
- Plugins/models: wire manifest `modelCatalog.aliases` and `modelCatalog.suppressions` into model-catalog planning and built-in model suppression, with stale Spark and Qwen Coding Plan suppressions now declared in plugin manifests instead of runtime fallback hooks. Thanks @shakkernerd.
- Plugin SDK/models: add a shared manifest-backed provider catalog builder and move Qianfan, Xiaomi, NVIDIA, Cerebras, Mistral, Moonshot, DeepSeek, Tencent TokenHub, and StepFun provider catalogs onto their plugin manifest `modelCatalog` rows. Thanks @shakkernerd.
- Plugin SDK/models: move BytePlus and Volcano Engine standard and plan-provider catalogs into plugin manifest `modelCatalog` rows and remove the now-unused Volcengine-family shared catalog SDK subpath. Thanks @shakkernerd.
- CLI/models: move Fireworks and Together AI fixed provider catalogs into plugin manifest `modelCatalog` rows so provider-filtered listing can use manifest-backed static rows. Thanks @shakkernerd.
- Channels/Yuanbao: register the Tencent Yuanbao external channel plugin (`openclaw-plugin-yuanbao`) in the official channel catalog, contract suites, and community plugin docs, with a new `docs/channels/yuanbao.md` quick-start guide for WebSocket bot DMs and group chats. (#72756) Thanks @loongfay.
- Channels/QQBot: add full group chat support (history tracking, @-mention gating, activation modes, per-group config, FIFO message queue with deliver debounce), C2C `stream_messages` streaming with a `StreamingController` lifecycle manager, unified `sendMedia` with chunked upload for large files, and refactor the engine into pipeline stages, focused outbound submodules, builtin slash-command modules, and explicit DI ports via `createEngineAdapters()`. (#70624) Thanks @cxyhhhhh.
- Plugins/startup: migrate bundled plugin manifests to explicit `activation.onStartup` declarations so Gateway startup imports only the bundled plugins that intentionally register startup-time runtime surfaces. Thanks @shakkernerd.
- Plugins/startup: add an opt-in future-mode gate for disabling deprecated implicit startup sidecar loading while preserving explicit startup and narrower activation triggers. Thanks @shakkernerd.
- Plugins/startup: add plugin compatibility warnings for deprecated implicit startup loading so authors can migrate to explicit `activation.onStartup` metadata. Thanks @shakkernerd.
- Plugins/runtime: load bundled agent tool-result middleware from manifest contracts on demand so tokenjuice stays startup-lazy without losing Pi/Codex tool-output compaction. Thanks @shakkernerd.
- Plugins/startup: add explicit `activation.onStartup` metadata so plugins can declare Gateway startup import behavior while the deprecated implicit sidecar fallback remains for legacy plugins. Thanks @shakkernerd.
- Gateway/startup: reuse lookup-table plugin manifests when loading startup plugins so Gateway boot avoids rebuilding plugin discovery and manifest metadata. Thanks @shakkernerd.
- CLI/models: declare fixed Qianfan, Xiaomi, NVIDIA, Cerebras, Mistral, Chutes, Kilo, OpenAI, and OpenCode Go model catalogs in refreshable plugin manifests, keep broad `models list --all` on raw registry and supplement rows without runtime normalization, and avoid duplicate supplement resolution. Thanks @shakkernerd.
- Gateway/runtime: reuse the current plugin metadata snapshot for provider discovery so repeated model-provider discovery avoids rebuilding plugin manifest metadata. Thanks @shakkernerd.
- Gateway/startup: pass the plugin metadata snapshot from config validation into plugin bootstrap so startup reuses one manifest product instead of rebuilding plugin metadata. Thanks @shakkernerd.
- Plugin SDK/testing: move core-only channel contract fixtures under the channel contract test tree and retire the old `test/helpers/channels` bridge directory so plugin tests stay on focused SDK surfaces. Thanks @vincentkoc.
- Plugin SDK/testing: expose native agent-runtime contract fixtures through `plugin-sdk/agent-runtime-test-contracts`, move sandbox config fixtures into the focused generic fixture subpath, and block extension tests from importing repo-only `test/helpers` bridges. Thanks @vincentkoc.
- Plugin SDK/testing: expose generic module reload, bundled-path, Node builtin mock, channel pairing/envelope, HTTP server, temp-home, replay-policy, and live STT helpers through focused SDK test subpaths so extension tests no longer depend on repo-only helper bridges. Thanks @vincentkoc.
- Plugin SDK: move maintained bundled channels off the deprecated `channel-config-schema-legacy` subpath, add an explicit bundled-channel schema SDK surface, and track both remaining legacy test/config compatibility barrels with dated removal windows. Thanks @vincentkoc.
- Plugin SDK/testing: expose media provider capability assertions and provider HTTP mocks through focused SDK test subpaths, and retire the repo-only media-generation test helper bridge. Thanks @vincentkoc.
- Plugin SDK/testing: promote bundled plugin/provider/channel contract helpers to focused SDK test subpaths and retire the repo-only `test/helpers/plugins` TypeScript bridge. Thanks @vincentkoc.
- Plugin SDK/testing: expose generic channel action, setup, status, and directory contract helpers through `plugin-sdk/channel-test-helpers` so bundled extension tests no longer import repo-only channel helper bridges. Thanks @vincentkoc.
- Plugin SDK/testing: add `plugin-sdk/channel-target-testing` for shared channel target-resolution cases, document channel reaction helpers on `plugin-sdk/channel-feedback`, and keep the old `plugin-sdk/test-utils` alias as compatibility-only. Thanks @vincentkoc.
- Plugin SDK/testing: add a focused generic fixture subpath for CLI capture, sandbox, skill, agent-message, system-event, terminal, chunking, auth-token, and typed-case helpers. Thanks @vincentkoc.
- Plugin SDK/testing: add focused plugin runtime and environment fixture subpaths so plugin tests can avoid the broad `plugin-sdk/testing` barrel for common setup helpers. Thanks @vincentkoc.
- Plugin SDK/testing: add a focused `plugin-sdk/plugin-test-api` helper subpath and move bundled plugin registration tests off the repo-only plugin API bridge. Thanks @vincentkoc.
- Plugin SDK: add generic host hooks for session state, next-turn context, trusted tool policy, UI descriptors, events, scheduler cleanup, and run-scoped plugin context. (#72287) Thanks @100yenadmin.
- Plugin SDK/testing: expose provider catalog, wizard, registry, manifest, public-artifact, outbound, and TTS contract helpers through documented SDK testing seams so bundled plugin tests no longer import repo `src/**` internals. Thanks @vincentkoc.
- Providers/DeepInfra: add a bundled DeepInfra provider with `DEEPINFRA_API_KEY` onboarding, dynamic OpenAI-compatible model discovery, image generation/editing, image/audio media understanding, TTS, text-to-video, memory embeddings, static catalog metadata, and provider-owned base URL policy. Carries forward #53805, #48088, #37576, #43896, #11533, and #2554. Thanks @ats3v.
- Matrix: attach versioned structured approval metadata to pending approval messages so capable Matrix clients can render richer approval UI while body text and reaction fallback keep working. (#72432) Thanks @kakahu2015.
### Fixes
- Channels/Telegram: keep Bot API network fallbacks sticky after failed attempts and retry timed-out startup control calls once on the fallback route, so `deleteWebhook` IPv6 stalls no longer trigger slow multi-account retry storms. Fixes #73255. Thanks @ttomiczek and @sktbrd.
- Gateway/agents: accept heartbeat, cron, and webhook as internal channel hints for agent runs so `sessions_spawn` works from non-delivery parent sessions while unknown channel hints still fail closed. Fixes #73237. Thanks @KeWang0622.
- Gateway/models: merge explicit `models.providers.*.models` rows into the Gateway model catalog with normalized provider/model dedupe, and use normalized image-capability lookup so custom vision models keep native image attachments even when Pi discovery omits them or model ID casing differs. Fixes #64213 and #65165. Thanks @billonese and @202233a.
- Gateway/reload: publish canonical post-write source config to in-process reloaders so simple config saves no longer create phantom plugin diffs or trigger unnecessary Gateway restarts. (#73267) Thanks @szsip239.
- Gateway/Docker: keep config-triggered restarts in-process inside containers instead of spawning a detached child and exiting PID 1 cleanly, so Docker Swarm and other on-failure supervisors do not leave the service stuck at 0/1 replicas. Fixes #73178. Thanks @du-nguyen-IT007.
- CLI/tasks: ship the task-registry control runtime in npm packages so `openclaw tasks cancel` can load ACP/subagent cancellation helpers from published builds. Fixes #68997. Thanks @1OAKDesign.
- Channels/Telegram: preserve unsent generated media after partial reply streaming has already delivered the text, so `image_generate` outputs still reach Telegram as photos instead of being dropped from the final payload. Fixes #73253. Thanks @mlaihk.
- Memory-core/dreaming: cap detached Dream Diary narrative subagents across cron sweeps so multi-workspace dreaming no longer fans out unbounded subagent sessions, lock contention, and cascading narrative timeouts. Fixes #73198. (#73287) Thanks @KeWang0622.
- CLI/agents: close local one-shot Claude live stdio sessions and bundled MCP loopback resources after embedded `openclaw agent --local` runs, while keeping gateway-owned MCP loopback cleanup internal to the Gateway. Thanks @frankekn.
- Export/session: keep inline export HTML scripts and vendor libraries injected after template formatting so generated session exports open with the app code, markdown renderer, and syntax highlighter present. Fixes #41862 and #49957; carries forward #41861 and #68947. Thanks @briannewman, @martenzi, and @armanddp.
- Agents/ACPX: stage the patched Claude ACP adapter as an ACPX runtime dependency and route known Codex/Claude ACP commands through local wrappers, so Gateway runtime no longer depends on live `npx` adapter resolution. Fixes #73202. Thanks @joerod26.
- Memory/compaction: let pre-compaction memory flush use an exact `agents.defaults.compaction.memoryFlush.model` override such as `ollama/qwen3:8b` without inheriting the active session fallback chain, so local housekeeping can avoid paid conversation models. Fixes #53772. Thanks @limen96.
- macOS/update: stop managed Gateway services before package replacement and keep LaunchAgent service secrets out of world-readable plist metadata by loading them from owner-only env files. Fixes #72996. Thanks @Mathewb7.
- Google Meet: keep observe-only Chrome joins and setup checks from requiring BlackHole or audio bridge commands, avoid granting or selecting the microphone in observe-only mode, and make `test_speech` report fresh realtime output-byte verification instead of only confirming a queued utterance. Refs #72478. Thanks @DougButdorf.
- Gateway/hooks: route non-delivered hook completion and error summaries to the target agent's main session instead of the default agent session, preserving multi-agent hook isolation. Fixes #24693; carries forward #68667. Thanks @abersonFAC and @bluesky6868.
- Control UI/models: request the configured Gateway model-list view so dashboards with only `models.providers.*.models` show those configured models first instead of flooding the picker with the full built-in catalog. Fixes #65405. Thanks @wbyanclaw.
- CLI/models: keep default-model and allowlist pickers on explicit `models.providers.*.models` entries when `models.mode` is `replace` instead of loading the full built-in catalog. Fixes #64950. Thanks @mrozentsvayg.
- Media/security: tighten media-understanding MIME sanitization so parameterized MIME values stay end-anchored and malformed whitespace or suffix payloads are rejected before file-context handling. Fixes #9795; carries forward #68225 with related review/test context from #61016/#68456. Thanks @ymaxgit, @bluesky6868, and @shamsulalam1114.
- Discord: own the Carbon interaction listener and hand off Discord slash/component handling asynchronously, so compaction or long session locks no longer trip `InteractionEventListener` listener timeouts. Fixes #73204. Thanks @slideshow-dingo.
- Compaction/diagnostics: keep unknown compaction failure classifications stable while logging sanitized detail for unclassified provider errors such as missing Ollama provider adapters. Thanks @gzsiang.
- Models/fallbacks: record first-class `model.fallback_step` trajectory events with from/to models, failure detail, chain position, and final outcome so support exports preserve the primary model failure even when a later fallback also fails. Fixes #71744. Thanks @nikolaykazakovvs-ux.
- Gateway/agents: block agent `exec` from launching interactive `openclaw channels login` flows and abort active agent runs after invalid-config recovery restores last-known-good config, preventing known channel-login and reload paths from wedging replies. Refs #72338. Thanks @midhunmonachan.
- Gateway/diagnostics: emit payload-free liveness warnings with event-loop delay, event-loop utilization, CPU-core ratio, active-session counts, and OTEL warning metrics/spans so live-but-stalled Gateways capture CPU-spin context in stability bundles and telemetry. Refs #72338. Thanks @midhunmonachan and @DougButdorf.
- Gateway/startup: keep value-option foreground starts on the gateway fast path and skip proxy bootstrap unless proxy env is configured, reducing normal gateway startup RSS and avoiding full CLI graph loading. Thanks @vincentkoc.
- Heartbeat/models: show heartbeat model bleed guidance on context-overflow resets when the last runtime model matches configured `heartbeat.model`, so smaller local heartbeat models point users to `isolatedSession` or `lightContext` instead of only compaction-buffer tuning. Fixes #67314. Thanks @Knightmare6890.
- Subagents/models: persist `sessions_spawn.model` and configured subagent models as child-session model overrides before the first turn, so spawned subagents actually run on the requested provider/model instead of reverting to the target agent default. Fixes #73180. Thanks @danielzinhu99.
- Channels/Telegram: keep webhook-mode local listeners alive and retry Telegram `setWebhook` registration after recoverable startup network failures, so transient Bot API timeouts no longer leave reverse proxies pointing at a closed listener. Fixes #71834. Thanks @jinon86.
- Agents/ACPX: bundle the Codex ACP adapter and launch it from the isolated `CODEX_HOME` wrapper before falling back to npm, so Codex ACP startup no longer depends on live `npx` resolution or the stale `@zed-industries/codex-acp@^0.11.1` range. Fixes #72037; refs #73202. Thanks @jasonftl, @sazora, and @joerod26.
- Agents/ACPX: register the embedded ACP backend at Gateway startup through a lightweight ACP backend SDK path and without importing the heavy ACPX runtime until an ACP session or explicit startup probe needs it, reducing baseline Gateway RSS. Thanks @vincentkoc.
- CLI/update: keep restart health polling when the restarted Gateway is reachable but has not reported its version yet, so macOS service restarts do not fail early with `actual unavailable`. Thanks @ProspectOre.
- Backup: skip installed plugin `extensions/*/node_modules` dependency trees while keeping plugin manifests and source files in archives, so local backups avoid rebuildable npm payload bloat. Fixes #64144. Thanks @BrilliantWang.
- Cron/models: fail isolated cron runs closed when an explicit `payload.model` is not allowed or cannot be resolved, so scheduled jobs do not silently fall back to an unrelated agent default or paid route before configured provider proxies such as LiteLLM can run. Fixes #73146. Thanks @oneandrewwang.
- Memory/QMD: back off repeated chat-turn QMD open failures while still letting memory status and CLI probes recheck immediately, so a broken sidecar dependency cannot trigger active-memory or cron retry storms. Fixes #73188 and #73176. Thanks @leonlushgit and @w3i-William.
- Talk Mode: resolve `messages.tts.providers.<id>.apiKey` through the active runtime snapshot for `talk.config`, so Talk overlays can discover SecretRef-backed speech providers without falling back to local speech. Fixes #73109. (#73111) Thanks @omarshahine.
- Memory/Ollama: resolve `memorySearch.provider` custom provider ids through their configured `models.providers.<id>.api` owner, so multi-GPU Ollama setups can dedicate embeddings to providers such as `ollama-5080` without losing the Ollama adapter or local auth semantics. Fixes #73150. Thanks @oneandrewwang.
- CLI/memory: skip eager context-window warmup for `openclaw memory` commands so memory search does not race unrelated model metadata discovery. Fixes #73123. Thanks @oalansilva and @neeravmakwana.
- CLI/Telegram: route Telegram `message send` and poll actions through the running Gateway when available, so packaged installs use the staged `grammy` runtime deps and CLI sends return instead of hanging after the Telegram channel is active. Fixes #73140. Thanks @oalansilva.
- Plugins/runtime deps: prepare staged bundled plugin dependencies before loading packaged public surfaces, so OpenClaw's Telegram runtime/test facade loads resolve `grammy` from the managed runtime-deps stage without copying dependencies into the global package root. Refs #73140. Thanks @oalansilva.
- Agents/exec: emit `(no output)` for silent exec update and node-host result blocks so Anthropic-compatible providers no longer reject empty tool-result text after quiet commands. Fixes #73117. Thanks @pfrederiksen and @Sanjays2402.
- Cron/providers: preflight local Ollama and OpenAI-compatible provider endpoints before isolated cron agent turns, record unreachable local providers as skipped runs, and cache dead-endpoint probes so many jobs do not hammer the same stopped local server. Fixes #58584. Thanks @jpeghead.
- Gateway/config: let config reload continue in degraded mode when invalidity is scoped to plugin entries, so incompatible plugin configs can be skipped and the Gateway restart can still pick up the rest of the config after rollbacks. Fixes #73131. Thanks @Adam-Researchh.
- Doctor/channels: suppress disabled bundled-plugin blocker warnings when a trusted external plugin owns the configured channel, so Lark/Feishu installs no longer get Feishu repair noise after switching to `openclaw-lark`. Fixes #56794. Thanks @wuji-tech-dev.
- CLI/status: show skipped fast-path memory checks as `not checked` and report active custom memory plugin runtime status from `status --json --all` without requiring built-in `agents.defaults.memorySearch`, so plugins such as memory-lancedb-pro and memory-cms no longer look unavailable when their own runtime is healthy. Fixes #56968. Thanks @Tony-ooo and @aderius.
- Gateway/channels: record and log unexpected clean channel monitor exits so channels that return without throwing no longer appear stopped with no error. Fixes #73099. Thanks @balaji1968-kingler.
- Discord/group chats: keep group/channel replies private by default unless the agent explicitly uses the message tool, so always-on rooms can lurk without leaking automatic final, block, preview, or status-reaction output; `messages.groupChat.visibleReplies: "automatic"` restores legacy auto-posting. (#73046) Thanks @scoootscooob.
- Plugins/package: force nested bundled-plugin runtime dependency installs out of inherited npm dry-run mode during prepack and package smoke checks, so packed installs materialize required plugin modules instead of reporting missing bundled files. Refs #73128. Thanks @Adam-Researchh.
- Discord: skip reaction events before REST channel fetch when notifications are off, guild reactions are disabled, or allowlist mode cannot match without channel overrides, reducing reconnect bursts that caused slow listener warnings. Fixes #73133. Thanks @isaacsummers.
- Channels/Telegram: centralize polling update tracking so accepted offsets remain durable across restarts, same-process handler failures can still retry, and slow offset writes cannot overwrite newer accepted watermarks. Refs #73115. Thanks @vdruts.
- Agents/models: classify empty, reasoning-only, and planning-only terminal agent runs before accepting a model fallback candidate, so invalid or incompatible models can advance to the next configured fallback instead of returning a 30-second terminal failure. Fixes #73115. Thanks @vdruts.
- Memory/LanceDB: let embedding config use provider-backed auth profiles, environment credentials, or provider config without a separate plugin `embedding.apiKey`, so OAuth-capable embedding providers can power auto-recall/capture. Fixes #68950. Thanks @malshaalan-ai.
- CLI/parents: invoking `openclaw <parent>` (memory, channels, plugins, approvals, devices, cron, mcp) without a subcommand now prints the parent's help and exits `0`, matching `<parent> --help` and the existing `agents` / `sessions` defaults so shell `&&` chains and pnpm wrappers no longer surface a misleading `ELIFECYCLE Command failed with exit code 1.` line. Fixes #73077. Thanks @hclsys.
- Plugins/hooks: time out never-settling `agent_end` observation hooks after 30 seconds and log the plugin failure, so hung embedding endpoints no longer leave memory capture silently pending forever. Fixes #65544. Thanks @ghoc0099.
- Gateway/config: serve runtime config schemas from the current plugin metadata snapshot and generated bundled channel schema metadata instead of rebuilding plugin channel config modules on every `config.get`/`config.schema`, preventing idle plugin-discovery CPU churn after upgrades. Fixes #73088. Thanks @sleitor and @geovansb.
- Memory/LanceDB: call OpenAI-compatible embedding endpoints through the raw SDK transport without sending `encoding_format`, then normalize float-array or base64 responses so providers such as ZhiPu and DashScope no longer fail recall with wrong vector dimensions or rejected parameters. Fixes #63655. Thanks @kinthaiofficial.
- Plugins/install: run dependency installs with npm error-level logging instead of silent mode so failed plugin or hook installs surface actionable npm errors such as EUNSUPPORTEDPROTOCOL instead of `npm install failed:` with no detail. (#73093) Thanks @sanctrl.
- Memory/LanceDB: bound memory recall embedding queries with a new `recallMaxChars` setting, prefer the latest user message over channel prompt metadata during auto-recall, and document the knob so small Ollama embedding models avoid context-length failures. Fixes #56780. Thanks @rungmc357 and @zak-collaborator.
- CLI/skills: resolve workspace-backed skills commands from `--agent`, then the current agent workspace, before falling back to the default agent, so multi-agent ClawHub installs, updates, and status checks stay scoped to the active workspace. Fixes #56161; carries forward #72726. Thanks @langbowang and @luyao618.
- Plugin SDK: fall back from partial bundled plugin directory overrides to package source public surfaces while preserving `OPENCLAW_DISABLE_BUNDLED_PLUGINS` as a hard disable. (#72817) Thanks @serkonyc.
- Agents/ACPX: stop forwarding Codex ACP timeout config controls that Codex rejects while preserving OpenClaw's run-timeout watchdog for ACP subagents. Fixes #73052. Thanks @pfrederiksen and @richa65.
- Memory Core: stream fallback vector search scoring with a bounded top-K result set so large indexes do not materialize every chunk embedding when sqlite-vec is unavailable. (#73069) Thanks @parkertoddbrooks.
- Memory Core: stream embedding-cache seeding during safe reindex so large local caches do not materialize every row into the V8 heap before the atomic rebuild. (#73067) Thanks @parkertoddbrooks.
- Memory/Ollama: add `memorySearch.remote.nonBatchConcurrency` for inline embedding indexing, default Ollama non-batch indexing to one request at a time, and keep batch concurrency separate from non-batch concurrency so local embedding backfills avoid timeout storms on smaller hosts. Carries forward #57733. Thanks @itilys.
- macOS app: update Peekaboo, ElevenLabsKit, and MLX TTS helper dependencies, make canvas file watching and config/exec-approval state writes reliable under concurrent app/test activity, and keep the app plus helper builds warning-free. Thanks @Blaizzy.
- iOS app: refresh SwiftPM/XcodeGen source hygiene, make app, extension, watch, and curated shared Swift files pass the prebuild SwiftFormat and SwiftLint checks, move relay registration off deprecated StoreKit receipt APIs, and keep simulator builds and logic tests warning-free. Thanks @ngutman.
- Agents/models: keep `models.json` readiness and provider-hook caches warm across repeated agent and subagent model resolution while preserving external `models.json` invalidation, reducing repeated provider-plugin loads on slower ARM64 hosts. Fixes #73075. Thanks @jochen.
- Docs/tools: clarify that `tools.profile: "messaging"` is intentionally narrow and that `tools.profile: "full"` is the unrestricted baseline for broader command/control access. Carries forward #39954. Thanks @posigit.
- Control UI/Agents: redact tool-call args, partial/final results, derived exec output, and configured custom secret patterns before streaming tool events to the Control UI, so tool output cannot expose provider or channel credentials. Fixes #72283. (#72319) Thanks @volcano303 and @BunsDev.
- Agents/sessions: keep `sessions_history` recall redaction enabled even when general log redaction is disabled, and clarify that safety-boundary UI/tool/diagnostic payloads still redact independently of `logging.redactSensitive`. Carries forward #72319. Thanks @volcano303 and @BunsDev.
- Providers/Codex: pass agent and workspace directories into provider stream wrappers so Codex native `web_search` activation can evaluate the correct auth context, and smoke-test the built status-message runtime by resolving the emitted bundle name. Carries forward #67843; refs #65909. Thanks @neilofneils404.
- Cron/models: keep `payload.model` as a per-job primary that can use configured fallbacks, while still letting `payload.fallbacks: []` make cron runs strict and avoid hidden agent-primary retries. Refs #73023. Thanks @pavelyortho-cyber.
- Models/fallbacks: treat user-selected session models as exact choices, so `/model ollama/...` and model-picker switches fail visibly when the selected provider is unreachable instead of answering from an unrelated configured fallback. Fixes #73023. Thanks @pavelyortho-cyber.
- Codex harness: keep ChatGPT subscription app-server runs from inheriting `CODEX_API_KEY` or `OPENAI_API_KEY`, and fall back to `CODEX_API_KEY` / `OPENAI_API_KEY` app-server login only when no Codex account is available. Fixes #73057. Thanks @holgergruenhagen and @pashpashpash.
- CLI/model probes: fail local `infer model run` probes when the provider returns no text output, so unreachable local providers and empty completions no longer look like successful smoke tests. Refs #73023. Thanks @pavelyortho-cyber.
- CLI/Ollama: run local `infer model run` through the lean provider completion path and skip global model discovery for one-shot local probes, so Ollama smoke tests no longer pay full chat-agent/tool startup cost or hang before the native `/api/chat` request. Fixes #72851. Thanks @TotalRes2020.
- Doctor/gateway services: ignore launchd/systemd companion services that only reference the gateway as a dependency, suppress inactive Linux extra-service warnings, and avoid rewriting a running systemd gateway command/entrypoint during doctor repair. Carries forward #39118. Thanks @therk.
- Daemon/service: only emit hard-coded version-manager paths such as `~/.volta/bin`, `~/.asdf/shims`, `~/.bun/bin`, and fnm/pnpm fallbacks into gateway and node service PATHs when the directories exist, so `openclaw doctor` no longer flags `gateway.path.non-minimal` against a PATH the daemon just wrote. Env-driven roots and stable user-bin dirs remain unconditional. Fixes #71944; carries forward #71964. Thanks @Sanjays2402.
- CLI/startup: disable Node's module compile cache automatically for live source-checkout launchers so in-place `pnpm build` updates are visible to the next `openclaw` CLI invocation. Fixes #73037. Thanks @LouisGameDev.
- Agents/group chat: keep silent-allowed empty and reasoning-only turns on the `NO_REPLY` path without injecting visible-answer retry prompts, and clarify the group prompt so agents use the exact silent token instead of prose. Thanks @vincentkoc.
- Agents/group chat: move `NO_REPLY` mechanics into channel-aware direct/group prompts and suppress the duplicate generic silent-reply section for auto-reply runs, so always-on group agents get one consistent stay-silent instruction. Thanks @vincentkoc.
- Providers/OpenAI: preserve encrypted empty-summary Responses reasoning items in WebSocket replay and request `reasoning.encrypted_content` on reasoning turns so GPT-5.4/GPT-5.5 sessions do not lose required `rs_*` state beside `msg_*` items. Fixes #73053. Thanks @odb36777.
- Gateway/startup: treat `plugins.enabled=false` as an early plugin fast path, skipping plugin auto-enable discovery, gateway plugin lookup/runtime-dependency staging, and stale-plugin cleanup warnings while preserving channel blocker warnings. (#73041) Thanks @WuKongAI-CMU.
- Channels/commands: make generated `/dock-*` commands switch the active session reply route through `session.identityLinks` instead of falling through to normal chat. Fixes #69206; carries forward #73033. Thanks @clawbones and @michaelatamuk.
- Providers/Cloudflare AI Gateway: strip assistant prefill turns from Anthropic Messages payloads when thinking is enabled, so Claude requests through Cloudflare AI Gateway no longer fail Anthropic conversation-ending validation. Fixes #72905; carries forward #73005. Thanks @AaronFaby and @sahilsatralkar.
- Gateway/startup: keep primary-model startup prewarm on scoped metadata preparation, let native approval bootstraps retry outside channel startup, and skip the global hook runner when no `gateway_start` hook is registered, so clean post-ready sidecar work stays off the critical path. Refs #72846. Thanks @RayWoo, @livekm0309, and @mrz1836.
- Gateway/channels: start bundled channel accounts with a lightweight `runtimeContexts` surface instead of importing the full reply/routing/session channel runtime before `startAccount`, so Discord, Telegram, Slack, Matrix, and QQBot startup no longer block on unrelated channel helper graphs. Refs #72846 and #72960. Thanks @mrz1836, @RayWoo, and @rollingshmily.
- Gateway/supervisor: exit cleanly when a supervised restart finds an existing healthy gateway and bound retries when the existing gateway stays unhealthy, so stale lock contention cannot loop indefinitely. Refs #72846. Thanks @azgardtek.
- Gateway/startup: scope primary-model provider discovery during channel prewarm to the configured provider owner and add split startup trace timings, so boot avoids staging unrelated bundled provider dependencies while setup discovery remains broad. Fixes #73002. Thanks @Schnup03.
- Plugins/runtime deps: declare retained staged bundled plugin dependencies in the npm staging manifest while installing only newly missing packages, so Gateway restarts avoid reinstalling the full retained dependency set when one runtime dependency is absent. Fixes #73055. Thanks @GCorp2026.
- CLI/status: keep default `openclaw status` off the heavyweight security audit, plugin compatibility, and memory-vector probes while still showing configured Telegram channels through setup metadata, so routine health checks stay fast and no longer render an empty Channels table. Fixes #72993. Thanks @comick1.
- Channels/Telegram: send a best-effort native typing cue immediately after an inbound message is accepted, so slow pre-dispatch turns show Telegram liveness before queueing, compaction, model, or tool work starts. Fixes #63759. Thanks @alessandropcostabr.
- Channels/Telegram: stop native approval startup auth failures from retrying every second, while still waiting through retryable Gateway auth handoffs, so Telegram approval setup problems no longer create a reconnect/log loop during channel startup. Refs #72846 and #72867. Thanks @kiranvk-2011 and @porly1985.
- Channels/Microsoft Teams: unwrap staged CommonJS JWT runtime dependencies before Bot Connector token validation so inbound Teams messages no longer 401 after the bundled runtime-deps move. Fixes #73026. Thanks @kbrown10000.
- Gateway/auth: allow local direct callers in trusted-proxy mode to use the configured gateway password as an internal fallback while keeping token fallback rejected. Fixes #17761. Thanks @dashed, @vincentkoc, and @jetd1.
- Gateway/auth: add explicit `trustedProxy.allowLoopback` support for same-host loopback reverse proxies while keeping loopback trusted-proxy auth fail-closed by default and preserving required-header and allowlist checks. Fixes #59167; carries forward #63379. Thanks @Matir, @jeremyakers, and @mrosmarin.
- Channels/sessions: prevent guarded inbound session recording from creating route-only phantom sessions while still allowing last-route updates for sessions that already exist. Carries forward #73009. Thanks @jzakirov.
- Cron: accept `delivery.threadId` in Gateway cron add/update schemas so scheduled announce delivery can target Telegram forum topics and other threaded channel destinations through the documented delivery path. Fixes #73017. Thanks @coachsootz.
- Plugins/runtime deps: stage bundled plugin dependencies imported by mirrored root dist chunks, so packaged memory and status commands do not miss `chokidar` or similar root-chunk dependencies after update. Fixes #72882 and #72970; carries forward #72992. Thanks @shrimpy8, @colin-chang, and @Schnup03.
- Plugins/runtime deps: reuse unchanged bundled plugin runtime mirrors instead of rebuilding plugin trees on every load, cutting avoidable writes and restart/reconnect I/O on slow storage. Fixes #72933. Thanks @jasonftl.
- Agents/runtime context: deliver hidden runtime context through prompt-local system context while keeping the transcript-only custom entry out of provider user turns, and strip stale copied runtime-context prefaces from user-facing replies. Fixes #72386; carries forward #72969. Thanks @jhsmith409.
- Channels/Telegram: skip the optional webhook-info API call during polling-mode status checks and startup bot-label probes so long-polling setups avoid an unnecessary Telegram round trip. Carries forward #72990. Thanks @danielgruneberg.
- CLI/message: resolve targeted `openclaw message` channels to their owning plugin before loading the registry, and fall back to configured channel plugins when the channel must be inferred, so scripted sends avoid full bundled plugin registry scans without assuming channel ids match plugin ids. Fixes #73006. Thanks @jasonftl.
- Plugins/startup: parse strict JSON plugin manifests with native JSON first and keep JSON5 as the compatibility fallback, reducing manifest registry CPU during Gateway boot and CLI startup. Fixes #73011. Thanks @jasonftl.
- CLI/models: keep route-first `models status --json` stdout reserved for the JSON payload by routing auth-profile and startup diagnostics to stderr. Fixes #72962. Thanks @vishutdhar.
- Gateway/runtime: keep dirty-tree status calls from rebuilding live `dist`, clear stale task and restart state across in-process restarts, retry transient Discord lazy imports, and let channel startup continue after slow model warmup so browser, Discord, and voice-call sidecars come online. Thanks @vincentkoc.
- Security/CodeQL: replace file SecretRef id gateway schema regex validation with segment-aligned predicates and set empty permissions on release summary/backfill jobs so the narrowed CodeQL profile stays clean. Thanks @vincentkoc.
- Sessions: ignore future-dated session activity timestamps during reset freshness checks and cap future `updatedAt` values at the merge boundary so clock-skewed messages cannot keep stale sessions alive forever. Fixes #72989. Thanks @martingarramon.
- Sessions: apply search, activity filters, and limits before gateway row enrichment so bounded session lists avoid scanning discarded transcripts. Carries forward #72978. Thanks @yeager.
- Sessions: remove trajectory runtime and pointer sidecars when session maintenance prunes, caps, or disk-evicts their owning session, while preserving sidecars still referenced by live rows. Fixes #73000. Thanks @jared-rebel.
- Plugins/CLI: allow managed plugin installs when the active extensions root is a symlink to a real state directory, while keeping nested target symlinks blocked and suppressing misleading hook-pack fallback errors for install-boundary failures. Fixes #72946. Thanks @mayank6136.
- Providers/Ollama: mark discovered Ollama catalog models as supporting streaming usage metadata so token accounting stays enabled for local models. (#72976) Thanks @sdeyang.
- Media understanding: reject malformed MIME values with trailing junk while preserving standard parameter tails before enrichment uses them. (#72914) Thanks @volcano303.
- WebChat: keep bare `/new` and `/reset` prompts from producing empty transcript text by inserting the hidden session marker when the visible tail is blank. (#72863) Thanks @mahopan.
- CLI/update: explain completion-cache refresh timeouts with manual refresh guidance instead of surfacing a raw low-level timeout. Fixes #72842. (#72850) Thanks @iot2edge.
- Memory-core/dreaming: give narrative generation a 60-second timeout so slower local or remote models can finish instead of timing out at 15 seconds. Fixes #72837. (#72852) Thanks @RayWoo.
- Plugins/hooks: inject each plugin's resolved config into internal hook event context without mutating the shared event object. (#72888) Thanks @jalapeno777.
- Agents/ACP: pass the resolved ACP agent directory into media understanding so per-agent media caches and config are used for ACP-dispatched image turns. (#72832) Thanks @luyao618.
- Gateway/Bonjour: truncate mDNS service names and host labels to the 63-byte DNS label limit at valid UTF-8 boundaries. (#72809) Thanks @luyao618.
- Feishu: treat groups explicitly configured under channels.feishu.groups as admitted even when groupAllowFrom is empty, while preserving groupPolicy: "disabled" as a hard group block and keeping groups.\* wildcard defaults non-admitting. Fixes #67687. (#72789) Thanks @MoerAI.
- Gateway/startup: keep hot Gateway boot paths on leaf config imports and add max-RSS reporting to the gateway startup bench so low-memory startup regressions are visible before release. Thanks @vincentkoc.
- WebChat: read `chat.history` from active transcript branches, drop stale streamed assistant tails once final history catches up, and coalesce duplicate in-flight Control UI submits, so rewritten prompts, completed replies, and rapid send events no longer render or process twice. Fixes #72975, #72963, and #72974. Thanks @dmagdici, @lhtpluto, and @Benjamin5281999.
- WebChat/TTS: persist automatic final-mode TTS audio as a supplemental audio-only transcript update instead of adding a second assistant message with the same visible text. Fixes #72830. Thanks @lhtpluto.
- Agents/LSP: terminate bundled stdio LSP process trees during runtime disposal and Gateway shutdown, so nested children such as `tsserver` do not survive stop or restart. Fixes #72357. Thanks @ai-hpc and @bittoby.
- Diagnostics/OTEL: capture privacy-safe model-call request payload bytes, streamed response bytes, first-response latency, and total duration in diagnostic events, plugin hooks, stability snapshots, and OTEL model-call spans/metrics without logging raw model content. Fixes #33832. Thanks @wwh830.
- Logging: write validated diagnostic trace context as top-level `traceId`, `spanId`, `parentSpanId`, and `traceFlags` fields in file-log JSONL records so traced requests and model calls are easier to correlate in log processors. Refs #40353. Thanks @liangruochong44-ui.
- Logging/sessions: apply configured redaction patterns to persisted session transcript text and accept escaped character classes in safe custom redaction regexes, so transcript JSONL no longer keeps matching sensitive text in the clear. Fixes #42982. Thanks @panpan0000.
- Providers/Ollama: honor `/api/show` capabilities when registering local models so non-tool Ollama models no longer receive the agent tool surface, and keep native Ollama thinking opt-in instead of enabling it by default. Fixes #64710 and duplicate #65343. Thanks @yuan-b, @netherby, @xilopaint, and @Diyforfun2026.
- Control UI/Agents: remount the Overview model controls when switching agents so the primary-model picker cannot retain stale per-agent selection. Fixes #39392; carries forward #39401, notes the duplicate #39495 approach, and keeps #46275/#54724 broader stabilization out of scope. Thanks @daijunyi002, @SergioChan, @aworki, and @wsyjh8.
- Auto-reply: poison inbound message dedupe after replay-unsafe provider/runtime failures so retries stay safe before visible progress but cannot duplicate messages after block output, tool side effects, or session progress. Fixes #69303; keeps #58549 and #64606 as duplicate validation. Thanks @martingarramon, @NikolaFC, and @zeroth-blip.
- Agents/model fallback: jump directly to a known later live-session model redirect instead of walking unrelated fallback candidates, while preserving the already-landed live-session/fallback loop guard. Fixes #57471; related loop family already closed via #58496. Thanks @yuxiaoyang2007-prog.
- Gateway/Bonjour: keep @homebridge/ciao cancellation handlers registered across advertiser restarts so late probing cancellations cannot crash Linux and other mDNS-churned gateways. Thanks @vincentkoc.
- Plugins/startup: load the default `memory-core` slot during Gateway startup when permitted so active-memory recall can call `memory_search` and `memory_get` without requiring an explicit `plugins.slots.memory` entry, while preserving `plugins.slots.memory: "none"`. Thanks @vincentkoc.
- Gateway/plugins: resolve `gateway_start` cron hooks from live Gateway runtime state before the legacy deps fallback, so memory-core dreaming cron reconciliation keeps working on installs where `deps.cron` is not populated during service startup. Fixes #72835. Thanks @RayWoo.
- Plugins/CLI: prefer native require for compiled bundled plugin JavaScript before jiti so read-only config, status, device, and node commands avoid unnecessary transform overhead on slow hosts. Fixes #62842. Thanks @Effet.
- Plugins/compat: inventory doctor-side deprecation migrations separately from runtime plugin compatibility so release sweeps preserve needed repairs while enforcing dated removal windows. Thanks @vincentkoc.
- Plugins/compat: add missing dated compatibility records for legacy extension-api, memory registration, provider hook/type aliases, runtime aliases, channel SDK helpers, and approval/test utility shims. Thanks @vincentkoc.
- Plugins/CLI: refresh the persisted registry after managed plugin files are removed so ClawHub uninstall cannot leave stale `plugins list` entries. Thanks @vincentkoc.
- Plugins/CLI: make plugin install and uninstall config writes conflict-aware, clear stale denylist entries on explicit reinstall/removal, and delete managed plugin files only after config/index commit succeeds. Thanks @vincentkoc.
- Plugins: fail `plugins update` when tracked plugin or hook updates error, keep bundled runtime-dependency repair behind restrictive allowlists, and reject package installs with unloadable extension entries. Thanks @vincentkoc.
- WebChat/Control UI: support non-video file attachments in chat uploads while preserving the existing image attachment path and MIME-sniff fallback for generic image uploads. (#70947) Thanks @IAMSamuelRodda.
- Skills/memory: restore Chokidar v5 hot reloads by watching concrete skill and memory roots with filters, including SKILL.md removals and deleted skill folders without broad workspace recursion. Fixes #27404, #33585, and #41606. Thanks @shelvenzhou, @08820048, and @rocke2020.
- Gateway/chat: keep duplicate attachment-backed `chat.send` retries with the same idempotency key on the documented in-flight path so aborts still target the real active run. Fixes #70139. Thanks @Feelw00.
- Gateway/chat: preserve repeated boundary characters while merging assistant chat stream deltas, including repeated digits, CJK characters, and markdown/table tokens. Fixes #63769; carries forward #63994 and #65457. Thanks @yon950905 and @mohuaxiao.
- Plugins: share package entrypoint resolution between install and discovery, reject mismatched `runtimeExtensions`, and cache bundled runtime-dependency manifest reads during scans. Thanks @vincentkoc.
- WhatsApp/Web: keep quiet but healthy linked-device sessions connected by basing the watchdog on WhatsApp Web transport activity, while retaining a longer app-silence cap so frame activity cannot mask a stuck session forever. Fixes #70678; carries forward the focused #71466 approach and keeps #63939 as related configurable-timeout follow-up. Thanks @vincentkoc and @oromeis.
- Discord/gateway: count failed health-monitor restart attempts toward cooldown and hourly caps, and evict stale account lifecycle state during channel reloads so repeated Discord gateway recovery cannot loop on old status. Fixes #38596. (#40413) Thanks @jellyAI-dev and @vashquez.
- TTS/BlueBubbles: pre-transcode synthesized MP3 audio to opus-in-CAF (mono, 24 kHz — validated against macOS 15.x Messages.app's native voice-memo CAF descriptor) on macOS hosts before handing the file to BlueBubbles, so iMessage renders the result as a native voice-memo bubble with proper duration and waveform UI instead of a plain file attachment. Adds an opt-in `tts.voice.preferAudioFileFormat` channel capability and a magic-byte sniff for the CAF container so the host-local-media validator (which uses `file-type` and didn't recognize CAF natively) can verify the pre-transcoded buffer. Channels that don't opt in are unaffected. (#72586) Fixes #72506. Thanks @omarshahine.
- Feishu: retry WebSocket startup failures with monitor-owned backoff while preserving SDK-local heartbeat defaults, so persistent-connection startup failures no longer leave the monitor hung. Fixes #68766; related #42354 and #55532. Thanks @alex-xuweilong, @120106835, @sirfengyu, and @tianhaocui.
## 2026.4.26
### Changes
- Channels/QQBot: add full group chat support (history tracking, @-mention gating, activation modes, per-group config, FIFO message queue with deliver debounce), C2C `stream_messages` streaming with a `StreamingController` lifecycle manager, unified `sendMedia` with chunked upload for large files, and refactor the engine into pipeline stages, focused outbound submodules, builtin slash-command modules, and explicit DI ports via `createEngineAdapters()`. (#70624) Thanks @cxyhhhhh.
- Channels/Yuanbao: register the Tencent Yuanbao external channel plugin (`openclaw-plugin-yuanbao`) in the official channel catalog, contract suites, and community plugin docs, with a new `docs/channels/yuanbao.md` quick-start guide for WebSocket bot DMs and group chats. (#72756) Thanks @loongfay.
- Control UI/Talk: add a generic browser realtime transport contract, Google Live browser Talk sessions with constrained ephemeral tokens, and a Gateway relay for backend-only realtime voice plugins. Thanks @VACInc.
- CLI/models: route provider-filtered model listing through an explicit source plan so user config, installed manifest rows, Provider Index previews, and scoped runtime fallbacks keep a stable authority order without adding another catalog cache. Thanks @shakkernerd.
- Providers: add Cerebras as a bundled plugin with onboarding, static model catalog, docs, and manifest-owned endpoint metadata.
- Memory/OpenAI-compatible: add optional `memorySearch.inputType`, `queryInputType`, and `documentInputType` config for asymmetric embedding endpoints, including direct query embeddings and provider batch indexing. Carries forward #63313 and #60727. Thanks @HOYALIM and @prospect1314521.
- Ollama/memory: add model-specific retrieval query prefixes for `nomic-embed-text`, `qwen3-embedding`, and `mxbai-embed-large` memory-search queries while leaving document batches unchanged. Carries forward #45013. Thanks @laolin5564.
- Plugins/providers: move pre-runtime model-id normalization, provider endpoint host metadata, and OpenAI-compatible request-family hints into plugin manifests so core no longer carries bundled-provider routing tables.
- Plugins/providers: move pre-runtime model-id normalization, endpoint host metadata, OpenAI-compatible request-family hints, model-catalog aliases/suppressions, OpenAI stale Spark suppression, and reusable startup metadata snapshots into plugin manifests so core no longer carries bundled-provider routing tables or repeated manifest rebuilds. Thanks @shakkernerd.
- Plugins/config: deprecate direct plugin config load/write helpers in favor of passed runtime snapshots plus transactional mutation helpers with explicit restart follow-up policy, scanner guardrails, runtime warnings, and revision-based cache invalidation.
- Plugins/install: allow `OPENCLAW_PLUGIN_STAGE_DIR` to contain layered runtime-dependency roots, resolving read-only preinstalled deps before installing missing deps into the final writable root. Fixes #72396. Thanks @liorb-mountapps.
- Control UI: add a raw config pending-changes diff panel that parses JSON5, redacts sensitive values until reveal, and avoids fake raw-edit callbacks when opening the panel. Refs #39831; supersedes #48621 and #46654. Thanks @JiajunBernoulli and @BunsDev.
- Control UI: polish the quick settings dashboard grid so common cards align across desktop, tablet, and mobile layouts without wasting horizontal space. Thanks @BunsDev.
- Matrix/E2EE: add `openclaw matrix encryption setup` to enable Matrix encryption, bootstrap recovery, and print verification status from one setup flow. Thanks @gumadeiras.
- Agents/compaction: add an opt-in `agents.defaults.compaction.maxActiveTranscriptBytes` preflight trigger that runs normal local compaction when the active JSONL grows too large, requiring transcript rotation so successful compaction moves future turns onto a smaller successor file instead of raw byte-splitting history. Thanks @vincentkoc.
- CLI/migration: add a bundled Claude importer that previews and applies Claude Code and Claude Desktop instructions, MCP servers, skills, command prompts, and safe archive/manual-review state. Thanks @vincentkoc.
- CLI/migration: add `openclaw migrate` with plan, dry-run, JSON, pre-migration backup, onboarding detection, archive-only report copies, and a bundled Hermes importer for configuration, memory/plugin hints, model providers, MCP servers, skills, and supported credentials. Thanks @NousResearch.
- CLI/migration: add `openclaw migrate` with plan, dry-run, JSON, pre-migration backup, onboarding detection, archive-only reports, a Claude Code/Desktop importer, and a Hermes importer for configuration, memory/plugin hints, model providers, MCP servers, skills, commands, and supported credentials. Thanks @vincentkoc and @NousResearch.
### Fixes
- Agents/LSP: terminate bundled stdio LSP process trees during runtime disposal and Gateway shutdown, so nested children such as `tsserver` do not survive stop or restart. Fixes #72357. Thanks @ai-hpc and @bittoby.
- Gateway/device tokens: stop echoing rotated bearer tokens from shared/admin `device.token.rotate` responses while preserving the same-device token handoff needed by token-only clients before reconnect. (#66773) Thanks @MoerAI.
- Agents/sessions_spawn: resolve configured bare model aliases for spawn model overrides using the target agent runtime default provider, carrying forward the alias-specific #69029 review fixes from #59681 without the unrelated active-session pruning path. Fixes #59681. Thanks @HowdyDooToYou.
- Control UI/Talk: keep Google Live browser sessions on the WebSocket transport instead of falling back to WebRTC, validate browser Google Live WebSocket endpoints, cap Gateway relay sessions per browser connection, and remove stale browser-native voice buttons that did not use the configured Talk/TTS provider. Thanks @BunsDev.
- Gateway/startup: reuse config snapshot plugin manifests for startup auto-enable before plugin bootstrap plans plugin loading. Thanks @shakkernerd.
- Gateway/startup: reuse config snapshot plugin manifests for startup auto-enable, config validation, and plugin bootstrap planning, including authored source config and disabled setup-probe handling, so restrictive allowlists avoid duplicate manifest/config passes during boot. Thanks @shakkernerd.
- Agents/subagents: enforce `subagents.allowAgents` for explicit same-agent `sessions_spawn(agentId=...)` calls instead of auto-allowing requester self-targets. Fixes #72827. Thanks @oiGaDio.
- ACP/sessions_spawn: let explicit `sessions_spawn(runtime="acp")` bootstrap turns run while `acp.dispatch.enabled=false` still blocks automatic ACP thread dispatch. Fixes #63591. Thanks @moeedahmed.
- CLI/update: install npm global updates into a verified temporary prefix before swapping the package tree into place, preventing mixed old/new installs and stale packaged files from breaking `openclaw update` verification. Thanks @shakkernerd.
- Gateway: skip CLI startup self-respawn for foreground gateway runs so low-memory Linux/Node 24 hosts start through the same path as direct `dist/index.js` without hanging before logs. Fixes #72720. Thanks @sign-2025.
- Google Meet: grant Meet media permissions through browser control and pin local Chrome audio defaults to `BlackHole 2ch`, so joined agents no longer show `Permission needed` or use macOS default audio devices. Thanks @DougButdorf.
- Gateway: treat uncaught broken-pipe stream errors like `EPIPE` as non-fatal so Discord delivery or closed pipes no longer crash the Gateway after a reply is ready.
- Google Meet: route local Chrome joins through OpenClaw browser control instead of raw default Chrome, so agents use the configured OpenClaw browser profile when opening Meet. Thanks @oromeis.
- Google Meet: route local Chrome joins through OpenClaw browser control, grant Meet media permissions, pin local Chrome audio defaults to `BlackHole 2ch`, and use the configured OpenClaw browser profile so joined agents no longer show `Permission needed` or use raw/default Chrome state. Thanks @DougButdorf and @oromeis.
- Plugins/discovery: follow symlinked plugin directories in global and workspace plugin roots while keeping broken links ignored and existing package safety checks in place. Fixes #36754; carries forward #72695 and #63206. Thanks @Quackstro, @ming1523, and @xsfX20.
- Plugins/install: skip test files and directories during install security scans while still force-scanning declared runtime entrypoints, so packaged test mocks no longer block plugin installs. Fixes #66840; carries forward #67050. Thanks @saurabhjain1592 and @Magicray1217.
- Plugins/install: allow exact package-manager peer links back to the trusted OpenClaw host package during install security scans while continuing to block spoofed or nested escaping `node_modules` symlinks. Carries forward #70819. Thanks @fgabelmannjr.
@@ -279,7 +42,6 @@ Docs: https://docs.openclaw.ai
- Plugins/registry: suppress duplicate-plugin startup warnings when a tracked npm-installed plugin intentionally overrides the bundled plugin with the same id. Carries forward #48673. Thanks @abdushsk.
- Plugins/startup: reuse canonical realpath lookups throughout each plugin discovery pass, including package and manifest boundary checks, so Windows npm-global startups no longer repeat expensive path resolution for the same plugin roots. Fixes #65733. Thanks @welfo-beo.
- Gateway/proxy: pass `ALL_PROXY` / `all_proxy` into the global Undici env-proxy dispatcher and provider proxy-fetch helper while keeping SSRF trusted-proxy auto-upgrade on `HTTP_PROXY` / `HTTPS_PROXY` only, so gateway/provider calls honor all-proxy setups without weakening guarded fetches. Fixes #43821; carries forward #43919. Thanks @RickyTong1.
- Providers/LiteLLM: honor `--custom-base-url` during non-interactive API-key onboarding without adding proxy discovery side effects, so scripted remote LiteLLM setup keeps the requested endpoint instead of falling back to localhost. Carries forward #66160. Thanks @dongs0104.
- Reply/link understanding: keep media and link preprocessing on stable runtime entrypoints and continue with raw message content if optional enrichment fails, so URL-bearing messages are no longer dropped after stale runtime chunk upgrades. Fixes #68466. Thanks @songshikang0111.
- Discord: persist routed model-picker overrides when the hidden `/model` dispatch succeeds but the bound thread session store is still stale, including LM Studio suffixed model ids. Carries forward #61473. Thanks @Nanako0129.
- Nodes/CLI: add `openclaw nodes remove --node <id|name|ip>` and `node.pair.remove` so stale gateway-owned node pairing records can be cleaned without hand-editing state files.
@@ -290,8 +52,7 @@ Docs: https://docs.openclaw.ai
- Local models: default custom providers with only `baseUrl` to the Chat Completions adapter and trust loopback model requests automatically, so local OpenAI-compatible proxies receive `/v1/chat/completions` without timing out. Fixes #40024. Thanks @parachuteshe.
- Channels/message tool: surface Discord, Slack, and Mattermost `user:`/`channel:` target syntax in the shared message target schema and Discord ambiguity errors, so DM sends by numeric id stop burning retries before finding `user:<id>`. Fixes #72401. Thanks @garyd9, @hclsys, and @praveen9354.
- Agents/tools: scope tool-loop detection history to the active run when available, so scheduled heartbeat cycles no longer inherit stale repeated-call counts from previous runs. Fixes #40144. Thanks @mattbrown319.
- Agents/subagents: preserve requester delivery for completion announces when a child agent is bound to a different channel account while keeping same-channel thread completions routed to the child thread. Thanks @sfuminya.
- Agents/subagents: fail closed instead of selecting a single child thread binding when completion delivery lacks requester conversation signal. Thanks @suyua9.
- Agents/subagents: preserve requester delivery for completion announces across different channel accounts, keep same-channel thread completions routed to the child thread, and fail closed instead of guessing a child binding when requester conversation signal is missing. Thanks @sfuminya and @suyua9.
- Agents/status: persist the post-compaction token estimate from auto-compaction when providers omit usage metadata, so `/status` and session lists keep showing fresh context usage after compaction. Fixes #67667; carries forward #72822. Thanks @Jimmy-xuzimo and @skylight-9.
- Control UI: show loading, reload, and retry states when a lazy dashboard panel cannot load after an upgrade, so the Logs tab no longer appears blank on stale browser bundles. Fixes #72450. Thanks @sobergou.
- Gateway/plugins: start the Gateway in degraded mode when a single plugin entry has invalid schema config, and let `openclaw doctor --fix` quarantine that plugin config instead of crash-looping every channel. Fixes #62976 and #70371. Thanks @Doraemon-Claw and @pksidekyk.
@@ -299,8 +60,6 @@ Docs: https://docs.openclaw.ai
- Agents/reasoning: recover fully wrapped unclosed `<think>` replies that would otherwise sanitize to empty text while keeping strict stripping for closed reasoning blocks and unclosed tails after visible text. Fixes #37696; supersedes #51915. Thanks @druide67 and @okuyam2y.
- Control UI/Gateway: bind WebChat handshakes to their active socket and reject post-close server registrations, so aborted connects no longer leave zombie clients or misleading duplicate WebSocket connection logs. Fixes #72753. Thanks @LumenFromTheFuture.
- Agents/fallback: split ambiguous provider failures into `empty_response`, `no_error_details`, and `unclassified`, and add flat fallback-step fields to structured fallback logs so primary-model failures stay visible when later fallbacks also fail. Fixes #71922; refs #71744. Thanks @andyk-ms and @nikolaykazakovvs-ux.
- Gateway/startup: reuse the plugin manifest registry inside config validation so restrictive plugin allowlists avoid a duplicate manifest pass during startup. Thanks @shakkernerd.
- Gateway/startup: run plugin auto-enable from authored source config and skip disabled setup probes, avoiding runtime-default plugin allowlist writes and a second config snapshot read during startup. Thanks @shakkernerd.
- Plugins/Windows: normalize Windows absolute paths before handing bundled plugin modules to Jiti, so Feishu/Lark message sending no longer fails with unsupported `c:` ESM loader URLs. Fixes #72783. Thanks @jackychen-png.
- CLI/doctor: run bundled plugin runtime-dependency repairs through the async npm installer with spinner/line progress and heartbeat updates, so long `openclaw doctor --fix` installs no longer look hung in TTY or piped output. Fixes #72775. Thanks @dfpalhano.
- Feishu/Windows: normalize bundled channel sidecar loads before Jiti evaluates them, so Feishu outbound sends no longer fail with raw `C:` ESM loader errors on Windows. Fixes #72783. Thanks @jackychen-png.
@@ -378,31 +137,16 @@ Docs: https://docs.openclaw.ai
- Docker/setup: route Docker onboarding defaults for host-side LM Studio and Ollama through `host.docker.internal` and add the Linux host-gateway mapping to the bundled Compose file, so containerized gateways can reach local providers without using container loopback. Fixes #68684; supersedes #68702. Thanks @safrano9999 and @skolez.
- Agents/LM Studio: strip prior-turn Gemma 4 reasoning from OpenAI-compatible replay while preserving active tool-call continuation reasoning. Fixes #68704. Thanks @chip-snomo and @Kailigithub.
- LM Studio: allow interactive onboarding to leave the API key blank for unauthenticated local servers, using local synthetic auth while clearing stale LM Studio auth profiles. Fixes #66937. Thanks @olamedia.
- Plugins/startup: use a `PluginLookUpTable` during Gateway startup so channel ownership, deferred channel loading, and startup plugin IDs reuse the same installed manifest registry instead of rebuilding manifest metadata on the boot path. Thanks @shakkernerd.
- Plugins/startup: pass the Gateway `PluginLookUpTable` through plugin loading so auto-enable checks and startup-scope fallback reuse the same manifest registry instead of doing another manifest pass. Thanks @shakkernerd.
- Plugins/startup: carry the Gateway `PluginLookUpTable` into deferred channel full-runtime reloads so post-listen startup does not rebuild manifest metadata after the provisional setup-runtime load. Thanks @shakkernerd.
- Gateway/models: reuse Gateway plugin manifest metadata during the initial model-pricing refresh so pricing policies and configured plugin web-search models do not rebuild plugin lookups during startup. Thanks @shakkernerd.
- Gateway/startup: extend `OPENCLAW_GATEWAY_STARTUP_TRACE=1` with per-phase event-loop delay plus plugin lookup-table timing and count metrics for installed-index, manifest, startup-plan, and owner-map work, and include the new timing fields in startup benchmark summaries. Thanks @shakkernerd.
- Plugins/channels: resolve read-only channel command defaults from one plugin index plus manifest pass instead of reloading plugin metadata while checking candidate plugin enablement. Thanks @shakkernerd.
- Plugins/capabilities: cache manifest-derived capability provider plugin IDs per config snapshot so repeated TTS, media, realtime, memory, image, video, and music provider resolution avoids redundant manifest scans. Thanks @shakkernerd.
- Plugins/contracts: resolve runtime manifest-contract plugin owners from one plugin index plus manifest pass instead of rebuilding manifest metadata separately for all owners and enabled owners. Thanks @shakkernerd.
- Plugins/extractors: reuse one manifest registry pass while resolving bundled document and web-content extractor plugins instead of rereading manifests for compatibility and enablement filtering. Thanks @shakkernerd.
- Plugins/providers: reuse one plugin registry snapshot and manifest registry while resolving provider discovery entries instead of rebuilding manifest metadata after provider owner discovery. Thanks @shakkernerd.
- Plugins/registry: resolve lookup-table owner maps for providers, CLI backends, setup providers, command aliases, model catalogs, channel configs, and manifest contracts while preserving setup-only CLI backend ownership. Thanks @shakkernerd.
- Plugins/registry: cache repeated installed-index manifest registry fallback rebuilds behind a bounded invalidating cache so cold provider-discovery paths avoid rereading unchanged manifests. Thanks @mcaxtr.
- Plugins/web: reuse manifest records already loaded for bundled web provider candidate discovery when falling back to public artifact provider loading. Thanks @shakkernerd.
- Plugins/startup/registry: reuse a Gateway `PluginLookUpTable` and one manifest registry pass across startup plugin IDs, plugin loading, deferred channel reloads, model pricing, read-only channel defaults, capability/provider/media resolution, manifest contracts, extractors, web fallback discovery, owner maps, and cold provider-discovery caches, with new startup-trace timing/count metrics for installed-index, manifest, startup-plan, and owner-map work. Thanks @shakkernerd and @mcaxtr.
- Mattermost: keep direct-message replies top-level by suppressing reply roots for DM delivery while preserving channel and group thread roots, and derive inbound chat kind from the trusted channel lookup instead of the websocket event channel type. Carries forward #60115, #55186, #72305, and #72659; refs #59758, #59981, #59791, and #57565. Thanks @vincentkoc, @jwchmodx, and @hnykda.
- Docker: pre-create `/home/node/.openclaw` with node ownership and private permissions so first-run Docker Compose named volumes no longer fail startup with EACCES. (#48072, #63959; fixes #61279) Thanks @timoxue and @jeanibarz.
- CLI/Gateway: treat local restart probe policy closes for connect, exact `device required`, pairing, and auth failures as Gateway reachability proof without accepting empty, broad standalone token/password/scope/role, or pair-substring 1008 close reasons. Fixes #48771; carries forward #48801; related #63491. Thanks @MarsDoge and @genoooool.
- Feishu: send outgoing interactive reply payloads as native cards with clickable buttons while preserving text, media, and document-comment fallbacks. Fixes #13175 and #58298; carries forward #47891. Thanks @Horacehxw.
- Control UI/WebChat: skip redundant final-event history reloads when the assistant payload already rendered, and keep deferred `session.message` reloads attached to the active run so final reconciliation no longer splits, duplicates, or drops assistant bubbles. Fixes #66875 and #66274; follows #66997 and #67037. Thanks @BiznessFish, @scotthuang, and @hansolo949.
- CLI/Agents: route new `openclaw agent --to` sessions through the configured default agent while migrating legacy `agent:main:<mainKey>` rows into the default-agent store, preserving the default-agent fix from #64108. Fixes #63992; related #56370, #56453, and #42009. Thanks @mushuiyu886 and @voocel.
- Process/Windows: decode command stdout and stderr from raw bytes with console-codepage awareness, while preserving valid UTF-8 output and multibyte characters split across chunks. Fixes #50519. Thanks @iready, @kevinten10, @zhangyongjie1997, @knightplat-blip, @heiqishi666, and @slepybear.
- Bonjour/Windows: hide the bundled mDNS advertiser's Windows ARP shell probe so Gateway startup no longer flashes command-prompt windows. Fixes #70238. Thanks @alexandre-leng, @PratikRai0101, @infinitypacific, and @tomerpeled.
- Agents/bootstrap: dedupe hook-injected bootstrap context files by workspace-relative path and store normalized resolved paths so duplicate relative and absolute hook paths no longer depend on the process cwd. (#59344; fixes #59319; related #56721, #56725, and #57587) Thanks @koen666.
- Agents/bootstrap: refresh cached workspace bootstrap snapshots on long-lived main-session turns when `AGENTS.md`, `SOUL.md`, `MEMORY.md`, or `TOOLS.md` change on disk, while preserving unchanged snapshot identity through the workspace file cache. (#64871; related #43901, #26497, #28594, #30896) Thanks @aimqwest and @mikejuyoon.
- macOS Gateway: detect installed-but-unloaded LaunchAgent split-brain states during status, doctor, and restart, and re-bootstrap launchd supervision before falling back to unmanaged listener restarts. Fixes #67335, #53475, and #71060; refs #58890, #60885, and #70801. Thanks @ze1tgeist88, @dafacto, and @vishutdhar.
- Gateway/restart: keep local restart-health probes on configured local daemon auth without falling back to remote gateway credentials. (#57374, #59439) Thanks @zssggle-rgb and @roytong9.
- Plugins/install: treat mirrored core logger dependencies as staged bundled runtime deps so packaged Gateway starts do not crash when the external plugin-runtime-deps root is missing `tslog`. Fixes #72228; supersedes #72493. Thanks @deepujain.
- Build/plugins: preserve active bundled runtime-dependency staging temp directories owned by live build processes so overlapping postbuild runs no longer delete each other's staged deps mid-prune. Supersedes #72220. Thanks @VACInc.
- Plugins/install: hide bundled runtime-dependency npm child windows on Windows across Gateway startup, postinstall, and packaged staging paths so Telegram/Anthropic dependency repair no longer flashes shell windows. Fixes #72315. Thanks @athuljayaram and @joshfeng.
@@ -418,28 +162,12 @@ Docs: https://docs.openclaw.ai
- CLI/help: treat positional `help` invocations like `openclaw channels help` as help paths for startup gating, avoiding model/auth warmup while preserving positional arguments such as `openclaw docs help`. Thanks @gumadeiras.
- Web search: route plugin-scoped web_search SecretRefs through the active runtime config snapshot so provider execution receives resolved credentials across app/runtime paths, including `plugins.entries.brave.config.webSearch.apiKey`. Fixes #68690. Thanks @VACInc.
- Voice Call: allow SecretRef-backed Twilio auth tokens and call-specific OpenAI/ElevenLabs TTS API keys through the plugin config surface. Fixes #68690. Thanks @joshavant.
- Google Meet: clean stale chrome-node realtime audio bridges by URL before rejoining, expose active node bridge inspection, and tolerate transient node input pull failures instead of dropping the Meet session. Fixes #72371. (#72372) Thanks @BsnizND.
- Google Meet: use 24 kHz PCM16 for Chrome command-pair realtime audio by default, preserve legacy 8 kHz G.711 mu-law custom command pairs, and let realtime providers negotiate the selected bridge audio format. Fixes #72525. Thanks @BsnizND.
- Google Meet: clear queued Gemini Live playback when realtime interruptions arrive, restart Chrome command-pair audio output after clears, and expose Google Live interruption/VAD config knobs for Meet and Voice Call realtime bridges. Fixes #72523. (#72524) Thanks @BsnizND.
- Google Meet: add `realtime.agentId` so live meeting consults can target a named OpenClaw agent instead of always using `main`. (#72381) Thanks @BsnizND.
- Google Meet: route stateful `google_meet` tool actions through the gateway-owned runtime so created or joined realtime sessions remain visible to status, speak, and leave after the agent turn ends. Fixes #72440. (#72441) Thanks @BsnizND.
- Google Meet/Voice Call: send Gemini Live a non-blocking consult continuation before long OpenClaw agent consults finish, then deliver the final result when idle so calls and meetings do not sit silent during tool-backed answers. (#72189) Thanks @VACInc.
- Google Meet: preserve Gemini Live function names when replying to realtime tool calls so Google SDK validation accepts the `FunctionResponse` payload. Fixes #72425. (#72426) Thanks @BsnizND.
- Google Meet/Voice Call: clean stale chrome-node realtime bridges before rejoining, expose bridge inspection, tolerate transient node input pull failures, default Chrome command-pair audio to 24 kHz PCM16 while preserving legacy 8 kHz G.711 mu-law pairs, handle Gemini Live interruptions/VAD and function-response names correctly, route stateful `google_meet` tools through the gateway runtime, support `realtime.agentId`, and send non-blocking consult continuations before long tool-backed answers finish. Fixes #72371, #72525, #72523, #72440, and #72425; (#72372, #72524, #72381, #72441, #72189, #72426) Thanks @BsnizND and @VACInc.
- Discord/media: keep incidental Markdown image badges in final replies as text unless a channel opts into Markdown-image media extraction, while preserving Telegram Markdown-image media replies and explicit `MEDIA:` attachments. Fixes #72642. Thanks @solavrc and @Bartok9.
- Matrix/E2EE: stabilize recovery and broken-device QA flows while avoiding Matrix device-cleanup sync races that could leave shutdown-time crypto work running. Thanks @gumadeiras.
- Cron: apply `cron.maxConcurrentRuns` to a dedicated `cron-nested` isolated agent-turn lane as well as cron dispatch, so parallel cron jobs no longer serialize on inner LLM execution while non-cron nested flows keep their existing lane behavior. Fixes #72707. Thanks @kagura-agent.
- Cron: report isolated runs as successful when verified cron delivery already delivered the reply, while keeping unresolved Message/Canvas tool failures fatal. Fixes #72732 and #50170; follow-up to #54188. Thanks @zNatix, @pixeldyn, and @ChickenEggRoll.
- Cron: treat isolated run-level agent failures as job errors even when no reply payload is produced, synthesizing a safe error payload so model/provider failures increment error counters and trigger failure notifications instead of clearing as successful. Fixes #43604; carries forward #43631. Thanks @SPFAdvisors.
- Cron: preserve exact `NO_REPLY` tool results from isolated jobs with empty final assistant turns as quiet successes instead of surfacing incomplete-turn errors. Fixes #68452; carries forward #68453. Thanks @anyech.
- Cron: resolve failure alerts and failure-destination announcements against `session:<id>` targets before falling back to the creator session, so jobs created from group chats can notify the targeted direct session without cross-account routing errors. Refs #62777; carries forward #68535. Thanks @slideshow-dingo and @likewen-tech.
- Discord: preserve explicit `user:` and `channel:` delivery targets through plugin routing so cron announcements and failure alerts keep their intended recipient kind. Refs #62777; carries forward #62798. Thanks @neeravmakwana.
- Cron: add `failureAlert.includeSkipped` and `openclaw cron edit --failure-alert-include-skipped` so persistently skipped jobs can alert without counting skips as execution errors or affecting retry backoff. Fixes #60846. Thanks @slideshow-dingo.
- Cron: invalidate stale pending runtime slots after live or offline `jobs.json` schedule edits, while preserving due slots for formatting-only rewrites. Fixes #27996 and #71607; carries forward #71651. Thanks @xialonglee and @fagnersouza666.
- Cron: keep legacy flat `jobs.json` rows loadable while comparing split-state schedule identities, so old cron stores do not crash before in-memory hydration can normalize them.
- Cron: start isolated agent-turn execution timeouts after the runner enters its effective execution lane, so queued cron/manual runs no longer spend their whole timeout budget before useful work begins. Fixes #41783. Thanks @ayanesakura and @Hurray0.
- Cron/Telegram: preserve direct-chat thread IDs and optional account IDs when inferring reminder delivery from Telegram direct-thread session keys. Fixes #44270; carries forward #44325, #44351, #44412, and #72657. Thanks @RunMintOn, @arkyu2077, @0xsline, and @vincentkoc.
- Cron: omit synthetic `delivery.resolved` errors from `--no-deliver` run records while preserving explicit no-deliver target traces for agent-initiated messages. Fixes #72210; carries forward #72219. Thanks @hatemclawbot-collab and @xydigit-sj.
- Cron: classify isolated runs as errors from structured embedded-run execution-denial metadata, with final-output marker fallback for `SYSTEM_RUN_DENIED`, `INVALID_REQUEST`, and approval-binding refusals, so blocked commands no longer appear green in cron history. Fixes #67172; carries forward #67186. Thanks @oc-gh-dr, @hclsys, and @1yihui.
- Cron: apply `cron.maxConcurrentRuns` to the nested isolated-agent lane, start isolated execution timeouts only after the runner enters that lane, keep legacy flat `jobs.json` rows loadable, invalidate stale pending runtime slots after schedule edits, and preserve due slots for formatting-only rewrites. Fixes #72707, #27996, #71607, and #41783; carries forward #71651. Thanks @kagura-agent, @xialonglee, @fagnersouza666, @ayanesakura, and @Hurray0.
- Cron/delivery: classify isolated successes, quiet `NO_REPLY` turns, model/provider failures, execution denials, `--no-deliver` traces, skipped-job alerts, and verified delivery outcomes correctly so cron history, retries, and failure counters reflect what actually happened. Fixes #72732, #50170, #43604, #68452, #60846, #72210, and #67172; follow-up to #54188; carries forward #43631, #68453, #72219, and #67186. Thanks @zNatix, @pixeldyn, @ChickenEggRoll, @SPFAdvisors, @anyech, @slideshow-dingo, @hatemclawbot-collab, @xydigit-sj, @oc-gh-dr, @hclsys, and @1yihui.
- Cron/routing: preserve direct Telegram thread/account IDs, explicit Discord `user:`/`channel:` delivery targets, and `session:<id>` failure-destination routing so reminders, cron announcements, and failure alerts keep the intended recipient kind across direct and group chats. Fixes #44270; refs #62777; carries forward #44325, #44351, #44412, #72657, #68535, and #62798. Thanks @RunMintOn, @arkyu2077, @0xsline, @vincentkoc, @slideshow-dingo, @likewen-tech, and @neeravmakwana.
- Subagents: keep the delegated task only in the subagent system prompt and send a short initial kickoff message, avoiding duplicate task tokens while preserving multiline task formatting. Fixes #72019; carries forward #72053. Thanks @Wizongod and @ly85206559.
- Onboarding/GitHub Copilot: add manifest-owned `--github-copilot-token` support for non-interactive setup, including env fallback, tokenRef storage in ref mode, saved-profile reuse, and current Copilot default-model wiring. Refs #50002 and supersedes #50003. Thanks @scottgl9.
- Gateway/install: add a validated `--wrapper`/`OPENCLAW_WRAPPER` service install path that persists executable LaunchAgent/systemd wrappers across forced reinstalls, updates, and doctor repairs instead of falling back to raw node/bun `ProgramArguments`. Fixes #69400. (#72445) Thanks @willtmc.
@@ -451,27 +179,13 @@ Docs: https://docs.openclaw.ai
- Exec/node: synthesize a local approval plan when a paired node advertises `system.run` without `system.run.prepare`, unblocking approval-required `host=node` exec on current macOS companion nodes while preserving remote prepare for node hosts that support it. Fixes #37591 and duplicate #66839; carries forward #69725. Thanks @soloclz.
- Memory/QMD: prefer QMD's `--mask` collection pattern flag so root memory indexing stays scoped to `MEMORY.md` instead of widening to every markdown file in the workspace. Fixes #65480; supersedes #65481 and #66259. Thanks @ccage-simp, @Bortlesboat, @seank-com, and @crazyscience.
- Memory/doctor: treat the specific `gateway timeout after ...` gateway memory probe result as inconclusive instead of reporting embeddings not ready, while preserving warnings for explicit failures. Fixes #44426; carries forward #46576 with the Greptile review feedback applied. Thanks Cengiz (@ghost).
- Gateway/memory: defer QMD startup for implicit non-default agents and scope memory runtime loading to the selected memory slot so Gateway boot and first memory recall avoid broad plugin runtime fanout. Thanks @vincentkoc.
- Gateway/startup: keep core request handlers, setup wizard, and channel runtime helpers off the boot path until the first matching request, wizard run, or channel start, reducing no-plugin Gateway ready RSS and avoidable startup imports. Thanks @vincentkoc.
- Gateway/startup: keep CLI outbound channel send dependencies as lazy request-time senders so Gateway boot no longer imports channel plugin registration just to construct default deps. Thanks @vincentkoc.
- Gateway/startup: split lightweight HTTP auth helpers away from model-override helpers so Gateway bind no longer imports model catalog selection while wiring base HTTP routes. Thanks @vincentkoc.
- Gateway/startup: lazy-load plugin HTTP route dispatch when active plugin routes exist so no-plugin Gateway boot skips plugin route runtime scope setup. Thanks @vincentkoc.
- Gateway/startup: move chat run/subscriber registries onto a lightweight state module and defer chat/session event projection until the first event so Gateway boot skips session IO imports. Thanks @vincentkoc.
- Gateway/startup: keep node session runtime on a lightweight JSON parser instead of importing gateway method validation helpers during boot. Thanks @vincentkoc.
- Gateway/startup: read embedded-run activity from a lightweight shared state module so restart deferral no longer imports the embedded runner during Gateway boot. Thanks @vincentkoc.
- Gateway/startup: defer MCP loopback server imports until Gateway shutdown so normal boot no longer loads the loopback HTTP/tool schema stack just to register close handlers. Thanks @vincentkoc.
- Gateway/startup: resolve channel runtime helpers asynchronously only when an enabled/configured channel starts, so no-channel Gateway boot skips auto-reply, media, pairing, and outbound channel helper imports. Thanks @vincentkoc.
- Gateway/startup: lazy-load HTTP auth, canvas auth, and plugin route scope helpers from their request paths so Gateway bind no longer pays those utility graphs during boot. Thanks @vincentkoc.
- Gateway/startup: defer isolated cron runner imports until `/hooks/agent` dispatch so Gateway boot skips the agent-turn runtime on installs that only need normal HTTP bind. Thanks @vincentkoc.
- Gateway/startup: split hook request parsing into a request-path module and load the Gateway hook dispatcher only when a request matches the hooks base path, keeping hook mapping and throttle helpers off plain HTTP bind. Thanks @vincentkoc.
- CLI/Gateway: use a parse-only config snapshot for plain `gateway status` reads and reuse same-path service config context so status no longer spends tens of seconds in full config validation before printing. Thanks @vincentkoc.
- Gateway/startup: defer QMD, core request handlers, setup wizard, CLI outbound senders, plugin HTTP routes, chat/session projection, node session runtime validation, embedded-run activity reads, MCP loopback server imports, channel runtime helpers, HTTP/canvas/plugin auth helpers, isolated cron imports, and hook dispatch parsing until their request or shutdown paths, while making plain `gateway status` use a parse-only config snapshot so no-plugin boots and status reads avoid broad runtime fanout. Thanks @vincentkoc.
- Lobster/Gateway: memoize repeated Ajv schema compilation before loading the embedded Lobster runtime so scheduled workflows and `llm.invoke` loops stop growing gateway heap on content-identical schemas. Fixes #71148. Thanks @cmi525, @vsolaz, and @vincentkoc.
- Codex harness: normalize cached input tokens before session/context accounting so prompt cache reads are not double-counted in `/status`, `session_status`, or persisted `sessionEntry.totalTokens`. Fixes #69298. Thanks @richardmqq.
- Hooks/session-memory: use the host local timezone for memory filenames, fallback timestamp slugs, and markdown headers instead of UTC dates. Fixes #46703. (#46721) Thanks @Astro-Han.
- Gateway health: preserve live runtime-backed channel/account state in `gateway.health` snapshots and cached refreshes while keeping raw probe payloads on sensitive/admin paths only. (#39921, #42586, #46527, #52770, #42543) Thanks @FAL1989, @rstar327, @0xble, and @ajayr.
- Feishu: extract quoted/replied interactive-card text across schema 1.0, schema 2.0, i18n, template-variable, and post-format fallback shapes without carrying broad generated/config churn from related parser experiments. (#38776, #60383, #42218, #45936) Thanks @lishuaigit, @lskun, @just2gooo, and @Br1an67.
- Telegram/agents: hide raw failed write/edit warning messages in Telegram when the assistant already explicitly acknowledges the failed action, while keeping warnings when the reply claims success or omits the failure; #39406 remains the broader configurable delivery-policy follow-up. Fixes #51065; covers #39631. Thanks @Bartok9 and @Bortlesboat.
- TUI: clear stale streaming status when an orphaned final event or watchdog reset leaves no tracked active run, flushing deferred local history refreshes without surfacing inactive-run failures. Fixes #64825; carries forward #52745. Thanks @lyksdu.
- Exec approvals: accept a symlinked `OPENCLAW_HOME` as the trusted approvals root while still rejecting symlinked `.openclaw` path components below it. (#64663) Thanks @FunJim.
- Logging: add top-level `hostname`, flattened `message`, and available `agent_id`, `session_id`, and `channel` fields to file-log JSONL records for multi-agent filtering without removing existing structured log arguments. Fixes #51075. Thanks @stevengonsalvez.
- ACP: route server logs to stderr before Gateway config/bootstrap work so ACP stdout remains JSON-RPC only for IDE integrations. Fixes #49060. Thanks @Hollychou924.
@@ -480,35 +194,12 @@ Docs: https://docs.openclaw.ai
- Logging: write validated diagnostic trace context as top-level `traceId`, `spanId`, `parentSpanId`, and `traceFlags` fields in file-log JSONL records so traced requests and model calls are easier to correlate in log processors. Refs #40353. Thanks @liangruochong44-ui.
- Logging/sessions: apply configured redaction patterns to persisted session transcript text and accept escaped character classes in safe custom redaction regexes, so transcript JSONL no longer keeps matching sensitive text in the clear. Fixes #42982. Thanks @panpan0000.
- Agents/sessions: let `sessions_spawn runtime="subagent"` ignore ACP-only `streamTo` and `resumeSessionId` fields while keeping ACP passthrough and documenting `streamTo` as ACP-only. Fixes #43556 and #63120; covers #56326, #61724, #64714, and #67248; carries forward #68397, #65282, #58686, #56342, and #40102. Thanks @skernelx, @damselem, @Br1an67, @Mintalix, @IsaacAPerez, @vvitovec, @Sanjays2402, @shenkq97, and @1034378361.
- Providers/Ollama: honor `/api/show` capabilities when registering local models so non-tool Ollama models no longer receive the agent tool surface, and keep native Ollama thinking opt-in instead of enabling it by default. Fixes #64710 and duplicate #65343. Thanks @yuan-b, @netherby, @xilopaint, and @Diyforfun2026.
- Providers/Ollama: honor `/api/show` capabilities, custom Modelfile `PARAMETER num_ctx`, configured provider/model context defaults, whitelisted native params such as `temperature`, `top_p`, and `think`, and native thinking effort levels so local models get accurate tools, context, and thinking behavior without forcing full-context VRAM use. Fixes #64710, duplicate #65343, #68344, #44550, #52206, #49684, #68662, #48010, #71584, and #44786; supersedes #69464; carries forward #44955. Thanks @yuan-b, @netherby, @xilopaint, @Diyforfun2026, @neeravmakwana, @taitruong, @armi0024, @LokiCode404, @zhouZcong, @dshenster-byte, @tangzhi, @pandego, @maweibin, @Adam-Researchh, @EmpireCreator, @g0st1n, and @voltwake.
- Image tool/media: honor `tools.media.image.timeoutSeconds` and matching per-model image timeouts in explicit image analysis, including the MiniMax VLM fallback path, so slow local vision models are not capped by hardcoded 30s/60s aborts. Fixes #67889; supersedes #67929. Thanks @AllenT22 and @alchip.
- Providers/Ollama: read larger custom Modelfile `PARAMETER num_ctx` values from `/api/show` so auto-discovered Ollama models with expanded context no longer stay pinned to the base model context. Fixes #68344. Thanks @neeravmakwana.
- Providers/Ollama: honor configured model `params.num_ctx` in native and OpenAI-compatible Ollama requests so local models can cap runtime context without rebuilding Modelfiles. Fixes #44550 and #52206; supersedes #69464. Thanks @taitruong, @armi0024, and @LokiCode404.
- Providers/Ollama: stop forcing native Ollama requests to use the full configured `contextWindow` as `options.num_ctx` unless `params.num_ctx` is explicit, so local models can keep Ollama's VRAM/env default instead of looking hung on first turns. Fixes #49684 and #68662. Thanks @zhouZcong and @dshenster-byte.
- Providers/Ollama: forward whitelisted native Ollama model params such as `temperature`, `top_p`, and top-level `think` so users can disable API-level thinking or tune local models from config without proxy shims. Fixes #48010. Thanks @tangzhi, @pandego, @maweibin, @Adam-Researchh, and @EmpireCreator.
- Providers/Ollama: expose native Ollama thinking effort levels so `/think max` is accepted for reasoning-capable Ollama models and maps to Ollama's highest supported `think` effort. Fixes #71584. Thanks @g0st1n.
- Providers/Ollama: strip the active custom Ollama provider prefix before native chat and embedding requests, so custom provider ids like `ollama-spark/qwen3:32b` reach Ollama as the real model name. Fixes #72353. Thanks @maximus-dss and @hclsys.
- Providers/Ollama: parse stringified native tool-call arguments before dispatch, preserving unsafe integer values so Ollama tool use receives structured parameters. Fixes #69735; supersedes #69910. Thanks @rongshuzhao and @yfge.
- Providers/Ollama: skip ambient localhost discovery unless Ollama auth or meaningful config opts in, preventing unexpected probes to `127.0.0.1:11434` for users who are not using Ollama. Fixes #56939; supersedes #57116. Thanks @IanxDev and @tsukhani.
- Providers/Ollama: skip implicit localhost discovery when a custom remote `api: "ollama"` provider is configured, while still treating `127/8` loopback hosts as local. Carries forward #43224. Thanks @issacthekaylon.
- Providers/models: honor provider-level `contextWindow`, `contextTokens`, and `maxTokens` as defaults when resolving discovered models, so local Ollama and other self-hosted providers can cap all models without repeating per-model entries. Fixes #44786; carries forward #44955. Thanks @voltwake and @maweibin.
- Providers/Ollama: move memory embeddings to Ollama's current `/api/embed` endpoint with batched `input` requests while preserving vector normalization and custom provider auth/header overrides. Fixes #39983. Thanks @sskkcc and @LiudengZhang.
- Providers/Ollama: route local web search through Ollama's signed `/api/experimental/web_search` daemon proxy, use hosted `/api/web_search` directly for `ollama.com`, and keep `OLLAMA_API_KEY` scoped to cloud fallback auth. Fixes #69132. Thanks @yoon1012 and @hyspacex.
- Providers/Ollama: accept OpenAI SDK-style `baseURL` as an alias for `baseUrl` across discovery, streaming, setup pulls, embeddings, and web search so remote Ollama hosts are not silently ignored. Fixes #62533; supersedes #62549. Thanks @Julien-BKK and @Linux2010.
- Providers/Ollama: scope synthetic local auth and embedding bearer headers to declared Ollama host boundaries so cloud keys are not sent to local/self-hosted embedding endpoints and remote/cloud Ollama endpoints no longer receive the `ollama-local` marker as if it were a real token. Supersedes #69261 and #69857; refs #43945. Thanks @hyspacex, @maxramsay, and @Meli73.
- Providers/Ollama: resolve custom-named local Ollama providers such as `ollama-remote` through the Ollama synthetic-auth hook so subagents no longer miss `ollama-local` auth and silently fall back to cloud models. Fixes #43945. Thanks @Meli73 and @maxramsay.
- Providers/Ollama: add provider-scoped model request timeouts, thread them through guarded fetch connect/header/body/abort handling, and document `params.keep_alive` for cold local models so first-turn Ollama loads no longer require global agent timeout changes. Fixes #64541 and #68796; supersedes #65143 and #66511. Thanks @LittleJakub, @Juankcba, @uninhibite-scholar, and @yfge.
- Providers/Ollama: preserve explicit configured model input modalities when merging discovered provider metadata so custom vision models keep image support instead of silently dropping attachments. Fixes #39690; carries forward #39785. Thanks @Skrblik and @Mriris.
- Providers/Ollama: estimate native Ollama transcript usage when `/api/chat` omits prompt/eval counters while preserving exact zero counters, keeping local model runs visible in usage surfaces. Carries forward #39112. Thanks @TylonHH.
- Agents/Ollama: retry native Ollama turns that finish without user-visible text, including unsigned thinking-only responses, so constrained reasoning turns can continue instead of surfacing an empty reply. Carries forward #66552 and #61223. Thanks @yfge and @L3G.
- Docs/Ollama: expand setup recipes for local, LAN, cloud, multi-host, web search, embeddings, thinking control, and large-context troubleshooting.
- Providers/Ollama: strip custom provider prefixes before native chat/embedding requests, skip ambient localhost discovery unless config/auth opts in, handle custom remote `api: "ollama"` providers, accept OpenAI SDK-style `baseURL`, scope synthetic local auth and embedding bearer headers to declared host boundaries, resolve custom-named local providers for subagents, add provider-scoped model request timeouts, preserve explicit input modalities, and document `params.keep_alive` plus local/LAN/cloud/multi-host/web-search/embedding/thinking setup recipes. Fixes #72353, #56939, #62533, #43945, #64541, #68796, and #39690; supersedes #57116, #62549, #69261, #69857, #65143, and #66511; refs #43945; carries forward #43224 and #39785. Thanks @maximus-dss, @hclsys, @IanxDev, @tsukhani, @issacthekaylon, @Julien-BKK, @Linux2010, @hyspacex, @maxramsay, @Meli73, @LittleJakub, @Juankcba, @uninhibite-scholar, @yfge, @Skrblik, and @Mriris.
- Providers/Ollama: move memory embeddings to `/api/embed` with batched `input`, route local web search through Ollama's signed daemon proxy while keeping cloud auth scoped, treat Ollama memory embeddings as key-optional in doctor, and keep model usage visible by estimating native transcript usage when `/api/chat` omits counters. Fixes #39983, #69132, and #46584; carries forward #39112. Thanks @sskkcc, @LiudengZhang, @yoon1012, @hyspacex, @fengly78, and @TylonHH.
- Agents/Ollama: parse stringified native tool-call arguments, retry native empty/thinking-only turns, accept already-prefixed LLM task model overrides, apply provider-owned replay normalization for Cloud models, validate explicit `--thinking max`, show resolved thinking defaults in Control UI, and include configured provider models in `models list --provider`. Fixes #69735, #50052, #71697, #71584, #72407, and #65207; supersedes #69910; carries forward #66552 and #61223. Thanks @rongshuzhao, @yfge, @L3G, @ralphy-maplebots, @Hollychou924, @ismael-81, @g0st1n, @NotecAG, and @drzeast-png.
- Providers/PDF/Ollama: add bounded network timeouts for Ollama model pulls and native Anthropic/Gemini PDF analysis requests so unresponsive provider endpoints no longer hang sessions indefinitely. Fixes #54142; supersedes #54144 and #54145. Thanks @jinduwang1001-max and @arkyu2077.
- LLM Task/Ollama: accept model overrides that already include the selected provider prefix, avoiding doubled ids such as `ollama/ollama/llama3.2:latest`, and live-verify local Ollama JSON tasks return parsed output. Fixes #50052. Thanks @ralphy-maplebots and @Hollychou924.
- Memory/doctor: treat Ollama memory embeddings as key-optional so `openclaw doctor` no longer warns about a missing API key when the gateway reports embeddings are ready. Fixes #46584. Thanks @fengly78.
- Agents/Ollama: apply provider-owned replay turn normalization to native Ollama chat so Cloud models no longer reject non-alternating replay history in agent/Gateway runs. Fixes #71697. Thanks @ismael-81.
- Control UI/Ollama: show the resolved configured thinking default in chat and session thinking dropdowns so inherited `adaptive`/per-model thinking config no longer appears as `Default (off)` or a generic inherit value. Fixes #72407. Thanks @NotecAG.
- Agents/Ollama: validate explicit `--thinking max` against catalog-discovered Ollama reasoning metadata so local agent runs accept the same native thinking levels shown in the model catalog. Fixes #71584. Thanks @g0st1n.
- CLI/models: include explicitly configured provider models in `openclaw models list --provider <id>` without requiring the full catalog path, so configured Ollama models are visible. Fixes #65207. Thanks @drzeast-png.
- Docker/QA: add observability coverage to the normal Docker aggregate so QA-lab OTEL and Prometheus diagnostics run inside Docker. Thanks @vincentkoc.
- Auto-reply: poison inbound message dedupe after replay-unsafe provider/runtime failures so retries stay safe before visible progress but cannot duplicate messages after block output, tool side effects, or session progress. Fixes #69303; keeps #58549 and #64606 as duplicate validation. Thanks @martingarramon, @NikolaFC, and @zeroth-blip.
- Agents/model fallback: keep auto-persisted fallback model overrides selected across turns until `/new` or reset clears them, avoiding repeated probes of a known-bad primary while `/status` shows the selected and active models. Thanks @kibedu.
@@ -516,11 +207,7 @@ Docs: https://docs.openclaw.ai
- Gateway/Bonjour: keep @homebridge/ciao cancellation handlers registered across advertiser restarts so late probing cancellations cannot crash Linux and other mDNS-churned gateways.
- Plugins/startup: load the default `memory-core` slot during Gateway startup when permitted so active-memory recall can call `memory_search` and `memory_get` without requiring an explicit `plugins.slots.memory` entry, while preserving `plugins.slots.memory: "none"`.
- Plugins/CLI: prefer native require for compiled bundled plugin JavaScript before jiti so read-only config, status, device, and node commands avoid unnecessary transform overhead on slow hosts. Fixes #62842. Thanks @Effet.
- Plugins/compat: inventory doctor-side deprecation migrations separately from runtime plugin compatibility so release sweeps preserve needed repairs while enforcing dated removal windows. Thanks @vincentkoc.
- Plugins/compat: add missing dated compatibility records for legacy extension-api, memory registration, provider hook/type aliases, runtime aliases, channel SDK helpers, and approval/test utility shims. Thanks @vincentkoc.
- Plugins/CLI: refresh the persisted registry after managed plugin files are removed so ClawHub uninstall cannot leave stale `plugins list` entries.
- Plugins/CLI: make plugin install and uninstall config writes conflict-aware, clear stale denylist entries on explicit reinstall/removal, and delete managed plugin files only after config/index commit succeeds.
- Plugins: fail `plugins update` when tracked plugin or hook updates error, keep bundled runtime-dependency repair behind restrictive allowlists, and reject package installs with unloadable extension entries.
- Plugins/compat/CLI: inventory doctor-side deprecation migrations separately from runtime plugin compatibility, add dated records for legacy extension-api, memory registration, provider hook/type aliases, runtime aliases, channel SDK helpers, and approval/test utility shims, refresh the persisted registry after managed plugin removals, make plugin install/uninstall writes conflict-aware, clear stale denylists, and fail tracked plugin/hook updates or unloadable package installs instead of leaving stale state. Thanks @vincentkoc.
- WebChat/Control UI: support non-video file attachments in chat uploads while preserving the existing image attachment path and MIME-sniff fallback for generic image uploads. (#70947) Thanks @IAMSamuelRodda.
- Skills/memory: restore Chokidar v5 hot reloads by watching concrete skill and memory roots with filters, including SKILL.md removals and deleted skill folders without broad workspace recursion. Fixes #27404, #33585, and #41606. Thanks @shelvenzhou, @08820048, and @rocke2020.
- Gateway/chat: keep duplicate attachment-backed `chat.send` retries with the same idempotency key on the documented in-flight path so aborts still target the real active run. Fixes #70139. Thanks @Feelw00.
@@ -536,12 +223,8 @@ Docs: https://docs.openclaw.ai
- Docker/update smoke: keep the package-derived update-channel fixture on package-shipped files and make its UI build stub create the asset the updater verifies. Thanks @vincentkoc.
- Gateway/models: repair legacy `models.providers.*.api = "openai"` config values to `openai-completions`, and skip providers with future stale API enum values during startup instead of bricking the gateway. Fixes #72477. (#72542) Thanks @JooyoungChoi14 and @obviyus.
- Gateway/skills: redact `apiKey` and secret-named `env` values from the `skills.update` RPC response to prevent leaking credentials into WebSocket traffic, client logs, or session transcripts. Config is still written to disk in full; only the response payload is redacted. (#69998) Thanks @Ziy1-Tan.
- Plugins/CLI: let flag-driven `openclaw channels add` install the selected channel plugin from its default source without opening an interactive prompt, fixing published npm Telegram setup in stdin-closed automation.
- Onboarding/setup: keep first-run config reads, plugin compatibility notices, and post-model sanity checks on cold metadata paths unless the user chooses to browse all models, avoiding full plugin/runtime catalog work between prompts. Thanks @shakkernerd.
- Onboarding/auth: run manifest-owned provider auth choices through scoped setup providers so selecting OpenAI Codex browser/device auth no longer loads every provider runtime before OAuth starts. Thanks @shakkernerd.
- Onboarding/auth: keep the post-auth default-model policy lookup on manifest/setup metadata so the next prompt appears without loading broad provider runtime. Thanks @shakkernerd.
- Onboarding/models: keep skip-auth and provider-scoped model picker prompts off the full global model catalog path, and cache provider catalog hook resolution so setup no longer stalls after auth on large plugin registries. Thanks @shakkernerd.
- Onboarding/setup: keep first-run config reads, plugin compatibility notices, OpenAI Codex auth, post-auth default-model policy lookup, skip-auth, provider-scoped model pickers, and post-model sanity checks on cold manifest/setup metadata unless the user chooses to browse all models, avoiding full plugin/provider runtime loads between prompts. Thanks @shakkernerd.
- Gateway/Bonjour: suppress known @homebridge/ciao cancellation and network assertion failures through scoped process handlers so malformed mDNS packets or restricted VPS networking disable/restart Bonjour instead of crashing the gateway. Fixes #67578. Thanks @zenassist26-create.
- Discord: keep late clicks on already-resolved exec approval buttons quiet when elevated mode auto-resolved the request, while still surfacing real approval submission failures. Fixes #66906. Thanks @rlerikse.
- Telegram: send a fresh final message for long-lived preview-streamed replies so the visible Telegram timestamp reflects completion time instead of the preview creation time. Thanks @rubencu.
@@ -1458,7 +1141,6 @@ Docs: https://docs.openclaw.ai
- Amazon Bedrock/prompt caching: resolve opaque application inference profile targets before injecting Bedrock cache points, require every routed target to support explicit cache points, and retry transient profile lookups instead of caching a false negative for the rest of the process. (#69953) Thanks @anirudhmarc and @vincentkoc.
- Gateway/channel health: base stale-socket recovery on provider-proven transport activity instead of inbound app-event freshness, preventing quiet Slack, Discord, Telegram, Matrix, and local-style channels from being restarted solely because no user traffic arrived. (#69833) Thanks @bek91.
- OpenCode Go: canonicalize stale bundled `opencode-go` base URLs from `/go` or `/go/v1` to `/zen/go` or `/zen/go/v1`, so older generated model metadata stops hitting the 404 HTML endpoint. (#69898).
- Plugins/channels: keep persisted channel auth state out of configured-channel and plugin auto-enable detection, so stale WhatsApp credentials alone no longer trigger plugin activation or runtime dependency repair. (#72836, #72844) Thanks @xiao398008 and @haishmg.
- CLI/channels: honor `channels.<id>.enabled=false` as a hard read-only presence opt-out, so env vars, manifest env vars, or stale persisted auth state no longer make disabled channel plugins appear in status, doctor, or setup-only discovery.
- Channels/preview streaming: centralize draft-preview finalization so Slack, Discord, Mattermost, and Matrix no longer flush temporary preview messages for media/error finals, and preserve first-reply threading for normal fallback delivery.
- Discord: keep slash command follow-up chunks ephemeral when the command is configured for ephemeral replies, so long `/status` output no longer leaks fallback model or runtime details into the public channel. (#69869) thanks @gumadeiras.

View File

@@ -156,10 +156,6 @@ LABEL org.opencontainers.image.source="https://github.com/openclaw/openclaw" \
WORKDIR /app
# Install runtime system utilities missing from bookworm-slim.
# `ca-certificates` ships in `bookworm` (full) but not in `bookworm-slim`,
# so it must be installed explicitly here. Without it `/etc/ssl/certs/`
# stays empty and every HTTPS outbound dies at TLS handshake with
# `error setting certificate file`.
RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
apt-get update && \

View File

@@ -1,13 +1,31 @@
{
"originHash" : "e6910acc97de62dc423c0a391985c1c2f28207951e356081539abde41f9ffc72",
"originHash" : "24a723309d7a0039d3df3051106f77ac1ed7068a02508e3a6804e41d757e6c72",
"pins" : [
{
"identity" : "commander",
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/Commander.git",
"state" : {
"revision" : "ae2ce746b386ff94b26648cfe5625cfa8d02639b",
"version" : "0.2.2"
"revision" : "9e349575c8e3c6745e81fe19e5bb5efa01b078ce",
"version" : "0.2.1"
}
},
{
"identity" : "elevenlabskit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/ElevenLabsKit",
"state" : {
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
"version" : "0.1.0"
}
},
{
"identity" : "swift-concurrency-extras",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
"state" : {
"revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
"version" : "1.3.2"
}
},
{
@@ -27,6 +45,24 @@
"revision" : "399f76dcd91e4c688ca2301fa24a8cc6d9927211",
"version" : "0.99.0"
}
},
{
"identity" : "swiftui-math",
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/swiftui-math",
"state" : {
"revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71",
"version" : "0.1.0"
}
},
{
"identity" : "textual",
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/textual",
"state" : {
"revision" : "5b06b811c0f5313b6b84bbef98c635a630638c38",
"version" : "0.3.1"
}
}
],
"version" : 3

View File

@@ -13,7 +13,7 @@ let package = Package(
.executable(name: "swabble", targets: ["SwabbleCLI"]),
],
dependencies: [
.package(url: "https://github.com/steipete/Commander.git", exact: "0.2.2"),
.package(url: "https://github.com/steipete/Commander.git", exact: "0.2.1"),
.package(url: "https://github.com/apple/swift-testing", from: "0.99.0"),
],
targets: [
@@ -43,6 +43,7 @@ let package = Package(
],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
.enableExperimentalFeature("SwiftTesting"),
]),
.testTarget(
name: "swabbleTests",

View File

@@ -45,15 +45,6 @@ extension AttributedString {
}
return ranges.compactMap { range in
guard #available(macOS 26.0, iOS 26.0, *) else {
return AttributedString(self[range].characters)
}
return self.sentenceWithAudioTimeRange(range)
}
}
@available(macOS 26.0, iOS 26.0, *)
private func sentenceWithAudioTimeRange(_ range: Range<AttributedString.Index>) -> AttributedString? {
let audioTimeRanges = self[range].runs.filter {
!String(self[$0.range].characters)
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
@@ -66,5 +57,6 @@ extension AttributedString {
start: start,
end: end)
return AttributedString(self[range].characters, attributes: attributes)
}
}
}

View File

@@ -17,35 +17,29 @@ public enum OutputFormat: String {
case .txt:
return String(transcript.characters)
case .srt:
guard #available(macOS 26.0, iOS 26.0, *) else { return "" }
return self.srtText(for: transcript, maxLength: maxLength)
func format(_ timeInterval: TimeInterval) -> String {
let ms = Int(timeInterval.truncatingRemainder(dividingBy: 1) * 1000)
let s = Int(timeInterval) % 60
let m = (Int(timeInterval) / 60) % 60
let h = Int(timeInterval) / 60 / 60
return String(format: "%0.2d:%0.2d:%0.2d,%0.3d", h, m, s, ms)
}
return transcript.sentences(maxLength: maxLength).compactMap { (sentence: AttributedString) -> (
CMTimeRange,
String)? in
guard let timeRange = sentence.audioTimeRange else { return nil }
return (timeRange, String(sentence.characters))
}.enumerated().map { index, run in
let (timeRange, text) = run
return """
\(index + 1)
\(format(timeRange.start.seconds)) --> \(format(timeRange.end.seconds))
\(text.trimmingCharacters(in: .whitespacesAndNewlines))
"""
}.joined().trimmingCharacters(in: .whitespacesAndNewlines)
}
}
@available(macOS 26.0, iOS 26.0, *)
private func srtText(for transcript: AttributedString, maxLength: Int) -> String {
func format(_ timeInterval: TimeInterval) -> String {
let ms = Int(timeInterval.truncatingRemainder(dividingBy: 1) * 1000)
let s = Int(timeInterval) % 60
let m = (Int(timeInterval) / 60) % 60
let h = Int(timeInterval) / 60 / 60
return String(format: "%0.2d:%0.2d:%0.2d,%0.3d", h, m, s, ms)
}
return transcript.sentences(maxLength: maxLength).compactMap { (sentence: AttributedString) -> (
CMTimeRange,
String)? in
guard let timeRange = sentence.audioTimeRange else { return nil }
return (timeRange, String(sentence.characters))
}.enumerated().map { index, run in
let (timeRange, text) = run
return """
\(index + 1)
\(format(timeRange.start.seconds)) --> \(format(timeRange.end.seconds))
\(text.trimmingCharacters(in: .whitespacesAndNewlines))
"""
}.joined().trimmingCharacters(in: .whitespacesAndNewlines)
}
}

View File

@@ -13,9 +13,7 @@ public struct WakeWordSegment: Sendable, Equatable {
self.range = range
}
public var end: TimeInterval {
self.start + self.duration
}
public var end: TimeInterval { start + duration }
}
public struct WakeWordGateConfig: Sendable, Equatable {
@@ -26,8 +24,7 @@ public struct WakeWordGateConfig: Sendable, Equatable {
public init(
triggers: [String],
minPostTriggerGap: TimeInterval = 0.45,
minCommandLength: Int = 1)
{
minCommandLength: Int = 1) {
self.triggers = triggers
self.minPostTriggerGap = minPostTriggerGap
self.minCommandLength = minCommandLength
@@ -81,10 +78,10 @@ public enum WakeWordGate {
segments: [WakeWordSegment],
config: WakeWordGateConfig)
-> WakeWordGateMatch? {
let triggerTokens = self.normalizeTriggers(config.triggers)
let triggerTokens = normalizeTriggers(config.triggers)
guard !triggerTokens.isEmpty else { return nil }
let tokens = self.normalizeSegments(segments)
let tokens = normalizeSegments(segments)
guard !tokens.isEmpty else { return nil }
var best: MatchCandidate?
@@ -118,7 +115,7 @@ public enum WakeWordGate {
}
guard let best else { return nil }
let command = self.commandText(transcript: transcript, segments: segments, triggerEndTime: best.triggerEnd)
let command = commandText(transcript: transcript, segments: segments, triggerEndTime: best.triggerEnd)
.trimmingCharacters(in: Self.whitespaceAndPunctuation)
guard command.count >= config.minCommandLength else { return nil }
return WakeWordGateMatch(
@@ -148,7 +145,7 @@ public enum WakeWordGate {
guard !text.isEmpty else { return false }
let normalized = text.lowercased()
for trigger in triggers {
let token = trigger.trimmingCharacters(in: self.whitespaceAndPunctuation).lowercased()
let token = trigger.trimmingCharacters(in: whitespaceAndPunctuation).lowercased()
if token.isEmpty { continue }
if normalized.contains(token) { return true }
}
@@ -158,11 +155,11 @@ public enum WakeWordGate {
public static func stripWake(text: String, triggers: [String]) -> String {
var out = text
for trigger in triggers {
let token = trigger.trimmingCharacters(in: self.whitespaceAndPunctuation)
let token = trigger.trimmingCharacters(in: whitespaceAndPunctuation)
guard !token.isEmpty else { continue }
out = out.replacingOccurrences(of: token, with: "", options: [.caseInsensitive])
}
return out.trimmingCharacters(in: self.whitespaceAndPunctuation)
return out.trimmingCharacters(in: whitespaceAndPunctuation)
}
private static func normalizeTriggers(_ triggers: [String]) -> [TriggerTokens] {
@@ -170,7 +167,7 @@ public enum WakeWordGate {
for trigger in triggers {
let tokens = trigger
.split(whereSeparator: { $0.isWhitespace })
.map { self.normalizeToken(String($0)) }
.map { normalizeToken(String($0)) }
.filter { !$0.isEmpty }
if tokens.isEmpty { continue }
output.append(TriggerTokens(source: tokens.joined(separator: " "), tokens: tokens))
@@ -180,7 +177,7 @@ public enum WakeWordGate {
private static func normalizeSegments(_ segments: [WakeWordSegment]) -> [Token] {
segments.compactMap { segment in
let normalized = self.normalizeToken(segment.text)
let normalized = normalizeToken(segment.text)
guard !normalized.isEmpty else { return nil }
return Token(
normalized: normalized,
@@ -193,7 +190,7 @@ public enum WakeWordGate {
private static func normalizeToken(_ token: String) -> String {
token
.trimmingCharacters(in: self.whitespaceAndPunctuation)
.trimmingCharacters(in: whitespaceAndPunctuation)
.lowercased()
}

View File

@@ -5,7 +5,6 @@ import Speech
import Swabble
@MainActor
@available(macOS 26.0, *)
struct TranscribeCommand: ParsableCommand {
@Argument(help: "Path to audio/video file") var inputFile: String = ""
@Option(name: .long("locale"), help: "Locale identifier", parsing: .singleValue) var locale: String = Locale.current

View File

@@ -1,9 +1,9 @@
import Foundation
import SwabbleKit
import XCTest
import Testing
final class WakeWordGateTests: XCTestCase {
func testMatchRequiresGapAfterTrigger() {
@Suite struct WakeWordGateTests {
@Test func matchRequiresGapAfterTrigger() {
let transcript = "hey clawd do thing"
let segments = makeSegments(
transcript: transcript,
@@ -14,10 +14,10 @@ final class WakeWordGateTests: XCTestCase {
("thing", 0.5, 0.1),
])
let config = WakeWordGateConfig(triggers: ["clawd"], minPostTriggerGap: 0.3)
XCTAssertNil(WakeWordGate.match(transcript: transcript, segments: segments, config: config))
#expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config) == nil)
}
func testMatchAllowsGapAndExtractsCommand() {
@Test func matchAllowsGapAndExtractsCommand() {
let transcript = "hey clawd do thing"
let segments = makeSegments(
transcript: transcript,
@@ -29,10 +29,10 @@ final class WakeWordGateTests: XCTestCase {
])
let config = WakeWordGateConfig(triggers: ["clawd"], minPostTriggerGap: 0.3)
let match = WakeWordGate.match(transcript: transcript, segments: segments, config: config)
XCTAssertEqual(match?.command, "do thing")
#expect(match?.command == "do thing")
}
func testMatchHandlesMultiWordTriggers() {
@Test func matchHandlesMultiWordTriggers() {
let transcript = "hey clawd do it"
let segments = makeSegments(
transcript: transcript,
@@ -44,10 +44,10 @@ final class WakeWordGateTests: XCTestCase {
])
let config = WakeWordGateConfig(triggers: ["hey clawd"], minPostTriggerGap: 0.3)
let match = WakeWordGate.match(transcript: transcript, segments: segments, config: config)
XCTAssertEqual(match?.command, "do it")
#expect(match?.command == "do it")
}
func testMatchPrefersMostSpecificTriggerWhenOverlapping() {
@Test func matchPrefersMostSpecificTriggerWhenOverlapping() {
let transcript = "hey clawd do it"
let segments = makeSegments(
transcript: transcript,
@@ -59,10 +59,10 @@ final class WakeWordGateTests: XCTestCase {
])
let config = WakeWordGateConfig(triggers: ["clawd", "hey clawd"], minPostTriggerGap: 0.3)
let match = WakeWordGate.match(transcript: transcript, segments: segments, config: config)
XCTAssertEqual(match?.trigger, "hey clawd")
#expect(match?.trigger == "hey clawd")
}
func testCommandTextHandlesForeignRangeIndices() {
@Test func commandTextHandlesForeignRangeIndices() {
let transcript = "hey clawd do thing"
let other = "do thing"
let foreignRange = other.range(of: "do")
@@ -78,7 +78,7 @@ final class WakeWordGateTests: XCTestCase {
segments: segments,
triggerEndTime: 0.3)
XCTAssertEqual(command, "do thing")
#expect(command == "do thing")
}
}

View File

@@ -1,22 +1,23 @@
import Foundation
import Testing
@testable import Swabble
import XCTest
final class ConfigTests: XCTestCase {
func testConfigRoundTrip() throws {
var cfg = SwabbleConfig()
cfg.wake.word = "robot"
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".json")
defer { try? FileManager.default.removeItem(at: url) }
@Test
func configRoundTrip() throws {
var cfg = SwabbleConfig()
cfg.wake.word = "robot"
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".json")
defer { try? FileManager.default.removeItem(at: url) }
try ConfigLoader.save(cfg, at: url)
let loaded = try ConfigLoader.load(at: url)
XCTAssertEqual(loaded.wake.word, "robot")
XCTAssertTrue(loaded.hook.prefix.contains("Voice swabble"))
}
try ConfigLoader.save(cfg, at: url)
let loaded = try ConfigLoader.load(at: url)
#expect(loaded.wake.word == "robot")
#expect(loaded.hook.prefix.contains("Voice swabble"))
}
func testConfigMissingThrows() {
XCTAssertThrowsError(
try ConfigLoader.load(at: FileManager.default.temporaryDirectory.appendingPathComponent("nope.json")))
@Test
func configMissingThrows() {
#expect(throws: ConfigError.missingConfig) {
_ = try ConfigLoader.load(at: FileManager.default.temporaryDirectory.appendingPathComponent("nope.json"))
}
}

View File

@@ -2,241 +2,6 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>OpenClaw</title>
<item>
<title>2026.4.26</title>
<pubDate>Tue, 28 Apr 2026 02:40:27 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026042690</sparkle:version>
<sparkle:shortVersionString>2026.4.26</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.4.26</h2>
<h3>Changes</h3>
<ul>
<li>Channels/QQBot: add full group chat support (history tracking, @-mention gating, activation modes, per-group config, FIFO message queue with deliver debounce), C2C <code>stream_messages</code> streaming with a <code>StreamingController</code> lifecycle manager, unified <code>sendMedia</code> with chunked upload for large files, and refactor the engine into pipeline stages, focused outbound submodules, builtin slash-command modules, and explicit DI ports via <code>createEngineAdapters()</code>. (#70624) Thanks @cxyhhhhh.</li>
<li>Channels/Yuanbao: register the Tencent Yuanbao external channel plugin (<code>openclaw-plugin-yuanbao</code>) in the official channel catalog, contract suites, and community plugin docs, with a new <code>docs/channels/yuanbao.md</code> quick-start guide for WebSocket bot DMs and group chats. (#72756) Thanks @loongfay.</li>
<li>Control UI/Talk: add a generic browser realtime transport contract, Google Live browser Talk sessions with constrained ephemeral tokens, and a Gateway relay for backend-only realtime voice plugins. Thanks @VACInc.</li>
<li>CLI/models: route provider-filtered model listing through an explicit source plan so user config, installed manifest rows, Provider Index previews, and scoped runtime fallbacks keep a stable authority order without adding another catalog cache. Thanks @shakkernerd.</li>
<li>Providers: add Cerebras as a bundled plugin with onboarding, static model catalog, docs, and manifest-owned endpoint metadata.</li>
<li>Memory/OpenAI-compatible: add optional <code>memorySearch.inputType</code>, <code>queryInputType</code>, and <code>documentInputType</code> config for asymmetric embedding endpoints, including direct query embeddings and provider batch indexing. Carries forward #63313 and #60727. Thanks @HOYALIM and @prospect1314521.</li>
<li>Ollama/memory: add model-specific retrieval query prefixes for <code>nomic-embed-text</code>, <code>qwen3-embedding</code>, and <code>mxbai-embed-large</code> memory-search queries while leaving document batches unchanged. Carries forward #45013. Thanks @laolin5564.</li>
<li>Plugins/providers: move pre-runtime model-id normalization, endpoint host metadata, OpenAI-compatible request-family hints, model-catalog aliases/suppressions, OpenAI stale Spark suppression, and reusable startup metadata snapshots into plugin manifests so core no longer carries bundled-provider routing tables or repeated manifest rebuilds. Thanks @shakkernerd.</li>
<li>Plugins/config: deprecate direct plugin config load/write helpers in favor of passed runtime snapshots plus transactional mutation helpers with explicit restart follow-up policy, scanner guardrails, runtime warnings, and revision-based cache invalidation.</li>
<li>Plugins/install: allow <code>OPENCLAW_PLUGIN_STAGE_DIR</code> to contain layered runtime-dependency roots, resolving read-only preinstalled deps before installing missing deps into the final writable root. Fixes #72396. Thanks @liorb-mountapps.</li>
<li>Control UI: add a raw config pending-changes diff panel that parses JSON5, redacts sensitive values until reveal, and avoids fake raw-edit callbacks when opening the panel. Refs #39831; supersedes #48621 and #46654. Thanks @JiajunBernoulli and @BunsDev.</li>
<li>Control UI: polish the quick settings dashboard grid so common cards align across desktop, tablet, and mobile layouts without wasting horizontal space. Thanks @BunsDev.</li>
<li>Matrix/E2EE: add <code>openclaw matrix encryption setup</code> to enable Matrix encryption, bootstrap recovery, and print verification status from one setup flow. Thanks @gumadeiras.</li>
<li>Agents/compaction: add an opt-in <code>agents.defaults.compaction.maxActiveTranscriptBytes</code> preflight trigger that runs normal local compaction when the active JSONL grows too large, requiring transcript rotation so successful compaction moves future turns onto a smaller successor file instead of raw byte-splitting history. Thanks @vincentkoc.</li>
<li>CLI/migration: add <code>openclaw migrate</code> with plan, dry-run, JSON, pre-migration backup, onboarding detection, archive-only reports, a Claude Code/Desktop importer, and a Hermes importer for configuration, memory/plugin hints, model providers, MCP servers, skills, commands, and supported credentials. Thanks @vincentkoc and @NousResearch.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Agents/LSP: terminate bundled stdio LSP process trees during runtime disposal and Gateway shutdown, so nested children such as <code>tsserver</code> do not survive stop or restart. Fixes #72357. Thanks @ai-hpc and @bittoby.</li>
<li>Gateway/device tokens: stop echoing rotated bearer tokens from shared/admin <code>device.token.rotate</code> responses while preserving the same-device token handoff needed by token-only clients before reconnect. (#66773) Thanks @MoerAI.</li>
<li>Control UI/Talk: keep Google Live browser sessions on the WebSocket transport instead of falling back to WebRTC, validate browser Google Live WebSocket endpoints, cap Gateway relay sessions per browser connection, and remove stale browser-native voice buttons that did not use the configured Talk/TTS provider. Thanks @BunsDev.</li>
<li>Gateway/startup: reuse config snapshot plugin manifests for startup auto-enable, config validation, and plugin bootstrap planning, including authored source config and disabled setup-probe handling, so restrictive allowlists avoid duplicate manifest/config passes during boot. Thanks @shakkernerd.</li>
<li>Agents/subagents: enforce <code>subagents.allowAgents</code> for explicit same-agent <code>sessions_spawn(agentId=...)</code> calls instead of auto-allowing requester self-targets. Fixes #72827. Thanks @oiGaDio.</li>
<li>ACP/sessions_spawn: let explicit <code>sessions_spawn(runtime="acp")</code> bootstrap turns run while <code>acp.dispatch.enabled=false</code> still blocks automatic ACP thread dispatch. Fixes #63591. Thanks @moeedahmed.</li>
<li>CLI/update: install npm global updates into a verified temporary prefix before swapping the package tree into place, preventing mixed old/new installs and stale packaged files from breaking <code>openclaw update</code> verification. Thanks @shakkernerd.</li>
<li>Gateway: skip CLI startup self-respawn for foreground gateway runs so low-memory Linux/Node 24 hosts start through the same path as direct <code>dist/index.js</code> without hanging before logs. Fixes #72720. Thanks @sign-2025.</li>
<li>Google Meet: route local Chrome joins through OpenClaw browser control, grant Meet media permissions, pin local Chrome audio defaults to <code>BlackHole 2ch</code>, and use the configured OpenClaw browser profile so joined agents no longer show <code>Permission needed</code> or use raw/default Chrome state. Thanks @DougButdorf and @oromeis.</li>
<li>Plugins/discovery: follow symlinked plugin directories in global and workspace plugin roots while keeping broken links ignored and existing package safety checks in place. Fixes #36754; carries forward #72695 and #63206. Thanks @Quackstro, @ming1523, and @xsfX20.</li>
<li>Plugins/install: skip test files and directories during install security scans while still force-scanning declared runtime entrypoints, so packaged test mocks no longer block plugin installs. Fixes #66840; carries forward #67050. Thanks @saurabhjain1592 and @Magicray1217.</li>
<li>Plugins/install: allow exact package-manager peer links back to the trusted OpenClaw host package during install security scans while continuing to block spoofed or nested escaping <code>node_modules</code> symlinks. Carries forward #70819. Thanks @fgabelmannjr.</li>
<li>Plugins/install: resolve plugin install destinations from the active profile state dir across CLI, ClawHub, marketplace, local path, and channel setup installs, so <code>openclaw --profile <name> plugins install ...</code> no longer writes into the default profile. Fixes #69960; carries forward #69971. Thanks @FrancisLyman and @Sanjays2402.</li>
<li>Plugins/registry: suppress duplicate-plugin startup warnings when a tracked npm-installed plugin intentionally overrides the bundled plugin with the same id. Carries forward #48673. Thanks @abdushsk.</li>
<li>Plugins/startup: reuse canonical realpath lookups throughout each plugin discovery pass, including package and manifest boundary checks, so Windows npm-global startups no longer repeat expensive path resolution for the same plugin roots. Fixes #65733. Thanks @welfo-beo.</li>
<li>Gateway/proxy: pass <code>ALL_PROXY</code> / <code>all_proxy</code> into the global Undici env-proxy dispatcher and provider proxy-fetch helper while keeping SSRF trusted-proxy auto-upgrade on <code>HTTP_PROXY</code> / <code>HTTPS_PROXY</code> only, so gateway/provider calls honor all-proxy setups without weakening guarded fetches. Fixes #43821; carries forward #43919. Thanks @RickyTong1.</li>
<li>Reply/link understanding: keep media and link preprocessing on stable runtime entrypoints and continue with raw message content if optional enrichment fails, so URL-bearing messages are no longer dropped after stale runtime chunk upgrades. Fixes #68466. Thanks @songshikang0111.</li>
<li>Discord: persist routed model-picker overrides when the hidden <code>/model</code> dispatch succeeds but the bound thread session store is still stale, including LM Studio suffixed model ids. Carries forward #61473. Thanks @Nanako0129.</li>
<li>Nodes/CLI: add <code>openclaw nodes remove --node <id|name|ip></code> and <code>node.pair.remove</code> so stale gateway-owned node pairing records can be cleaned without hand-editing state files.</li>
<li>Gateway: include the connecting client and fresh presence version in the initial <code>hello-ok</code> snapshot, so clients no longer need a follow-up event before seeing themselves online.</li>
<li>Docker: install the CA certificate bundle in the slim runtime image so HTTPS calls from containerized gateways no longer fail TLS setup after the <code>bookworm-slim</code> base switch. Fixes #72787. Thanks @ryuhaneul.</li>
<li>Providers/OpenRouter: remove retired Hunter Alpha and Healer Alpha static catalog rows and disable proxy reasoning injection for stale Hunter Alpha configs, so replies are not hidden when OpenRouter returns answer text in reasoning fields. Fixes #43942. Thanks @EvanDataForge.</li>
<li>Providers/reasoning: let Groq and LM Studio declare provider-native reasoning effort values, so Qwen thinking models receive <code>none</code>/<code>default</code> or <code>off</code>/<code>on</code> instead of OpenAI-only <code>low</code>/<code>medium</code> values. Fixes #32638. Thanks @Aqu1bp, @mgoulart, @Norpps, and @BSTail.</li>
<li>Local models: default custom providers with only <code>baseUrl</code> to the Chat Completions adapter and trust loopback model requests automatically, so local OpenAI-compatible proxies receive <code>/v1/chat/completions</code> without timing out. Fixes #40024. Thanks @parachuteshe.</li>
<li>Channels/message tool: surface Discord, Slack, and Mattermost <code>user:</code>/<code>channel:</code> target syntax in the shared message target schema and Discord ambiguity errors, so DM sends by numeric id stop burning retries before finding <code>user:<id></code>. Fixes #72401. Thanks @garyd9, @hclsys, and @praveen9354.</li>
<li>Agents/tools: scope tool-loop detection history to the active run when available, so scheduled heartbeat cycles no longer inherit stale repeated-call counts from previous runs. Fixes #40144. Thanks @mattbrown319.</li>
<li>Agents/subagents: preserve requester delivery for completion announces across different channel accounts, keep same-channel thread completions routed to the child thread, and fail closed instead of guessing a child binding when requester conversation signal is missing. Thanks @sfuminya and @suyua9.</li>
<li>Agents/status: persist the post-compaction token estimate from auto-compaction when providers omit usage metadata, so <code>/status</code> and session lists keep showing fresh context usage after compaction. Fixes #67667; carries forward #72822. Thanks @Jimmy-xuzimo and @skylight-9.</li>
<li>Control UI: show loading, reload, and retry states when a lazy dashboard panel cannot load after an upgrade, so the Logs tab no longer appears blank on stale browser bundles. Fixes #72450. Thanks @sobergou.</li>
<li>Gateway/plugins: start the Gateway in degraded mode when a single plugin entry has invalid schema config, and let <code>openclaw doctor --fix</code> quarantine that plugin config instead of crash-looping every channel. Fixes #62976 and #70371. Thanks @Doraemon-Claw and @pksidekyk.</li>
<li>Agents/plugins: skip malformed plugin tools with missing schema objects and report plugin diagnostics, so one broken tool no longer crashes Anthropic agent runs. Fixes #69423. Thanks @jmnickels.</li>
<li>Agents/reasoning: recover fully wrapped unclosed <code><think></code> replies that would otherwise sanitize to empty text while keeping strict stripping for closed reasoning blocks and unclosed tails after visible text. Fixes #37696; supersedes #51915. Thanks @druide67 and @okuyam2y.</li>
<li>Control UI/Gateway: bind WebChat handshakes to their active socket and reject post-close server registrations, so aborted connects no longer leave zombie clients or misleading duplicate WebSocket connection logs. Fixes #72753. Thanks @LumenFromTheFuture.</li>
<li>Agents/fallback: split ambiguous provider failures into <code>empty_response</code>, <code>no_error_details</code>, and <code>unclassified</code>, and add flat fallback-step fields to structured fallback logs so primary-model failures stay visible when later fallbacks also fail. Fixes #71922; refs #71744. Thanks @andyk-ms and @nikolaykazakovvs-ux.</li>
<li>Plugins/Windows: normalize Windows absolute paths before handing bundled plugin modules to Jiti, so Feishu/Lark message sending no longer fails with unsupported <code>c:</code> ESM loader URLs. Fixes #72783. Thanks @jackychen-png.</li>
<li>CLI/doctor: run bundled plugin runtime-dependency repairs through the async npm installer with spinner/line progress and heartbeat updates, so long <code>openclaw doctor --fix</code> installs no longer look hung in TTY or piped output. Fixes #72775. Thanks @dfpalhano.</li>
<li>Feishu/Windows: normalize bundled channel sidecar loads before Jiti evaluates them, so Feishu outbound sends no longer fail with raw <code>C:</code> ESM loader errors on Windows. Fixes #72783. Thanks @jackychen-png.</li>
<li>Agents/tools: ignore volatile <code>exec</code> runtime metadata when comparing tool-loop outcomes, so enabled loop detection can stop repeated identical shell-command results instead of resetting on duration, PID, session, or cwd changes. Fixes #34574; supersedes #41502. Thanks @gucasbrg and @Zcg2021.</li>
<li>Agents/fallback: classify internal live-session model switch conflicts as unknown fallback failures instead of provider overloads, preventing local vLLM endpoints from receiving misleading overloaded cooldowns. Refs #63229. Thanks @clawdia-lobster.</li>
<li>Discord: let thread sessions inherit the parent channel's session-level <code>/model</code> override as a model-only fallback without enabling parent transcript inheritance. Fixes #72755. Thanks @solavrc.</li>
<li>Gateway/plugins: skip stale configured channels whose matching plugin is no longer discoverable, point cleanup at <code>openclaw doctor --fix</code>, and keep unrelated channel typos fatal so one missing channel plugin no longer crash-loops the Gateway. Fixes #53311. Thanks @futhgar.</li>
<li>Control UI: keep session-specific assistant identity loads authoritative after WebSocket connect, so non-main agent chat sessions do not show the main agent name in the header after bootstrap refreshes. Fixes #72776. Thanks @rockytian-top.</li>
<li>Agents/Qwen: preserve exact custom <code>modelstudio</code> provider configs with foreign <code>api</code> owners so explicit OpenAI-compatible Model Studio endpoints no longer get normalized into the bundled Qwen plugin path. Fixes #64483. Thanks @FiredMosquito831.</li>
<li>MCP/bundle-mcp: normalize CLI-native <code>type: "http"</code> MCP server entries to OpenClaw <code>transport: "streamable-http"</code> on save, repair existing configs with doctor, and keep embedded Pi from falling back to legacy SSE GET-first startup for those servers. Fixes #72757. Thanks @Studioscale.</li>
<li>OpenCode: expose Anthropic Opus/Sonnet 4.x thinking levels for proxied Claude models, so <code>/think xhigh</code>, <code>/think adaptive</code>, and <code>/think max</code> validate consistently with the direct Anthropic provider. Fixes #72729. Thanks @haishmg and @aaajiao.</li>
<li>Media-understanding/audio: migrate deprecated <code>{input}</code> placeholders in legacy <code>audio.transcription.command</code> configs to <code>{{MediaPath}}</code>, so custom audio transcribers no longer receive the literal placeholder after doctor repair. Fixes #72760. Thanks @krisfanue3-hash.</li>
<li>Ollama/WSL2: warn when GPU-backed WSL2 installs combine CUDA visibility with an autostarting <code>ollama.service</code> using <code>Restart=always</code>, and document the systemd, <code>.wslconfig</code>, and keep-alive mitigation for crash loops. Carries forward #61022; fixes #61185. Thanks @yhyatt.</li>
<li>Ollama/onboarding: de-dupe suggested bare local models against installed <code>:latest</code> tags and skip redundant pulls, so setup shows the installed model once and no longer says it is downloading an already available model. Fixes #68952. Thanks @tleyden.</li>
<li>Memory-core/doctor: keep <code>doctor.memory.status</code> on the cached path by default and only run live embedding pings for explicit deep probes, preventing slow local embedding backends from blocking Gateway status checks. Fixes #71568. Thanks @apex-system.</li>
<li>Memory/QMD: group same-source collections into one QMD search invocation when the installed QMD supports multiple <code>-c</code> filters, while keeping older QMD builds on the per-collection fallback. Fixes #72484; supersedes #72485 and #69583. Thanks @BsnizND and @zeroaltitude.</li>
<li>Memory/QMD: accept QMD status vector-count variants such as <code>Vectors = 42</code>, <code>Vectors:42</code>, and <code>Vectors: 42 embedded</code>, so <code>memory status --deep</code> no longer reports embeddings unavailable for healthy QMD wrappers. Fixes #63652; carries forward #63678. Thanks @apoapostolov and @WarrenJones.</li>
<li>Memory/QMD: skip QMD vector status probes and embedding maintenance in lexical <code>searchMode: "search"</code>, so BM25-only QMD setups on ARM do not trigger llama.cpp/Vulkan builds during status checks or embed cycles. Fixes #59234 and #67113. Thanks @PrinceOfEgypt, @Vksh07, @Snipe76, @NomLom, @t4r3e2q1-commits, and @dmak.</li>
<li>Memory/QMD: report the live watcher dirty state in memory status, so changed QMD-backed memory files show as dirty until the queued sync finishes. Fixes #60244. Thanks @xinzf.</li>
<li>Compaction: skip oversized pre-compaction checkpoint snapshots and prune duplicate long user turns from compaction input and rotated successor transcripts, preventing retry storms from being preserved across checkpoint cycles. Fixes #72780. Thanks @SweetSophia.</li>
<li>Control UI/Cron: render cron job prompts and run summaries as sanitized markdown in the dashboard, with full-width block content, safer link clicks, and no duplicate error text when a failed run has no summary. Supersedes #48504. Thanks @garethdaine.</li>
<li>Control UI/Gateway: preserve WebChat client version labels across localhost, 127.0.0.1, and IPv6 loopback aliases on the same port, avoiding misleading <code>vcontrol-ui</code> connection logs while investigating duplicate-message reports. Refs #72753 and #72742. Thanks @LumenFromTheFuture and @allesgutefy.</li>
<li>Agents/reasoning: treat orphan closing reasoning tags with following answer text as a privacy boundary across delivery, history, streaming, and Control UI sanitizers so malformed local-model output cannot leak chain-of-thought text. Fixes #67092. Thanks @AnildoSilva.</li>
<li>Memory-core: run one-shot memory CLI commands through transient builtin and QMD managers so <code>memory index</code>, <code>memory status --index</code>, and <code>memory search</code> no longer start long-lived file watchers that can hit macOS <code>EMFILE</code> limits. Fixes #59101; carries forward #49851. Thanks @mbear469210-coder and @maoyuanxue.</li>
<li>Agents/ACP: ship the Claude ACP adapter with OpenClaw and require Claude result messages before idle can complete a prompt, preventing parent agents from waking early on long-running <code>sessions_spawn(runtime: "acp", agentId: "claude")</code> children. Fixes #72080. Thanks @siavash-saki and @iannwu.</li>
<li>CLI/tasks: route <code>tasks --json</code>, <code>tasks list --json</code>, and <code>tasks audit --json</code> through a lean JSON path so read-only task inspection no longer loads unrelated plugin/runtime command graphs. Fixes #66238. Thanks @ChuckChambers.</li>
<li>Memory-core: re-resolve the active runtime config whenever <code>memory_search</code> or <code>memory_get</code> executes, so provider changes made by <code>config.patch</code> stop leaving stale embedding backends behind in existing tool instances. Fixes #61098. Thanks @BradGroux and @Linux2010.</li>
<li>WebChat: keep bare <code>/new</code> and <code>/reset</code> startup instructions out of visible chat history while preserving <code>/reset <note></code> as user-visible transcript text. Fixes #72369. Thanks @collynes and @haishmg.</li>
<li>Tasks/memory: checkpoint and truncate SQLite WAL sidecars on a timer and before close for task, Task Flow, proxy capture, and builtin memory databases, bounding long-running gateway <code>*.sqlite-wal</code> growth. Fixes #72774. Thanks @dfpalhano.</li>
<li>CLI/doctor: remove dangling channel config, heartbeat targets, and channel model overrides when stale plugin repair removes a missing channel plugin, preventing Gateway boot loops after failed plugin reinstalls. Fixes #65293. Thanks @yidecode.</li>
<li>Control UI/Gateway: cache, coalesce, stale-refresh, and invalidate effective tool inventory on channel registry changes while reusing the gateway-bound plugin registry and avoiding model/auth discovery, so chat runs no longer stall Control UI requests on repeated plugin/model setup. Fixes #72365; supersedes #72558. Thanks @Gabiii2398 and @1yihui.</li>
<li>Channels/setup: treat bundled channel plugins as already bundled during <code>channels add</code> and onboarding, enabling them without writing redundant <code>plugins.load.paths</code> entries or path install records. Fixes #72740. Thanks @iCodePoet.</li>
<li>WhatsApp: honor gateway <code>HTTPS_PROXY</code> / <code>HTTP_PROXY</code> env vars for QR-login WebSocket connections, while respecting <code>NO_PROXY</code>, so proxied networks no longer fall back to direct <code>mmg.whatsapp.net</code> connections that time out with 408. Fixes #72547; supersedes #72692. Thanks @mebusw and @SymbolStar.</li>
<li>Bonjour: default mDNS advertisements to the system hostname when it is DNS-safe, avoiding <code>openclaw.local</code> probing conflicts and Gateway restart loops on hosts such as <code>Lobster</code> or <code>ubuntu</code>. Fixes #72355 and #72689; supersedes #72694. Thanks @mscheuerlein-bot, @gcusms, @moyuwuhen601, @pavan987, @zml-0912, @hhq365, and @SymbolStar.</li>
<li>Agents/OpenAI-compatible: retry replay-safe empty <code>stop</code> turns once for <code>openai-completions</code> endpoints, so transient empty local backend responses no longer surface as “Agent couldn't generate a response” when a continuation succeeds, and restore <code>openclaw agent --model</code> for one-shot CLI runs. Fixes #72751. Thanks @moooV252.</li>
<li>Git hooks: skip ignored staged paths when formatting and restaging pre-commit files, so merge commits no longer abort when <code>.gitignore</code> newly ignores staged merged content. Fixes #72744. Thanks @100yenadmin.</li>
<li>Memory-core/dreaming: add a supported <code>dreaming.model</code> knob for Dream Diary narrative subagents, wired through phase config and the existing plugin subagent model-override trust gate. Refs #65963. Thanks @esqandil and @mjamiv.</li>
<li>Agents/Anthropic: remove trailing assistant prefill payloads when extended thinking is enabled, so Opus 4.7/Sonnet 4.6 requests do not fail Anthropic's user-final-turn validation. Fixes #72739. Thanks @superandylin.</li>
<li>Agents/vLLM/Qwen: add plugin-owned Qwen thinking controls for vLLM chat-template kwargs and DashScope-style top-level <code>enable_thinking</code> flags, including preserved thinking for agent loops. Fixes #72329. Thanks @stavrostzagadouris.</li>
<li>Memory-core/dreaming: treat request-scoped narrative fallback as expected, skip session cleanup when no subagent run was created, and remove duplicate phase-level cleanup so fallback no longer emits warning noise. Fixes #67152. Thanks @jsompis.</li>
<li>Agents/exec: apply configured <code>tools.exec.timeoutSec</code> to background, <code>yieldMs</code>, and node <code>system.run</code> commands when no per-call timeout is set, preventing auto-backgrounded and remote node commands from running indefinitely. Fixes #67600; supersedes #67603. Thanks @dlmpx and @kagura-agent.</li>
<li>Config/doctor: stop masking unknown-key validation diagnostics such as <code>agents.defaults.llm</code>, and have <code>openclaw doctor --fix</code> remove the retired <code>agents.defaults.llm</code> timeout block. Thanks @aidiffuser.</li>
<li>CLI/startup: keep the built pre-dispatch CLI graph free of package-level imports and extend packaged CLI smoke coverage to onboard and doctor help paths, preventing missing runtime dependencies such as tslog from killing onboarding before repair code can run. Fixes #63024. Thanks @hu19940121.</li>
<li>CLI/plugins: preserve unversioned ClawHub install specs so <code>plugins update</code> can follow newer ClawHub releases instead of pinning to the initially resolved version. Fixes #63010; supersedes #58426. Thanks @kangsen1234 and @robinspt.</li>
<li>Memory-core/subagents: tag plugin-created subagent sessions with their plugin owner so dreaming narrative cleanup can delete its own ephemeral sessions without granting broad admin session deletion. Fixes #72712. Thanks @BSG2000.</li>
<li>Gateway/models: move local-provider pricing opt-outs, OpenRouter/LiteLLM aliases, and proxy passthrough pricing lookup into plugin manifest metadata so core no longer carries extension-specific pricing tables.</li>
<li>CLI/update: honor <code>OPENCLAW_NO_AUTO_UPDATE=1</code> as a gateway startup kill-switch for configured background package auto-updates, so operators can hold a deliberate downgrade during incident recovery without editing config first. Fixes #72715. Thanks @Xivi08.</li>
<li>Agents/Claude CLI: force live-session launches to include <code>--output-format stream-json</code> whenever OpenClaw adds <code>--input-format stream-json</code>, so new Claude CLI sessions no longer fail immediately while reusable sessions keep working. Fixes #72206. Thanks @kwangwonkoh and @Xivi08.</li>
<li>CLI/plugins: accept ClawHub plugin API wildcard ranges such as <code>*</code> without rejecting compatible plugin installs, while still requiring a valid runtime API version. Fixes #56446; supersedes #56466. Thanks @darconada and @claygeo.</li>
<li>CLI/plugins: add an explicit <code>npm:<package></code> install prefix that skips ClawHub lookup for known npm packages while keeping bare package specs ClawHub-first. Fixes #55805; supersedes #54377. Thanks @Zeoy2020 and @vagusX.</li>
<li>CLI/plugins: let config-gated bundled plugins install without persisting invalid placeholder config entries, so install/uninstall sweeps can cover plugins such as memory-lancedb before the user configures credentials. Thanks @vincentkoc.</li>
<li>CLI/plugins: reject malformed ClawHub plugin specs with trailing <code>@</code> before registry lookup, so empty-version typos report as invalid specs instead of package-not-found errors. Fixes #56579; supersedes #56582. Thanks @Kansodata.</li>
<li>Agents/sessions: acquire the session write lock only after cold bootstrap, plugin, and tool setup so fallback runs are not blocked by stalled pre-model startup work.</li>
<li>Browser/plugins: auto-start the bundled browser plugin when root <code>browser</code> config is present, including restrictive plugin allowlists, and ignore stale persisted plugin registries whose package paths no longer exist.</li>
<li>Browser: circuit-break repeated managed Chrome launch failures per profile so browser requests stop spawning Chromium indefinitely when CDP cannot start. Fixes #64271. Thanks @TheophilusChinomona.</li>
<li>Gateway/models: skip external OpenRouter and LiteLLM pricing refreshes for local/self-hosted model endpoints so startup does not wait on remote pricing catalogs for local-only Ollama, vLLM, and compatible providers.</li>
<li>CLI/plugins: stop security-blocked plugin installs from retrying as hook packs, so normal plugin packages report the scanner failure without a misleading "not a valid hook pack" follow-up. Fixes #61175; supersedes #64102. Thanks @KonsultDigital and @ziyincody.</li>
<li>Agents/Anthropic: strip stale trailing assistant prefill turns from outbound replay so context-engine short circuits cannot send unsupported assistant-prefill payloads to provider APIs. Fixes #72556. Thanks @Veda-openclaw.</li>
<li>Agents/Google: strip stale trailing assistant/model prefill turns from Gemini outbound replay so Google Generative AI requests end with a user turn or function response. Follow-up to #72556. Thanks @Veda-openclaw.</li>
<li>Control UI/Dreaming: require explicit confirmation before applying restart-impacting Dreaming mode changes, with restart warning copy and loading feedback. Fixes #63804. (#63807) Thanks @bbddbb1.</li>
<li>CLI/agent: mark Gateway-to-embedded fallback runs with <code>meta.transport: "embedded"</code> and <code>meta.fallbackFrom: "gateway"</code> in JSON output, and make the terminal diagnostic explicit so scripts and operators can distinguish fallback runs from Gateway runs. Fixes #71416. Thanks @amknight.</li>
<li>Agents/tools: normalize <code>null</code> or missing tool-call arguments to <code>{}</code> for parameterless object schemas before Pi validation, so empty-argument tools run instead of failing argument validation. Fixes #72587. Thanks @amknight.</li>
<li>Agents/subagents: clear active embedded-run state before terminal lifecycle events so post-completion cleanup no longer treats finished child runs as still active and skips archive or announcement bookkeeping. (#70187) Thanks @amknight.</li>
<li>CLI/update: keep the automatic post-update completion refresh on the core-command tree so it no longer stages bundled plugin runtime deps before the Gateway restart path, avoiding <code>.24</code> update hangs and 1006 disconnect cascades. Fixes #72665. Thanks @sakalaboator and @He-Pin.</li>
<li>Control UI: make explicit Reload Config actions discard stale local config edits while passive refreshes and failed-save recovery keep pending drafts intact. Fixes #40352; carries forward #40443. Thanks @realmikechong-dotcom.</li>
<li>Agents/Bedrock: stop heartbeat runs from persisting blank user transcript turns and repair existing blank user text messages before replay, preventing AWS Bedrock <code>ContentBlock</code> blank-text validation failures. Fixes #72640 and #72622. Thanks @goldzulu.</li>
<li>Agents/LM Studio: promote standalone bracketed local-model tool requests into registered tool calls and hide unsupported bracket blocks from visible replies, so MemPalace MCP lookups do not print raw <code>[tool]</code> JSON scaffolding in chat. Fixes #66178. Thanks @detroit357.</li>
<li>Local models: warn when an assistant reply looks like a tool call but the provider emitted plain text instead of a structured tool invocation, making fake/non-executed tool calls visible in logs. Fixes #51332. Thanks @emilclaw.</li>
<li>Local models: accept persisted non-secret local auth markers for private-LAN custom OpenAI-compatible providers, so LAN Ollama configs no longer fail with missing auth when <code>ollama-local</code> is saved as the key. Fixes #49736. Thanks @charles-zh.</li>
<li>TUI/local models: treat visible gateway client labels such as <code>openclaw-tui</code> as the current requester session for session-aware tools, so Ollama tool calls no longer fail by resolving the UI label as a session id. Fixes #66391. Thanks @kickingzebra.</li>
<li>Local models: route self-hosted OpenAI-compatible model discovery through the guarded fetch path pinned to the configured host, covering vLLM and SGLang setup without reopening local/LAN SSRF probes. Supersedes #46359. Thanks @cdxiaodong.</li>
<li>Local models: classify terminated, reset, closed, timeout, and aborted model-call failures and attach a process memory snapshot to the diagnostic event, making LM Studio/Ollama RAM-pressure failures easier to prove from stability bundles. Refs #65551. Thanks @BigWiLLi111.</li>
<li>Local models: pass configured provider request timeouts through OpenAI SDK transports and the model idle watchdog so long-running local or custom OpenAI-compatible streams use one timeout knob instead of hitting the SDK's 10-minute default or the 120s idle default. Fixes #63663. Thanks @aidiffuser.</li>
<li>LM Studio: trust configured LM Studio loopback, LAN, and tailnet endpoints for guarded model requests by default, preserving explicit private-network opt-outs. Refs #60994. Thanks @tnowakow.</li>
<li>Docker/setup: route Docker onboarding defaults for host-side LM Studio and Ollama through <code>host.docker.internal</code> and add the Linux host-gateway mapping to the bundled Compose file, so containerized gateways can reach local providers without using container loopback. Fixes #68684; supersedes #68702. Thanks @safrano9999 and @skolez.</li>
<li>Agents/LM Studio: strip prior-turn Gemma 4 reasoning from OpenAI-compatible replay while preserving active tool-call continuation reasoning. Fixes #68704. Thanks @chip-snomo and @Kailigithub.</li>
<li>LM Studio: allow interactive onboarding to leave the API key blank for unauthenticated local servers, using local synthetic auth while clearing stale LM Studio auth profiles. Fixes #66937. Thanks @olamedia.</li>
<li>Plugins/startup/registry: reuse a Gateway <code>PluginLookUpTable</code> and one manifest registry pass across startup plugin IDs, plugin loading, deferred channel reloads, model pricing, read-only channel defaults, capability/provider/media resolution, manifest contracts, extractors, web fallback discovery, owner maps, and cold provider-discovery caches, with new startup-trace timing/count metrics for installed-index, manifest, startup-plan, and owner-map work. Thanks @shakkernerd and @mcaxtr.</li>
<li>Mattermost: keep direct-message replies top-level by suppressing reply roots for DM delivery while preserving channel and group thread roots, and derive inbound chat kind from the trusted channel lookup instead of the websocket event channel type. Carries forward #60115, #55186, #72305, and #72659; refs #59758, #59981, #59791, and #57565. Thanks @vincentkoc, @jwchmodx, and @hnykda.</li>
<li>Docker: pre-create <code>/home/node/.openclaw</code> with node ownership and private permissions so first-run Docker Compose named volumes no longer fail startup with EACCES. (#48072, #63959; fixes #61279) Thanks @timoxue and @jeanibarz.</li>
<li>CLI/Gateway: treat local restart probe policy closes for connect, exact <code>device required</code>, pairing, and auth failures as Gateway reachability proof without accepting empty, broad standalone token/password/scope/role, or pair-substring 1008 close reasons. Fixes #48771; carries forward #48801; related #63491. Thanks @MarsDoge and @genoooool.</li>
<li>Feishu: send outgoing interactive reply payloads as native cards with clickable buttons while preserving text, media, and document-comment fallbacks. Fixes #13175 and #58298; carries forward #47891. Thanks @Horacehxw.</li>
<li>Process/Windows: decode command stdout and stderr from raw bytes with console-codepage awareness, while preserving valid UTF-8 output and multibyte characters split across chunks. Fixes #50519. Thanks @iready, @kevinten10, @zhangyongjie1997, @knightplat-blip, @heiqishi666, and @slepybear.</li>
<li>Bonjour/Windows: hide the bundled mDNS advertiser's Windows ARP shell probe so Gateway startup no longer flashes command-prompt windows. Fixes #70238. Thanks @alexandre-leng, @PratikRai0101, @infinitypacific, and @tomerpeled.</li>
<li>Agents/bootstrap: dedupe hook-injected bootstrap context files by workspace-relative path and store normalized resolved paths so duplicate relative and absolute hook paths no longer depend on the process cwd. (#59344; fixes #59319; related #56721, #56725, and #57587) Thanks @koen666.</li>
<li>Agents/bootstrap: refresh cached workspace bootstrap snapshots on long-lived main-session turns when <code>AGENTS.md</code>, <code>SOUL.md</code>, <code>MEMORY.md</code>, or <code>TOOLS.md</code> change on disk, while preserving unchanged snapshot identity through the workspace file cache. (#64871; related #43901, #26497, #28594, #30896) Thanks @aimqwest and @mikejuyoon.</li>
<li>macOS Gateway: detect installed-but-unloaded LaunchAgent split-brain states during status, doctor, and restart, and re-bootstrap launchd supervision before falling back to unmanaged listener restarts. Fixes #67335, #53475, and #71060; refs #58890, #60885, and #70801. Thanks @ze1tgeist88, @dafacto, and @vishutdhar.</li>
<li>Plugins/install: treat mirrored core logger dependencies as staged bundled runtime deps so packaged Gateway starts do not crash when the external plugin-runtime-deps root is missing <code>tslog</code>. Fixes #72228; supersedes #72493. Thanks @deepujain.</li>
<li>Build/plugins: preserve active bundled runtime-dependency staging temp directories owned by live build processes so overlapping postbuild runs no longer delete each other's staged deps mid-prune. Supersedes #72220. Thanks @VACInc.</li>
<li>Plugins/install: hide bundled runtime-dependency npm child windows on Windows across Gateway startup, postinstall, and packaged staging paths so Telegram/Anthropic dependency repair no longer flashes shell windows. Fixes #72315. Thanks @athuljayaram and @joshfeng.</li>
<li>Agents/Windows: normalize lazy agent runtime imports before Node ESM loading so Windows drive-letter <code>subagent-registry</code> runtime paths no longer fail every agent task with <code>ERR_UNSUPPORTED_ESM_URL_SCHEME</code>. Fixes #72636; carries forward #72716. Thanks @Andyz-CData and @xialonglee.</li>
<li>Plugins/Windows: normalize lazy plugin service override imports before Node ESM loading so drive-letter browser-control module paths no longer fail with <code>ERR_UNSUPPORTED_ESM_URL_SCHEME</code>. Fixes #72573; supersedes #72599 and #72582. Thanks @llzzww316, @feineryonah-byte, and @WuKongAI-CMU.</li>
<li>Browser/plugins: load <code>playwright-core</code> through the browser runtime shim so packaged installs can run Playwright actions from staged plugin runtime deps after doctor/startup repair. Fixes #72168; supersedes #72238. Thanks @zdg1110 and @yetval.</li>
<li>Plugins/install: stage bundled plugin runtime dependencies before Gateway startup, drain update restarts, and materialize plugin-owned root chunks in external mirrors so staged deps resolve under native ESM. Fixes #72058; supersedes #72084. Thanks @amnesia106 and @drvoss.</li>
<li>TTS/SecretRef: resolve <code>messages.tts.providers.*.apiKey</code> from the active runtime snapshot so SecretRef-backed MiniMax and other TTS provider keys work in runtime reply/audio paths. Fixes #68690. Thanks @joshavant.</li>
<li>Gateway/install: surface systemd user-bus recovery hints during Linux service activation and retry via the target user scope when <code>systemctl --user</code> reports no-medium bus failures, without letting stale <code>SUDO_USER</code> override <code>sudo -u</code> installs. Fixes #39673; refs #44417 and #63561. Thanks @Arbor4, @myrsu, @mssteuer, and @boyuaner.</li>
<li>CLI/nodes: make unfiltered <code>openclaw nodes list</code> prefer the effective paired-node view used by <code>nodes status</code> while preserving pending rows, pairing-scope fallback, terminal-safe table rendering, and paired JSON metadata. Fixes #46871; carries forward #65772 through the ProjectClownfish #72619 repair. Thanks @skainguyen1412.</li>
<li>CLI/startup: read generated startup metadata from the bundled <code>dist</code> layout before falling back to live help rendering, so root/browser help and channel-option bootstrap stay on the fast path. Thanks @vincentkoc.</li>
<li>Feishu/Lark: stop treating broadcast-only <code>@all</code>/<code>@_all</code> messages as bot mentions while preserving direct bot mentions, including messages that also include <code>@all</code>. Fixes #37706. Thanks @JosepLee.</li>
<li>CLI/help: treat positional <code>help</code> invocations like <code>openclaw channels help</code> as help paths for startup gating, avoiding model/auth warmup while preserving positional arguments such as <code>openclaw docs help</code>. Thanks @gumadeiras.</li>
<li>Web search: route plugin-scoped web_search SecretRefs through the active runtime config snapshot so provider execution receives resolved credentials across app/runtime paths, including <code>plugins.entries.brave.config.webSearch.apiKey</code>. Fixes #68690. Thanks @VACInc.</li>
<li>Voice Call: allow SecretRef-backed Twilio auth tokens and call-specific OpenAI/ElevenLabs TTS API keys through the plugin config surface. Fixes #68690. Thanks @joshavant.</li>
<li>Google Meet/Voice Call: clean stale chrome-node realtime bridges before rejoining, expose bridge inspection, tolerate transient node input pull failures, default Chrome command-pair audio to 24 kHz PCM16 while preserving legacy 8 kHz G.711 mu-law pairs, handle Gemini Live interruptions/VAD and function-response names correctly, route stateful <code>google_meet</code> tools through the gateway runtime, support <code>realtime.agentId</code>, and send non-blocking consult continuations before long tool-backed answers finish. Fixes #72371, #72525, #72523, #72440, and #72425; (#72372, #72524, #72381, #72441, #72189, #72426) Thanks @BsnizND and @VACInc.</li>
<li>Discord/media: keep incidental Markdown image badges in final replies as text unless a channel opts into Markdown-image media extraction, while preserving Telegram Markdown-image media replies and explicit <code>MEDIA:</code> attachments. Fixes #72642. Thanks @solavrc and @Bartok9.</li>
<li>Matrix/E2EE: stabilize recovery and broken-device QA flows while avoiding Matrix device-cleanup sync races that could leave shutdown-time crypto work running. Thanks @gumadeiras.</li>
<li>Cron: apply <code>cron.maxConcurrentRuns</code> to the nested isolated-agent lane, start isolated execution timeouts only after the runner enters that lane, keep legacy flat <code>jobs.json</code> rows loadable, invalidate stale pending runtime slots after schedule edits, and preserve due slots for formatting-only rewrites. Fixes #72707, #27996, #71607, and #41783; carries forward #71651. Thanks @kagura-agent, @xialonglee, @fagnersouza666, @ayanesakura, and @Hurray0.</li>
<li>Cron/delivery: classify isolated successes, quiet <code>NO_REPLY</code> turns, model/provider failures, execution denials, <code>--no-deliver</code> traces, skipped-job alerts, and verified delivery outcomes correctly so cron history, retries, and failure counters reflect what actually happened. Fixes #72732, #50170, #43604, #68452, #60846, #72210, and #67172; follow-up to #54188; carries forward #43631, #68453, #72219, and #67186. Thanks @zNatix, @pixeldyn, @ChickenEggRoll, @SPFAdvisors, @anyech, @slideshow-dingo, @hatemclawbot-collab, @xydigit-sj, @oc-gh-dr, @hclsys, and @1yihui.</li>
<li>Cron/routing: preserve direct Telegram thread/account IDs, explicit Discord <code>user:</code>/<code>channel:</code> delivery targets, and <code>session:<id></code> failure-destination routing so reminders, cron announcements, and failure alerts keep the intended recipient kind across direct and group chats. Fixes #44270; refs #62777; carries forward #44325, #44351, #44412, #72657, #68535, and #62798. Thanks @RunMintOn, @arkyu2077, @0xsline, @vincentkoc, @slideshow-dingo, @likewen-tech, and @neeravmakwana.</li>
<li>Subagents: keep the delegated task only in the subagent system prompt and send a short initial kickoff message, avoiding duplicate task tokens while preserving multiline task formatting. Fixes #72019; carries forward #72053. Thanks @Wizongod and @ly85206559.</li>
<li>Onboarding/GitHub Copilot: add manifest-owned <code>--github-copilot-token</code> support for non-interactive setup, including env fallback, tokenRef storage in ref mode, saved-profile reuse, and current Copilot default-model wiring. Refs #50002 and supersedes #50003. Thanks @scottgl9.</li>
<li>Gateway/install: add a validated <code>--wrapper</code>/<code>OPENCLAW_WRAPPER</code> service install path that persists executable LaunchAgent/systemd wrappers across forced reinstalls, updates, and doctor repairs instead of falling back to raw node/bun <code>ProgramArguments</code>. Fixes #69400. (#72445) Thanks @willtmc.</li>
<li>Plugins: fail plugin registration when loader-owned acceptance gates reject missing hook names or memory-only capability registration from non-memory plugins, surfacing the issue through plugin status and doctor instead of silently dropping the registration. Fixes #72459. Thanks @amknight.</li>
<li>macOS Gateway: write launchd services with a state-dir <code>WorkingDirectory</code>, use a durable state-dir temp path instead of freezing macOS session <code>TMPDIR</code>, create that temp directory before bootstrap, and label abort-shaped launchd exits as <code>SIGABRT/abort</code> in status output. Fixes #53679 and #70223; refs #71848. Thanks @dlturock, @stammi922, and @palladius.</li>
<li>Control UI/update: make <code>Update now</code> require a real gateway process replacement, report skipped/error update outcomes with stable reasons, and verify the running gateway version after restart so global installs cannot silently keep old code in memory. Fixes #62492; addresses #64892 and #63562. Thanks @IAMSamuelRodda.</li>
<li>Exec approvals: accept runtime-owned <code>source: "allow-always"</code> and <code>commandText</code> allowlist metadata in gateway and node approval-set payloads so Control UI round-trips no longer fail with <code>unexpected property 'source'</code>. Fixes #60000; carries forward #60064. Thanks @sd1471123, @sharkqwy, and @luoyanglang.</li>
<li>Exec/node: skip approval-plan preparation for full-trust <code>host=node</code> runs so interpreter and script commands no longer fail with <code>SYSTEM_RUN_DENIED: approval cannot safely bind</code> when effective policy is <code>security=full</code> and <code>ask=off</code>. Fixes #48457 and duplicate #69251. Thanks @ajtran303, @jaserNo1, @Blakeshannon, @lesliefag, and @AvIsBeastMC.</li>
<li>Exec/node: synthesize a local approval plan when a paired node advertises <code>system.run</code> without <code>system.run.prepare</code>, unblocking approval-required <code>host=node</code> exec on current macOS companion nodes while preserving remote prepare for node hosts that support it. Fixes #37591 and duplicate #66839; carries forward #69725. Thanks @soloclz.</li>
<li>Memory/QMD: prefer QMD's <code>--mask</code> collection pattern flag so root memory indexing stays scoped to <code>MEMORY.md</code> instead of widening to every markdown file in the workspace. Fixes #65480; supersedes #65481 and #66259. Thanks @ccage-simp, @Bortlesboat, @seank-com, and @crazyscience.</li>
<li>Memory/doctor: treat the specific <code>gateway timeout after ...</code> gateway memory probe result as inconclusive instead of reporting embeddings not ready, while preserving warnings for explicit failures. Fixes #44426; carries forward #46576 with the Greptile review feedback applied. Thanks Cengiz (@ghost).</li>
<li>Gateway/startup: defer QMD, core request handlers, setup wizard, CLI outbound senders, plugin HTTP routes, chat/session projection, node session runtime validation, embedded-run activity reads, MCP loopback server imports, channel runtime helpers, HTTP/canvas/plugin auth helpers, isolated cron imports, and hook dispatch parsing until their request or shutdown paths, while making plain <code>gateway status</code> use a parse-only config snapshot so no-plugin boots and status reads avoid broad runtime fanout. Thanks @vincentkoc.</li>
<li>Lobster/Gateway: memoize repeated Ajv schema compilation before loading the embedded Lobster runtime so scheduled workflows and <code>llm.invoke</code> loops stop growing gateway heap on content-identical schemas. Fixes #71148. Thanks @cmi525, @vsolaz, and @vincentkoc.</li>
<li>Codex harness: normalize cached input tokens before session/context accounting so prompt cache reads are not double-counted in <code>/status</code>, <code>session_status</code>, or persisted <code>sessionEntry.totalTokens</code>. Fixes #69298. Thanks @richardmqq.</li>
<li>Hooks/session-memory: use the host local timezone for memory filenames, fallback timestamp slugs, and markdown headers instead of UTC dates. Fixes #46703. (#46721) Thanks @Astro-Han.</li>
<li>Gateway health: preserve live runtime-backed channel/account state in <code>gateway.health</code> snapshots and cached refreshes while keeping raw probe payloads on sensitive/admin paths only. (#39921, #42586, #46527, #52770, #42543) Thanks @FAL1989, @rstar327, @0xble, and @ajayr.</li>
<li>Feishu: extract quoted/replied interactive-card text across schema 1.0, schema 2.0, i18n, template-variable, and post-format fallback shapes without carrying broad generated/config churn from related parser experiments. (#38776, #60383, #42218, #45936) Thanks @lishuaigit, @lskun, @just2gooo, and @Br1an67.</li>
<li>Telegram/agents: hide raw failed write/edit warning messages in Telegram when the assistant already explicitly acknowledges the failed action, while keeping warnings when the reply claims success or omits the failure; #39406 remains the broader configurable delivery-policy follow-up. Fixes #51065; covers #39631. Thanks @Bartok9 and @Bortlesboat.</li>
<li>Exec approvals: accept a symlinked <code>OPENCLAW_HOME</code> as the trusted approvals root while still rejecting symlinked <code>.openclaw</code> path components below it. (#64663) Thanks @FunJim.</li>
<li>Logging: add top-level <code>hostname</code>, flattened <code>message</code>, and available <code>agent_id</code>, <code>session_id</code>, and <code>channel</code> fields to file-log JSONL records for multi-agent filtering without removing existing structured log arguments. Fixes #51075. Thanks @stevengonsalvez.</li>
<li>ACP: route server logs to stderr before Gateway config/bootstrap work so ACP stdout remains JSON-RPC only for IDE integrations. Fixes #49060. Thanks @Hollychou924.</li>
<li>Logging: propagate internal request trace scopes through Gateway HTTP requests and WebSocket frames so file logs, diagnostic events, agent run traces, model-call traces, OTEL spans, and trusted provider <code>traceparent</code> headers share a correlatable <code>traceId</code> without logging raw request or model content. Fixes #40353. Thanks @liangruochong44-ui.</li>
<li>Diagnostics/OTEL: capture privacy-safe model-call request payload bytes, streamed response bytes, first-response latency, and total duration in diagnostic events, plugin hooks, stability snapshots, and OTEL model-call spans/metrics without logging raw model content. Fixes #33832. Thanks @wwh830.</li>
<li>Logging: write validated diagnostic trace context as top-level <code>traceId</code>, <code>spanId</code>, <code>parentSpanId</code>, and <code>traceFlags</code> fields in file-log JSONL records so traced requests and model calls are easier to correlate in log processors. Refs #40353. Thanks @liangruochong44-ui.</li>
<li>Logging/sessions: apply configured redaction patterns to persisted session transcript text and accept escaped character classes in safe custom redaction regexes, so transcript JSONL no longer keeps matching sensitive text in the clear. Fixes #42982. Thanks @panpan0000.</li>
<li>Agents/sessions: let <code>sessions_spawn runtime="subagent"</code> ignore ACP-only <code>streamTo</code> and <code>resumeSessionId</code> fields while keeping ACP passthrough and documenting <code>streamTo</code> as ACP-only. Fixes #43556 and #63120; covers #56326, #61724, #64714, and #67248; carries forward #68397, #65282, #58686, #56342, and #40102. Thanks @skernelx, @damselem, @Br1an67, @Mintalix, @IsaacAPerez, @vvitovec, @Sanjays2402, @shenkq97, and @1034378361.</li>
<li>Providers/Ollama: honor <code>/api/show</code> capabilities, custom Modelfile <code>PARAMETER num_ctx</code>, configured provider/model context defaults, whitelisted native params such as <code>temperature</code>, <code>top_p</code>, and <code>think</code>, and native thinking effort levels so local models get accurate tools, context, and thinking behavior without forcing full-context VRAM use. Fixes #64710, duplicate #65343, #68344, #44550, #52206, #49684, #68662, #48010, #71584, and #44786; supersedes #69464; carries forward #44955. Thanks @yuan-b, @netherby, @xilopaint, @Diyforfun2026, @neeravmakwana, @taitruong, @armi0024, @LokiCode404, @zhouZcong, @dshenster-byte, @tangzhi, @pandego, @maweibin, @Adam-Researchh, @EmpireCreator, @g0st1n, and @voltwake.</li>
<li>Image tool/media: honor <code>tools.media.image.timeoutSeconds</code> and matching per-model image timeouts in explicit image analysis, including the MiniMax VLM fallback path, so slow local vision models are not capped by hardcoded 30s/60s aborts. Fixes #67889; supersedes #67929. Thanks @AllenT22 and @alchip.</li>
<li>Providers/Ollama: strip custom provider prefixes before native chat/embedding requests, skip ambient localhost discovery unless config/auth opts in, handle custom remote <code>api: "ollama"</code> providers, accept OpenAI SDK-style <code>baseURL</code>, scope synthetic local auth and embedding bearer headers to declared host boundaries, resolve custom-named local providers for subagents, add provider-scoped model request timeouts, preserve explicit input modalities, and document <code>params.keep_alive</code> plus local/LAN/cloud/multi-host/web-search/embedding/thinking setup recipes. Fixes #72353, #56939, #62533, #43945, #64541, #68796, and #39690; supersedes #57116, #62549, #69261, #69857, #65143, and #66511; refs #43945; carries forward #43224 and #39785. Thanks @maximus-dss, @hclsys, @IanxDev, @tsukhani, @issacthekaylon, @Julien-BKK, @Linux2010, @hyspacex, @maxramsay, @Meli73, @LittleJakub, @Juankcba, @uninhibite-scholar, @yfge, @Skrblik, and @Mriris.</li>
<li>Providers/Ollama: move memory embeddings to <code>/api/embed</code> with batched <code>input</code>, route local web search through Ollama's signed daemon proxy while keeping cloud auth scoped, treat Ollama memory embeddings as key-optional in doctor, and keep model usage visible by estimating native transcript usage when <code>/api/chat</code> omits counters. Fixes #39983, #69132, and #46584; carries forward #39112. Thanks @sskkcc, @LiudengZhang, @yoon1012, @hyspacex, @fengly78, and @TylonHH.</li>
<li>Agents/Ollama: parse stringified native tool-call arguments, retry native empty/thinking-only turns, accept already-prefixed LLM task model overrides, apply provider-owned replay normalization for Cloud models, validate explicit <code>--thinking max</code>, show resolved thinking defaults in Control UI, and include configured provider models in <code>models list --provider</code>. Fixes #69735, #50052, #71697, #71584, #72407, and #65207; supersedes #69910; carries forward #66552 and #61223. Thanks @rongshuzhao, @yfge, @L3G, @ralphy-maplebots, @Hollychou924, @ismael-81, @g0st1n, @NotecAG, and @drzeast-png.</li>
<li>Providers/PDF/Ollama: add bounded network timeouts for Ollama model pulls and native Anthropic/Gemini PDF analysis requests so unresponsive provider endpoints no longer hang sessions indefinitely. Fixes #54142; supersedes #54144 and #54145. Thanks @jinduwang1001-max and @arkyu2077.</li>
<li>Docker/QA: add observability coverage to the normal Docker aggregate so QA-lab OTEL and Prometheus diagnostics run inside Docker. Thanks @vincentkoc.</li>
<li>Auto-reply: poison inbound message dedupe after replay-unsafe provider/runtime failures so retries stay safe before visible progress but cannot duplicate messages after block output, tool side effects, or session progress. Fixes #69303; keeps #58549 and #64606 as duplicate validation. Thanks @martingarramon, @NikolaFC, and @zeroth-blip.</li>
<li>Agents/model fallback: keep auto-persisted fallback model overrides selected across turns until <code>/new</code> or reset clears them, avoiding repeated probes of a known-bad primary while <code>/status</code> shows the selected and active models. Thanks @kibedu.</li>
<li>Agents/model fallback: jump directly to a known later live-session model redirect instead of walking unrelated fallback candidates, while preserving the already-landed live-session/fallback loop guard. Fixes #57471; related loop family already closed via #58496. Thanks @yuxiaoyang2007-prog.</li>
<li>Gateway/Bonjour: keep @homebridge/ciao cancellation handlers registered across advertiser restarts so late probing cancellations cannot crash Linux and other mDNS-churned gateways.</li>
<li>Plugins/startup: load the default <code>memory-core</code> slot during Gateway startup when permitted so active-memory recall can call <code>memory_search</code> and <code>memory_get</code> without requiring an explicit <code>plugins.slots.memory</code> entry, while preserving <code>plugins.slots.memory: "none"</code>.</li>
<li>Plugins/CLI: prefer native require for compiled bundled plugin JavaScript before jiti so read-only config, status, device, and node commands avoid unnecessary transform overhead on slow hosts. Fixes #62842. Thanks @Effet.</li>
<li>Plugins/compat/CLI: inventory doctor-side deprecation migrations separately from runtime plugin compatibility, add dated records for legacy extension-api, memory registration, provider hook/type aliases, runtime aliases, channel SDK helpers, and approval/test utility shims, refresh the persisted registry after managed plugin removals, make plugin install/uninstall writes conflict-aware, clear stale denylists, and fail tracked plugin/hook updates or unloadable package installs instead of leaving stale state. Thanks @vincentkoc.</li>
<li>WebChat/Control UI: support non-video file attachments in chat uploads while preserving the existing image attachment path and MIME-sniff fallback for generic image uploads. (#70947) Thanks @IAMSamuelRodda.</li>
<li>Skills/memory: restore Chokidar v5 hot reloads by watching concrete skill and memory roots with filters, including SKILL.md removals and deleted skill folders without broad workspace recursion. Fixes #27404, #33585, and #41606. Thanks @shelvenzhou, @08820048, and @rocke2020.</li>
<li>Gateway/chat: keep duplicate attachment-backed <code>chat.send</code> retries with the same idempotency key on the documented in-flight path so aborts still target the real active run. Fixes #70139. Thanks @Feelw00.</li>
<li>Gateway/session rows: report the same config-resolved thinking default that runtime sessions use, including global and per-agent defaults, so Control UI and TUI default labels stay aligned. (#71779, #70981, #71033, #70302) Thanks @chen-zhang-cs-code, @SymbolStar, and @cholaolu-boop.</li>
<li>Plugins: share package entrypoint resolution between install and discovery, reject mismatched <code>runtimeExtensions</code>, and cache bundled runtime-dependency manifest reads during scans.</li>
<li>WhatsApp/Web: keep quiet but healthy linked-device sessions connected by basing the watchdog on WhatsApp Web transport activity, while retaining a longer app-silence cap so frame activity cannot mask a stuck session forever. Fixes #70678; carries forward the focused #71466 approach and keeps #63939 as related configurable-timeout follow-up. Thanks @vincentkoc and @oromeis.</li>
<li>Discord/gateway: count failed health-monitor restart attempts toward cooldown and hourly caps, and evict stale account lifecycle state during channel reloads so repeated Discord gateway recovery cannot loop on old status. Fixes #38596. (#40413) Thanks @jellyAI-dev and @vashquez.</li>
<li>Cron/context engine: run isolated cron jobs under run-scoped context-engine session keys so prior runs of the same job are not inherited unless the job is explicitly session-bound. (#72292) Thanks @jalehman.</li>
<li>Control UI: localize command palette labels, categories, skill shortcuts, footer hints, and connect-command copy labels while preserving localized command palette search matching. (#61130, #61119) Thanks @rubensfox20.</li>
<li>Plugins/memory-lancedb: request float embedding responses from OpenAI-compatible servers so local providers that default SDK requests to base64 no longer return dimension-mismatched LanceDB vectors while preserving configured dimensions. Fixes #45982. (#59048, #46069, #45986) Thanks @deep-introspection, @xiaokhkh, @caicongyang, and @thiswind.</li>
<li>Plugins/memory-lancedb: advance auto-capture cursors per session only after messages are processed or intentionally skipped, retry failed messages, survive compacted histories, and clear cursor state on session end. Fixes #71349; carries forward #42083. Thanks @as775116191.</li>
<li>Plugins/memory-core: respect configured memory-search embedding concurrency during non-batch indexing so local Ollama embedding backends can serialize indexing instead of flooding the server. Fixes #66822. (#66931) Thanks @oliviareid-svg and @LyraInTheFlesh.</li>
<li>Docker/update smoke: keep the package-derived update-channel fixture on package-shipped files and make its UI build stub create the asset the updater verifies. Thanks @vincentkoc.</li>
<li>Gateway/models: repair legacy <code>models.providers.*.api = "openai"</code> config values to <code>openai-completions</code>, and skip providers with future stale API enum values during startup instead of bricking the gateway. Fixes #72477. (#72542) Thanks @JooyoungChoi14 and @obviyus.</li>
<li>Gateway/skills: redact <code>apiKey</code> and secret-named <code>env</code> values from the <code>skills.update</code> RPC response to prevent leaking credentials into WebSocket traffic, client logs, or session transcripts. Config is still written to disk in full; only the response payload is redacted. (#69998) Thanks @Ziy1-Tan.</li>
<li>Plugins/CLI: let flag-driven <code>openclaw channels add</code> install the selected channel plugin from its default source without opening an interactive prompt, fixing published npm Telegram setup in stdin-closed automation.</li>
<li>Onboarding/setup: keep first-run config reads, plugin compatibility notices, OpenAI Codex auth, post-auth default-model policy lookup, skip-auth, provider-scoped model pickers, and post-model sanity checks on cold manifest/setup metadata unless the user chooses to browse all models, avoiding full plugin/provider runtime loads between prompts. Thanks @shakkernerd.</li>
<li>Gateway/Bonjour: suppress known @homebridge/ciao cancellation and network assertion failures through scoped process handlers so malformed mDNS packets or restricted VPS networking disable/restart Bonjour instead of crashing the gateway. Fixes #67578. Thanks @zenassist26-create.</li>
<li>Discord: keep late clicks on already-resolved exec approval buttons quiet when elevated mode auto-resolved the request, while still surfacing real approval submission failures. Fixes #66906. Thanks @rlerikse.</li>
<li>Telegram: send a fresh final message for long-lived preview-streamed replies so the visible Telegram timestamp reflects completion time instead of the preview creation time. Thanks @rubencu.</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.4.26/OpenClaw-2026.4.26.zip" length="48222029" type="application/octet-stream" sparkle:edSignature="6wgFZUyyU09Y6nvD9T1Ufq7Plo0Wzfg+L9r80DCaNMMuwebcKWAsMVSP3RvhRhTxVMax8toUDYg3gb/vOiE5BA=="/>
</item>
<item>
<title>2026.4.25</title>
<pubDate>Mon, 27 Apr 2026 13:34:25 +0000</pubDate>
@@ -689,5 +454,206 @@
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.4.24/OpenClaw-2026.4.24.zip" length="48033180" type="application/octet-stream" sparkle:edSignature="wxOfxadSZ/9iXMitaC6SA9J6YPZC3P2tkeK7HZPHzjUIlzQTvOl7EjR4aRyXzaYt1N1AK5ba+YhuCwEngrTdCQ=="/>
</item>
<item>
<title>2026.4.22</title>
<pubDate>Thu, 23 Apr 2026 15:18:00 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026042290</sparkle:version>
<sparkle:shortVersionString>2026.4.22</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.4.22</h2>
<h3>Changes</h3>
<ul>
<li>Providers/xAI: add image generation, text-to-speech, and speech-to-text support, including <code>grok-imagine-image</code> / <code>grok-imagine-image-pro</code>, reference-image edits, six live xAI voices, MP3/WAV/PCM/G.711 TTS formats, <code>grok-stt</code> audio transcription, and xAI realtime transcription for Voice Call streaming. (#68694) Thanks @KateWilkins.</li>
<li>Providers/STT: add Voice Call streaming transcription for Deepgram, ElevenLabs, and Mistral, alongside the existing OpenAI and xAI realtime STT paths; ElevenLabs also gains Scribe v2 batch audio transcription for inbound media.</li>
<li>TUI: add local embedded mode for running terminal chats without a Gateway while keeping plugin approval gates enforced. (#66767) Thanks @fuller-stack-dev.</li>
<li>Onboarding: auto-install missing provider and channel plugins during setup so first-run configuration can complete without manual plugin recovery.</li>
<li>OpenAI/Responses: use OpenAI's native <code>web_search</code> tool automatically for direct OpenAI Responses models when web search is enabled and no managed search provider is pinned; explicit providers such as Brave keep the managed <code>web_search</code> tool.</li>
<li>Models/commands: add <code>/models add <provider> <modelId></code> so you can register a model from chat and use it without restarting the gateway; keep <code>/models</code> as a simple provider browser while adding clearer add guidance and copy-friendly command examples. (#70211) Thanks @Takhoffman.</li>
<li>WhatsApp: add configurable native reply quoting with replyToMode for WhatsApp conversations. Thanks @mcaxtr.</li>
<li>WhatsApp/groups+direct: forward per-group and per-direct <code>systemPrompt</code> config into inbound context <code>GroupSystemPrompt</code> so configured per-chat behavioral instructions are injected on every turn. Supports <code>"*"</code> wildcard fallback and account-scoped overrides under <code>channels.whatsapp.accounts.<id>.{groups,direct}</code>; account maps fully replace root maps (no deep merge), matching the existing <code>requireMention</code> pattern. Closes #7011. (#59553) Thanks @Bluetegu.</li>
<li>Agents/sessions: add mailbox-style <code>sessions_list</code> filters for label, agent, and search plus visibility-scoped derived title and last-message previews. (#69839) Thanks @dangoZhang.</li>
<li>Control UI/settings+chat: add a browser-local personal identity for the operator (name plus local-safe avatar), route user identity rendering through the shared chat/avatar path used by assistant and agent surfaces, and tighten Quick Settings, agent fallback chips, and narrow-screen chat layouts so personalization no longer wastes space or clips controls. (#70362) Thanks @BunsDev.</li>
<li>Gateway/diagnostics: enable payload-free stability recording by default and add a support-ready diagnostics export with sanitized logs, status, health, config, and stability snapshots for bug reports. (#70324) Thanks @gumadeiras.</li>
<li>Providers/Tencent: add the bundled Tencent Cloud provider plugin with TokenHub onboarding, docs, <code>hy3-preview</code> model catalog entries, and tiered Hy3 pricing metadata. (#68460) Thanks @JuniperSling.</li>
<li>Providers/Amazon Bedrock Mantle: add Claude Opus 4.7 through Mantle's Anthropic Messages route with provider-owned bearer-auth streaming, so the model is actually callable without treating AWS bearer tokens like Anthropic API keys. Thanks @wirjo.</li>
<li>Providers/GPT-5: move the GPT-5 prompt overlay into the shared provider runtime so compatible GPT-5 models receive the same behavior and heartbeat guidance through OpenAI, OpenRouter, OpenCode, Codex, and other GPT providers; add <code>agents.defaults.promptOverlays.gpt5.personality</code> as the global friendly-style toggle while keeping the OpenAI plugin setting as a fallback.</li>
<li>Providers/OpenAI Codex: remove the Codex CLI auth import path from onboarding and provider discovery so OpenClaw no longer copies <code>~/.codex</code> OAuth material into agent auth stores; use browser login or device pairing instead. (#70390) Thanks @pashpashpash.</li>
<li>CLI/Claude: default <code>claude-cli</code> runs to warm stdio sessions, including custom configs that omit transport fields, and resume from the stored Claude session after Gateway restarts or idle exits. (#69679) Thanks @obviyus.</li>
<li>Pi/models: update the bundled pi packages to <code>0.68.1</code> and let the OpenCode Go catalog come from pi instead of plugin-maintained model aliases, adding the refreshed <code>opencode-go/kimi-k2.6</code>, Qwen, GLM, MiMo, and MiniMax entries.</li>
<li>Tokenjuice: add bundled native OpenClaw support for tokenjuice as an opt-in plugin that compacts noisy <code>exec</code> and <code>bash</code> tool results in Pi embedded runs. (#69946) Thanks @vincentkoc.</li>
<li>ACPX: add an explicit <code>openClawToolsMcpBridge</code> option that injects a core OpenClaw MCP server for selected built-in tools, starting with <code>cron</code>.</li>
<li>CLI/doctor plugins: lazy-load doctor plugin paths and prefer installed plugin <code>dist/*</code> runtime entries over source-adjacent JavaScript fallbacks, reducing the measured <code>doctor --non-interactive</code> runtime by about 74% while keeping cold doctor startup on built plugin artifacts. (#69840) Thanks @gumadeiras.</li>
<li>CLI/debugging: add an opt-in temporary debug timing helper for local CLI performance investigations, with readable stderr output, JSONL capture, and docs for removing probes before landing fixes. (#70469) Thanks @shakkernerd.</li>
<li>Docs/i18n: add Thai translation support for the docs site.</li>
<li>Providers/OpenAI-compatible: mark known local backends such as vLLM, SGLang, llama.cpp, LM Studio, LocalAI, Jan, TabbyAPI, and text-generation-webui as streaming-usage compatible, so their token accounting no longer degrades to unknown/stale totals. (#68711) Thanks @gaineyllc.</li>
<li>Providers/OpenAI-compatible: recover streamed token usage from llama.cpp-style <code>timings.prompt_n</code> / <code>timings.predicted_n</code> metadata and sanitize usage counts before accumulation, fixing unknown or stale totals when compatible servers do not emit an OpenAI-shaped <code>usage</code> object. (#41056) Thanks @xaeon2026.</li>
<li>Plugins/startup: prefer native Jiti loading for built bundled plugin dist modules on supported runtimes, cutting measured bundled plugin load time by 82-90% while keeping source TypeScript on the transform path. (#69925) Thanks @aauren.</li>
<li>Plugin SDK/STT: share realtime transcription WebSocket transport and multipart batch transcription form helpers across bundled STT providers, reducing provider plugin boilerplate while preserving proxy capture, reconnects, audio queueing, close flushing, upload filename normalization, and ready handshakes.</li>
<li>Plugin SDK/Pi embedded runs: add a bundled-plugin embedded extension factory seam so native plugins can extend Pi embedded runs with async runtime hooks such as <code>tool_result</code> handling instead of falling back to the older synchronous persistence path. (#69946) Thanks @vincentkoc.</li>
<li>Codex harness/hooks: route native Codex app-server turns through <code>before_prompt_build</code> and emit <code>before_compaction</code> / <code>after_compaction</code> for native compaction items so prompt and compaction hooks stop drifting from Pi. Thanks @vincentkoc.</li>
<li>Codex harness/plugins: add a bundled-plugin Codex app-server extension seam for async <code>tool_result</code> middleware, fire <code>after_tool_call</code> for Codex tool runs, and route mirrored Codex transcript writes through <code>before_message_write</code> so tool integrations stop diverging from Pi. Thanks @vincentkoc.</li>
<li>Codex harness/hooks: fire <code>llm_input</code>, <code>llm_output</code>, and <code>agent_end</code> for native Codex app-server turns so lifecycle hooks stop drifting from Pi. Thanks @vincentkoc.</li>
<li>QA/Telegram: record per-scenario reply RTT in the live Telegram QA report and summary, starting with the canary response. (#70550) Thanks @obviyus.</li>
<li>Status: add an explicit <code>Runner:</code> field to <code>/status</code> so sessions now report whether they are running on embedded Pi, a CLI-backed provider, or an ACP harness agent/backend such as <code>codex (acp/acpx)</code> or <code>gemini (acp/acpx)</code>. (#70595)</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Thinking defaults/status: raise the implicit default thinking level for reasoning-capable models from legacy <code>off</code>/<code>low</code> fallback behavior to a safe provider-supported <code>medium</code> equivalent when no explicit config default is set, preserve configured-model reasoning metadata when runtime catalog loading is empty, and make <code>/status</code> report the same resolved default as runtime.</li>
<li>Gateway/model pricing: fetch OpenRouter and LiteLLM pricing asynchronously at startup and extend catalog fetch timeouts to 30 seconds, reducing noisy timeout warnings during slow upstream responses.</li>
<li>Agents/sessions: keep daily reset and idle-maintenance bookkeeping from bumping session activity or pruning freshly active routes, so active conversations no longer look newer or disappear for maintenance-only updates.</li>
<li>Plugins/install: add newly installed plugin ids to an existing <code>plugins.allow</code> list before enabling them, so allowlisted configs load installed plugins after restart.</li>
<li>Status: show <code>Fast</code> in <code>/status</code> when fast mode is enabled, including config/default-derived fast mode, and omit it when disabled.</li>
<li>OpenAI/image generation: detect Azure OpenAI-style image endpoints, use Azure <code>api-key</code> auth plus deployment-scoped image URLs, honor <code>AZURE_OPENAI_API_VERSION</code>, and document the Azure setup path so image generation and edits work against Azure-hosted OpenAI resources. (#70570) Thanks @zhanggpcsu.</li>
<li>Telegram/forum topics: cache recovered forum metadata with bounded expiry so supergroup updates no longer need repeated <code>getChat</code> lookups before topic routing.</li>
<li>Onboarding/WeCom: show the official WeCom channel plugin with its native Enterprise WeChat display name and blurb in the external channel catalog.</li>
<li>Models/auth: merge provider-owned default-model additions from <code>openclaw models auth login</code> instead of replacing <code>agents.defaults.models</code>, so re-authenticating an OAuth provider such as OpenAI Codex no longer wipes other providers' aliases and per-model params. Migrations that must rename keys (Anthropic -> Claude CLI) opt in with <code>replaceDefaultModels</code>. Fixes #69414. (#70435) Thanks @neeravmakwana.</li>
<li>Media understanding/audio: prefer configured or key-backed STT providers before auto-detected local Whisper CLIs, so installed local transcription tools no longer shadow API providers such as Groq/OpenAI in <code>tools.media.audio</code> auto mode. Fixes #68727.</li>
<li>Providers/OpenAI: lock the auth picker wording for OpenAI API key, Codex browser login, and Codex device pairing so the setup choices no longer imply a mixed Codex/API-key auth path. (#67848) Thanks @tmlxrd.</li>
<li>Agents/BTW: route <code>/btw</code> side questions through provider stream registration with the session workspace, so Ollama provider URL construction and workspace-scoped hooks apply correctly. Fixes #68336. (#70413) Thanks @suboss87.</li>
<li>Agents/sessions: make session transcript write locks non-reentrant by default, so same-process transcript writers contend unless a helper explicitly opts into nested lock ownership.</li>
<li>ACPX/probe: expose an optional <code>probeAgent</code> plugin config field so the embedded ACP runtime health probe can target a configured agent (for example <code>opencode</code> or <code>claude</code>) instead of hardcoding <code>codex</code>, and stop marking the entire ACP runtime backend unavailable when the default probe agent is simply not installed or not authenticated. (#68409) Thanks @lyfuci.</li>
<li>Memory search: use sqlite-vec KNN for vector recall while preserving full post-filter result limits in multi-model indexes. Fixes #69666. (#69680) Thanks @aalekh-sarvam.</li>
<li>Providers/OpenAI Codex: stop stale per-agent <code>openai-codex:default</code> OAuth profiles from shadowing a newer main-agent identity-scoped profile, and let <code>openclaw doctor</code> offer the matching cleanup. (#70393) Thanks @pashpashpash.</li>
<li>ACPX: route OpenClaw ACP bridge commands through the MCP-free runtime path even when the command is wrapped with <code>env</code>, has bridge flags, or is resumed from persisted session state, so documented <code>acpx openclaw</code> setups no longer fail on per-session MCP injection. (#68741) Thanks @alexlomt.</li>
<li>Codex harness: route Codex-tagged MCP tool approval elicitations through OpenClaw plugin approvals, including current empty-schema app-server requests, while leaving generic user-input prompts fail-closed. (#68807) Thanks @kesslerio.</li>
<li>WhatsApp/outbound: hold an in-memory active-delivery claim while a live outbound send is in flight, so a concurrent reconnect drain no longer re-drives the same pending queue entry and duplicates cron sends 7-12x after the 30-minute inbound-silence watchdog fires mid-delivery. Crash-replay of fresh queue entries left behind by a dead process is preserved because the claim is intentionally process-local. Fixes #70386. (#70428) Thanks @neeravmakwana.</li>
<li>Matrix/commands: keep Matrix DM allowlist state out of room control-command authorization, so trusted DM senders do not accidentally gain room-command access.</li>
<li>Providers/SDK retry: cap long <code>Retry-After</code> sleeps in Stainless-based Anthropic/OpenAI model SDKs so 60s+ retry windows surface immediately for OpenClaw failover instead of blocking the run. (#68474) Thanks @jetd1.</li>
<li>Agents/TTS: preserve spoken text in TTS tool results while defusing reply directives in transcript content, so future turns remember voice replies without treating spoken <code>MEDIA:</code> or voice tags as delivery metadata. (#68869) Thanks @zqchris.</li>
<li>Providers/OpenAI: harden Voice Call realtime transcription against OpenAI Realtime session-update drift, forward language and prompt hints, and add live coverage for realtime STT.</li>
<li>Agents/Pi embedded runs: suppress the "⚠️ Agent couldn't generate a response" warning when the assistant already delivered user-visible content through a messaging tool and the turn ended cleanly (<code>stopReason=stop</code>). Real failure modes (tool errors, provider <code>stopReason=error</code>, interrupted tool use) still surface the existing "verify before retrying" warning. Fixes #70396. (#70425) Thanks @neeravmakwana.</li>
<li>Gateway/Linux: wrap gateway-managed supervisor, PTY, MCP stdio, and browser child processes in a tiny <code>/bin/sh</code> shim that raises the child's own <code>oom_score_adj</code> on Linux, so under cgroup memory pressure the kernel prefers transient workers over the long-lived gateway. Opt out with <code>OPENCLAW_CHILD_OOM_SCORE_ADJ=0</code>. Fixes #70404. (#70419) Thanks @neeravmakwana.</li>
<li>Providers/Moonshot: stop strict-sanitizing Kimi's native tool_call IDs (shaped like <code>functions.<name>:<index></code>) on the OpenAI-compatible transport, so multi-turn agentic flows through Kimi K2.6 no longer break after 2-3 tool-calling rounds when the serving layer fails to match mangled IDs against the original tool definitions. Adds a <code>sanitizeToolCallIds</code> opt-out to the shared <code>openai-compatible</code> replay family helper and wires Moonshot to it. Fixes #62319. (#70030) Thanks @LeoDu0314.</li>
<li>Dependencies/security: override transitive <code>uuid</code> to <code>14.0.0</code>, clearing the runtime advisory across dependencies.</li>
<li>Codex harness: ignore dynamic tool descriptions when deciding whether to reuse a native app-server thread while still fingerprinting tool schemas, so channel-specific copy changes no longer reset otherwise compatible Codex conversations. (#69976) Thanks @chen-zhang-cs-code.</li>
<li>Codex harness: expose the Codex app-server model catalog in <code>models list/status</code>, avoid startup hangs from app-server discovery timeouts, and accept current Codex turn-completion notifications so Docker live gateway turns finish reliably.</li>
<li>Codex harness: drop invalid legacy app-server <code>serviceTier</code> values such as <code>"priority"</code> before native thread and turn requests, while keeping supported Codex tiers limited to <code>"fast"</code> and <code>"flex"</code>. Fixes #64815.</li>
<li>Codex harness: show bounded, sanitized permission target samples in app-server approval prompts, so native permission requests keep their specific hosts, roots, and paths visible without leaking home usernames or URL credentials. (#70340) Thanks @Lucenx9.</li>
<li>Docs/Codex harness: narrow native compaction docs to the current start/completion signals, without promising a readable summary or kept-entry audit list yet. (#69612) Thanks @91wan.</li>
<li>Providers/Amazon Bedrock: use known context-window metadata for discovered models while keeping the unknown-model fallback conservative, so compaction and overflow handling improve for newer Bedrock models without overstating unlisted model limits. Thanks @wirjo.</li>
<li>Providers/Amazon Bedrock Mantle: refresh IAM-backed bearer tokens at runtime instead of baking discovery-time tokens into provider config, so long-lived Mantle sessions keep working after the initial token ages out. Thanks @wirjo.</li>
<li>Config/includes: write through single-file top-level includes for isolated OpenClaw-owned mutations, so <code>plugins install</code> and <code>plugins update</code> update an included <code>plugins.json5</code> file instead of flattening modular <code>$include</code> configs. Fixes #41050 and #66048.</li>
<li>Config/reload: plan gateway reloads from source-authored config instead of runtime-materialized snapshots, so plugin update writes no longer trigger false restarts from derived provider/plugin config paths. Fixes #68732.</li>
<li>Plugins/update: skip npm plugin reinstall/config rewrites when the installed version and recorded artifact identity already match the registry target, let bare npm package names resolve back to tracked install records, and point already-installed <code>plugins install</code> attempts at <code>plugins update</code> / <code>--force</code> instead of a hook-pack fallback. Fixes #46955, #67957, and #68073.</li>
<li>Agents/MCP: keep <code>mcp.servers</code> and bundle MCP tools available in Pi embedded <code>coding</code> and <code>messaging</code> sessions while preserving <code>minimal</code> profile and <code>tools.deny: ["bundle-mcp"]</code> opt-out behavior. Fixes #68875 and #68818.</li>
<li>Plugins/startup: tolerate transient bundled-channel catalog/metadata drift while auto-enabling configured plugins, so CLI and gateway startup no longer crash when a channel id is known but its display metadata is unavailable.</li>
<li>CLI/Claude: report CLI-backed reply runs as streaming while Claude/Codex CLI turns are still in flight, so WebChat keeps visible response state until the backend finishes. Fixes #70125.</li>
<li>Slack/streaming: fall back to normal Slack replies for Slack Connect streams rejected before the SDK flushes its local buffer, so short replies no longer disappear or report success before Slack acknowledges delivery. Fixes #70295. (#70370) Thanks @mvanhorn.</li>
<li>Codex harness: rotate the shared app-server websocket client when the configured bearer token changes, so auth-token refreshes reconnect with the new <code>Authorization</code> header instead of reusing a stale socket. (#70328) Thanks @Lucenx9.</li>
<li>Channels/sandbox: derive runtime policy keys for external direct messages that share the main conversation, so sandbox/tool policy no longer treats channel-originated DMs as local main-session runs.</li>
<li>Config/models: merge provider-scoped model allowlist updates and protect model/provider map writes from accidental full replacement, adding <code>config set --merge</code> for additive updates and <code>--replace</code> for intentional clobbers. Fixes #65920, #68392, and #68653.</li>
<li>Agents/Pi auth: preserve AWS SDK-authenticated Bedrock runs for IMDS and task-role setups, clear stale refresh timers on sentinel fallback, and log unexpected runtime-auth prep failures instead of silently leaving the provider unauthenticated. Thanks @wirjo.</li>
<li>Config/gateway: restore last-known-good config on critical clobber signatures such as missing metadata, missing <code>gateway.mode</code>, or sharp size drops, preventing gateway crash loops when a valid backup exists. Fixes #70336.</li>
<li>Config/gateway: recover configs accidentally prefixed with non-JSON output during gateway startup or <code>openclaw doctor --fix</code>, preserving the clobbered file as a backup while leaving normal config reads read-only.</li>
<li>Agents/GitHub Copilot: normalize connection-bound Responses item IDs in the Copilot provider wrapper so replayed histories no longer fail after the upstream connection changes. (#69362) Thanks @Menci.</li>
<li>Pi embedded runs: pass real built-in tools into Pi session creation and then narrow active tool names after custom tool registration, so the runner and compaction paths compile cleanly and keep OpenClaw-managed custom tool allowlists without feeding string arrays into <code>createAgentSession</code>. Thanks @vincentkoc.</li>
<li>Agents/OpenAI websocket: route native OpenAI websocket metadata and session-header decisions through the shared endpoint classifier so local mocks and custom <code>models.providers.openai.baseUrl</code> endpoints stay out of the native OpenAI path consistently across embedded-runner and websocket transport code. Thanks @vincentkoc.</li>
<li>Cron/MCP: retire bundled MCP runtimes through one shared cleanup path for isolated cron run ends, persistent cron session rollover, and direct cron <code>deleteAfterRun</code> fallback cleanup. Fixes #69145, #68623, and #68827.</li>
<li>MCP/gateway: tear down stdio MCP process trees on transport close and dispose bundled MCP runtimes during session delete/reset, preventing orphaned wrapper/server processes from accumulating. Fixes #68809 and #69465.</li>
<li>Agents/MCP: retire bundled MCP runtimes after completed one-shot subagent cleanup and nested <code>sessions_send</code> steps, while keeping persistent subagent sessions warm.</li>
<li>Config: render validation warnings with real line breaks instead of a literal <code>\n</code> sequence in CLI/audit output. Fixes #70140.</li>
<li>Cron/doctor: repair malformed persisted cron job IDs through <code>openclaw doctor</code>, including legacy <code>jobId</code>, non-string <code>id</code>, and missing <code>id</code> rows, so <code>cron list</code> no longer needs display-layer coercion for corrupt store data. Fixes #70128.</li>
<li>Discord: normalize prefixed channel targets only at the thread-binding API boundary, so <code>sessions_spawn({ runtime: "acp", thread: true })</code> can create child threads from Discord channels without breaking current-channel ACP bindings. (#68034) Thanks @Zetarcos.</li>
<li>Discord: harden inbound thread metadata handling against partial Carbon channel getters, so non-command thread messages and queued jobs no longer crash when <code>name</code>, <code>parentId</code>, <code>parent</code>, or <code>ownerId</code> requires fetched raw data.</li>
<li>Discord: let <code>message</code> tool reactions resolve <code>user:<id></code> DM targets and preserve <code>channels.discord.guilds.<guild>.channels.<channel>.requireMention: false</code> during reply-stage activation fallback. Fixes #70165 and #69441.</li>
<li>Plugins/startup: pre-normalize and cache Jiti alias maps before creating plugin loaders, so module-scoped loader filenames do not reintroduce per-plugin alias-normalization startup cost. Fixes #70186.</li>
<li>ACP/Codex: run the bundled Codex ACP harness with an isolated <code>CODEX_HOME</code> and avoid writing incomplete ChatGPT auth bridge files, so Codex ACP sessions no longer clobber the user's real Codex CLI auth. Fixes #70234. Thanks @Lonobers88.</li>
<li>Gateway/client: keep long-running RPCs such as ACP <code>agent.wait</code> calls in charge of their own timeout instead of closing the websocket on a missed app-level tick while work is still pending.</li>
<li>Telegram/webhooks: lower the grammY webhook callback timeout to 5s so Telegram gets an early 200 response instead of retrying long-running updates as read timeouts. (#70146) Thanks @friday-james.</li>
<li>Telegram/polling: rebuild the polling HTTP transport after <code>getUpdates</code> 409 conflicts, so retries use a fresh TCP connection instead of looping on a Telegram-terminated keep-alive socket. (#69873) Thanks @hclsys.</li>
<li>Media delivery: strip persisted base64 audio payloads from webchat history, resolve stored <code>media://inbound/*</code> attachments before local-root checks, suppress duplicate Telegram voice/audio sends when TTS emits the same media twice, and support custom image-model IDs that already include their provider prefix.</li>
<li>Slack/files: resolve <code>downloadFile</code> bot tokens from the runtime config when callers provide <code>cfg</code> without an explicit token or prebuilt client, preserving cfg-only file downloads outside the action runtime path. (#70160) Thanks @martingarramon.</li>
<li>Slack/HTTP: dispatch registered Request URL webhooks through the same handler registry used by Slack monitor setup, so HTTP-mode Slack events no longer 404 after successful route registration. (#70275) Thanks @FroeMic.</li>
<li>Slack/runtime bindings: route focused Slack thread replies through their bound ACP session instead of preparing replies against the default agent shell. Fixes #67739. Thanks @Frankla20.</li>
<li>CLI/Claude: keep stored Claude CLI sessions through OAuth refresh-token rotation by keying auth epochs on stable account identity instead of mutable OAuth token material. (#70452) Thanks @obviyus.</li>
<li>CLI/Claude: verify stored Claude CLI session ids have a readable project transcript before resuming, clearing phantom bindings with <code>reason=transcript-missing</code> instead of silently starting fresh under <code>--resume</code>. Fixes #70177.</li>
<li>CLI sessions: persist CLI session clearing through the atomic session-store merge path, so expired Claude/Codex CLI bindings are actually removed before retrying without the stale session id. (#70298) Thanks @HFConsultant.</li>
<li>ACP/sessions_spawn: honor explicit <code>model</code> overrides for ACP child sessions instead of silently falling back to the target agent default model. (#70210) Thanks @felix-miao.</li>
<li>Diffs/viewer: re-read remote viewer access policy from live runtime config on each request, so toggling <code>plugins.entries.diffs.config.security.allowRemoteViewer</code> closes proxied viewer access immediately instead of waiting for a restart. Thanks @vincentkoc.</li>
<li>Diffs/tooling: re-read <code>viewerBaseUrl</code>, presentation defaults, and viewer access policy from live runtime config, and fail closed when the live <code>diffs</code> plugin entry disappears instead of reviving startup viewer settings. Thanks @vincentkoc.</li>
<li>Memory/LanceDB: stop resurrecting removed live <code>memory-lancedb</code> hook config from startup snapshots, so deleting or disabling the plugin entry shuts off auto-recall and auto-capture without a restart. Thanks @vincentkoc.</li>
<li>Memory/LanceDB: keep auto-recall and auto-capture hooks wired when those settings start disabled, so turning them on in live config starts recall and capture without waiting for a restart. Thanks @vincentkoc.</li>
<li>Skill Workshop: keep the tool plus <code>before_prompt_build</code> / <code>agent_end</code> hooks wired while the plugin is disabled at startup, so turning the plugin back on in live config starts guidance and capture without waiting for a restart. Thanks @vincentkoc.</li>
<li>Active Memory: stop reviving removed live <code>active-memory</code> config from startup snapshots, so removing the plugin entry turns the hook off immediately instead of waiting for a restart. Thanks @vincentkoc.</li>
<li>GitHub Copilot: re-read plugin discovery config from the live runtime snapshot, so toggling <code>plugins.entries.github-copilot.config.discovery.enabled</code> takes effect without a restart. Thanks @vincentkoc.</li>
<li>Ollama: re-read plugin discovery config from the live runtime snapshot, so toggling <code>plugins.entries.ollama.config.discovery.enabled</code> takes effect without a restart. Thanks @vincentkoc.</li>
<li>OpenAI: re-read the plugin prompt-overlay personality from live runtime config, so GPT-5 system prompt contributions update without a restart when <code>plugins.entries.openai.config.personality</code> changes. Thanks @vincentkoc.</li>
<li>Amazon Bedrock: re-read live discovery and guardrail plugin config, so toggling <code>plugins.entries.amazon-bedrock.config.discovery</code> or <code>plugins.entries.amazon-bedrock.config.guardrail</code> takes effect without a restart. Thanks @vincentkoc.</li>
<li>Codex: re-read the plugin discovery config from the live runtime snapshot, so toggling <code>plugins.entries.codex.config.discovery</code> takes effect without a restart. Thanks @vincentkoc.</li>
<li>Agents/subagents: drop bare <code>NO_REPLY</code> from the parent turn when the session still has pending spawned children, so direct-conversation surfaces such as Telegram DMs no longer rewrite the sentinel into visible fallback chatter while waiting for the child completion event. (#69942) Thanks @neeravmakwana.</li>
<li>Plugins/install: keep bundled plugin dependencies off npm install while repairing them when plugins activate from a packaged install, including Feishu/Lark, Browser, and direct bundled channel setup-entry loads.</li>
<li>CLI/channels: skip and cache bundled channel plugin, setup, and secrets load failures during read-only discovery, so one broken unused bundled channel cannot crash <code>openclaw status</code> or bootstrap secret scans.</li>
<li>Memory/LanceDB: retry initialization after a failed LanceDB load and report unsupported Intel macOS native runtime clearly instead of caching the failure or repeatedly attempting an install that cannot work.</li>
<li>CLI/Claude: hash only static extra system prompt parts when deciding whether to reuse a CLI session, so per-message inbound metadata no longer resets Claude CLI conversations on every turn. (#70122) Thanks @zijunl.</li>
<li>Hooks/Slack: standardize shared message hook routing fields (<code>threadId</code> / <code>replyToId</code>) and stop Slack outbound delivery from re-running <code>message_sending</code> inside the channel adapter, so plugins like thread-ownership make one outbound routing decision per reply. Thanks @vincentkoc.</li>
<li>Auto-reply/media: share one run-scoped reply media context between streamed block delivery and final payload filtering, so a local <code>MEDIA:</code> attachment is staged once and duplicate media sends are suppressed reliably. (#68111) Thanks @ayeshakhalid192007-dev.</li>
<li>Plugins/gateway hooks: expose startup config, workspace dir, and a live cron getter on the typed <code>gateway_start</code> hook, and move memory-core managed dreaming off the internal <code>gateway:startup</code> bridge so cron reconciliation stays on the public plugin hook path. Thanks @vincentkoc.</li>
<li>Plugins/config: read plugin trust decisions from the source config snapshot when a resolved runtime snapshot is active, so <code>plugins.allow</code> remains enforced and <code>doctor</code>/gateway startup no longer warn that the allowlist is empty when it is configured. Fixes #70161. Also fixes #70141.</li>
<li>Agents/openai-completions: enable malformed streamed tool-call argument repair for self-hosted OpenAI-compatible backends such as Kimi/SGLang, so fragmented tool-call arguments no longer reach tools as empty or unusable objects. Fixes #69672. (#70294) Thanks @MonkeyLeeT.</li>
<li>Gateway/restart: preserve group and channel chat context when resuming an agent turn after a Gateway restart, so continuation replies keep the same prompt, routing, and tool-status behavior as the original conversation.</li>
<li>Gateway/pairing: shared-secret loopback CLI clients now silently auto-approve <code>metadata-upgrade</code> pairing (platform / device family refresh) instead of being disconnected with <code>1008 pairing required</code>. This matches the scope-upgrade and role-upgrade behavior added in #69431 and unblocks non-interactive CLI automation when a paired-device record has a stale platform string (e.g. device key replicated across hosts, install migrated between OSes, or platform-string format changed between OpenClaw versions). Browser / Control-UI clients keep the existing approval-required flow for metadata changes.</li>
<li>Gateway/pairing: treat any forwarded-header evidence (<code>Forwarded</code>, <code>X-Forwarded-*</code>, or <code>X-Real-IP</code>) as proxied WebSocket traffic before pairing locality checks, so reverse-proxy topologies cannot use the loopback shared-secret helper auto-pairing path.</li>
<li>Agents/OpenAI: treat exact <code>NO_REPLY</code> assistant output as a deliberate silent reply in embedded runs, so GPT-5.4 turns with signed reasoning plus a silent final no longer surface a false incomplete-turn error.</li>
<li>Auto-reply/streaming: preserve streamed reply directives through chunk boundaries and phase-aware <code>final_answer</code> delivery, so split <code>MEDIA:<path></code> lines, voice tags, and reply targets reach channel delivery instead of leaking as text or being dropped. (#70243) Thanks @zqchris.</li>
<li>Anthropic/Claude Opus 4.7: normalize Opus 4.7 and <code>claude-cli</code> Opus 4.7 variants to a 1M context window in resolved runtime metadata and active-agent status/context reporting, so they no longer inherit the stale 200k fallback. Thanks @BunsDev.</li>
<li>Gateway/pairing webchat: render <code>/pair qr</code> replies as structured media instead of raw markdown text, preserve inline reply threading and silent-control handling on media replies, avoid persisting sensitive QR images into transcript history, and keep local webchat media embedding behind internal-only trust markers. (#70047) Thanks @BunsDev.</li>
<li>Codex harness: default app-server runs to unchained local execution, so OpenAI heartbeats can use network and shell tools without stalling behind native Codex approvals or the workspace-write sandbox.</li>
<li>Codex harness: fail closed for unknown native app-server approval methods instead of routing unsupported future approval shapes through OpenClaw approval grants. (#70356) Thanks @Lucenx9.</li>
<li>Codex harness: apply the GPT-5 behavior and heartbeat prompt overlay to native Codex app-server runs, so <code>codex/gpt-5.x</code> sessions get the same follow-through, tool-use, and proactive heartbeat guidance as OpenAI GPT-5 runs.</li>
<li>Codex harness: add an explicit Guardian mode for Codex app-server approvals, plus a Docker live probe for approved and ask-back Guardian decisions, while keeping default app-server runs unchained for unattended local heartbeats. The legacy <code>OPENCLAW_CODEX_APP_SERVER_GUARDIAN</code> shortcut is removed; use plugin config <code>appServer.mode: "guardian"</code> or <code>OPENCLAW_CODEX_APP_SERVER_MODE=guardian</code>. Thanks @pashpashpash.</li>
<li>OpenAI/Responses: keep embedded OpenAI Responses runs on HTTP when <code>models.providers.openai.baseUrl</code> points at a local mock or other non-public endpoint, so mocked/custom endpoints no longer drift onto the hardcoded public websocket transport. (#69815) Thanks @vincentkoc.</li>
<li>Channels/config: require resolved runtime config on channel send/action/client helpers and block runtime helper <code>loadConfig()</code> calls, so SecretRefs are resolved at startup/boundaries instead of being re-read during sends.</li>
<li>Discord: pass resolved runtime config through guild and moderation action helpers, so thread-originated Discord commands can run channel, member, role, and guild actions without falling back to runtime config reads. (#70215) Thanks @szponeczek.</li>
<li>CLI/channels: preserve bundled setup promotion metadata when a loaded partial channel plugin omits it, so adding a non-default account still moves legacy single-account fields such as Telegram <code>streaming</code> into <code>accounts.default</code>.</li>
<li>Telegram: keep the sent-message ownership cache isolated per configured session store, so own-message reaction filtering remains correct with custom <code>session.store</code> paths.</li>
<li>Security/update: fail closed when exact pinned npm plugin or hook-pack updates detect integrity drift, and expose aborted plugin drift details in <code>openclaw update --json</code>.</li>
<li>Ollama: forward OpenClaw thinking control to native <code>/api/chat</code> requests as top-level <code>think</code>, so <code>/think off</code> and <code>openclaw agent --thinking off</code> suppress thinking on models such as qwen3 instead of idling until the watchdog fires. Fixes #69902. (#69967) Thanks @WZH8898.</li>
<li>Memory-core/dreaming: suppress the startup-only managed dreaming cron unavailable warning when the cron service is still attaching, while preserving the runtime warning if cron genuinely remains unavailable. Fixes #69939. (#69941) Thanks @Sanjays2402.</li>
<li>Mattermost: suppress reasoning-only payloads even when they arrive as blockquoted <code>> Reasoning:</code> text, preventing <code>/reasoning on</code> from leaking thinking into channel posts. (#69927) Thanks @lawrence3699.</li>
<li>Discord: read <code>channel.parentId</code> through a safe accessor in the slash-command, reaction, and model-picker paths so partial <code>GuildThreadChannel</code> prototype getters no longer throw <code>Cannot access rawData on partial Channel</code> when commands like <code>/new</code> run from inside a thread. Fixes #69861. (#69908) Thanks @neeravmakwana.</li>
<li>Discord: use safe channel name and parent accessors across voice command authorization, so <code>/vc</code> commands from partial Discord thread channels no longer crash on Carbon rawData getters. (#70199) Thanks @hanamizuki.</li>
<li>Discord: make auto-thread parent transcript inheritance opt-in via <code>channels.discord.thread.inheritParent</code>, keeping newly created Discord thread sessions isolated by default while preserving explicit inheritance for configured accounts. Fixes #69907. (#69986) Thanks @Blahdude.</li>
<li>Browser/Chrome MCP: reset cached existing-session control sessions when a <code>navigate_page</code> call times out, so one stuck navigation no longer poisons the browser profile until a gateway restart. (#69733) Thanks @ayeshakhalid192007-dev.</li>
<li>Browser/Chrome MCP: propagate click timeouts and abort signals to existing-session actions so a stuck click fails fast and reconnects instead of poisoning the browser tool until gateway restart. (#63524) Thanks @dongseok0.</li>
<li>Amazon Bedrock/prompt caching: resolve opaque application inference profile targets before injecting Bedrock cache points, require every routed target to support explicit cache points, and retry transient profile lookups instead of caching a false negative for the rest of the process. (#69953) Thanks @anirudhmarc and @vincentkoc.</li>
<li>Gateway/channel health: base stale-socket recovery on provider-proven transport activity instead of inbound app-event freshness, preventing quiet Slack, Discord, Telegram, Matrix, and local-style channels from being restarted solely because no user traffic arrived. (#69833) Thanks @bek91.</li>
<li>OpenCode Go: canonicalize stale bundled <code>opencode-go</code> base URLs from <code>/go</code> or <code>/go/v1</code> to <code>/zen/go</code> or <code>/zen/go/v1</code>, so older generated model metadata stops hitting the 404 HTML endpoint. (#69898)</li>
<li>CLI/channels: honor <code>channels.<id>.enabled=false</code> as a hard read-only presence opt-out, so env vars, manifest env vars, or stale persisted auth state no longer make disabled channel plugins appear in status, doctor, or setup-only discovery.</li>
<li>Channels/preview streaming: centralize draft-preview finalization so Slack, Discord, Mattermost, and Matrix no longer flush temporary preview messages for media/error finals, and preserve first-reply threading for normal fallback delivery.</li>
<li>Discord: keep slash command follow-up chunks ephemeral when the command is configured for ephemeral replies, so long <code>/status</code> output no longer leaks fallback model or runtime details into the public channel. (#69869) thanks @gumadeiras.</li>
<li>Gateway/session history: re-check current auth and <code>chat.history</code> scope before later SSE keepalives and transcript updates, so active session-history streams close before delivering post-revocation events.</li>
<li>Plugins/discovery: reject package plugin source entries that escape the package directory before explicit runtime entries or inferred built JavaScript peers can be used. (#69868) thanks @gumadeiras.</li>
<li>CLI/channels: resolve channel presence through a shared policy that keeps ambient env vars and stale persisted auth from surfacing disabled bundled plugins in status, doctor, security audit, and cron delivery validation unless the channel or plugin is effectively enabled or explicitly configured. (#69862) Thanks @gumadeiras.</li>
<li>Doctor/plugins: hydrate legacy partial interactive handler state before plugin reload clears dedupe caches, so <code>openclaw doctor</code> and post-update doctor runs no longer crash with <code>Cannot read properties of undefined (reading 'clear')</code>. (#70135) Thanks @ngutman.</li>
<li>Control UI/config: preserve intentionally empty raw config snapshots when clearing pending updates so reset restores the original bytes instead of synthesizing JSON for blank config files. (#68178) Thanks @BunsDev.</li>
<li>memory-core/dreaming: surface a <code>Dreaming status: blocked</code> line in <code>openclaw memory status</code> when dreaming is enabled but the heartbeat that drives the managed cron is not firing for the default agent, and add a Troubleshooting section to the dreaming docs covering the two common causes (per-agent <code>heartbeat</code> blocks excluding <code>main</code>, and <code>heartbeat.every</code> set to <code>0</code>/empty/invalid), so the silent failure described in #69843 becomes legible on the status surface.</li>
<li>Cron/run-log: report generic <code>message</code> tool sends under the resolved delivery channel when they match the cron target, while preserving account-specific mismatch checks for delivery traces. (#69940) Thanks @davehappyminion.</li>
<li>Doctor/channels: merge configured-channel doctor hooks across read-only, loaded, setup, and runtime plugin discovery so partial adapters no longer hide runtime-only compatibility repair or allowlist warnings, preserve disabled-channel opt-outs, and ignore malformed hook values before they can mask valid fallbacks. (#69919) Thanks @gumadeiras.</li>
<li>Models/CLI: show bundled provider-owned static catalog rows in <code>models list --all</code> before auth is configured, including Kimi K2.6 rows for Moonshot, OpenRouter, and Vercel AI Gateway, while keeping local-only and workspace plugin catalog paths isolated. (#69909) Thanks @shakkernerd.</li>
<li>Models/CLI: clarify that <code>models list --provider</code> expects provider ids and reject display labels before loading model discovery. (#70504) Thanks @shakkernerd.</li>
<li>Configure: skip generic CLI startup bootstrap for <code>openclaw configure</code> and bound hint-only gateway probes so the onboarding TUI reaches its first prompt faster when the Gateway is unavailable. (#69984) Thanks @obviyus.</li>
<li>Agents/harness: surface selected plugin harness failures directly instead of replaying the same turn through embedded PI, preventing misleading secondary PI auth errors and avoiding duplicate side effects.</li>
<li>OpenAI Codex: add a ChatGPT device-code auth option beside browser OAuth, so headless or callback-hostile setups can sign in without relying on the localhost browser callback. (#69557) Thanks @vincentkoc.</li>
<li>CLI sessions: keep provider-owned CLI sessions through implicit daily expiry while preserving explicit reset behavior, and retain Claude CLI binding metadata across gateway agent requests. (#70106) Thanks @obviyus.</li>
<li>fix(config): accept truncateAfterCompaction (#68395). Thanks @MonkeyLeeT</li>
<li>CLI/Claude: keep Claude CLI session bindings stable across OAuth access-token refreshes, so gateway restarts continue the same Claude conversation instead of minting a fresh one. (#70132) Thanks @obviyus.</li>
<li>QQBot: add <code>INTERACTION</code> intent (<code>1 << 26</code>) to the gateway constants and include it in the <code>FULL_INTENTS</code> mask so interaction events are received. (#70143) Thanks @cxyhhhhh.</li>
<li>Gateway/restart: preserve one-shot continuation instructions across gateway restarts so agents can resume and reply back to the original chat after reboot. (#63406) Thanks @VACInc.</li>
<li>Gateway/restart: write restart sentinel files atomically so interrupted writes cannot leave a truncated sentinel behind. (#70225) Thanks @obviyus.</li>
<li>Pairing: remove stale pending requests for a device when that paired device is deleted, so an old repair approval cannot recreate the removed device from leftover state.</li>
<li>Security/dotenv: block workspace <code>.env</code> overrides for Matrix, Mattermost, IRC, and Synology endpoint settings so cloned workspaces cannot redirect bundled connector traffic through local endpoint config. (#70240) Thanks @drobison00.</li>
<li>Telegram: require the same <code>/models</code> authorization for group model-picker callbacks, so unauthorized participants can no longer browse or change the session model through inline buttons. (#70235) Thanks @drobison00.</li>
<li>Agents/Pi: keep the filtered tool-name allowlist active for embedded OpenAI/OpenAI Codex GPT-5 runs and compaction sessions, so bundled and client tools still execute after the Pi <code>0.68.1</code> session-tool allowlist change instead of stopping at plan-only replies with no tool call. (#70281) Thanks @jalehman.</li>
<li>Agents/Pi: honor explicit <code>strict-agentic</code> execution contracts for incomplete-turn retry guards across providers, so manually opted-in local or compatible models get the same retry behavior without relying on OpenAI model inference. (#66750) Thanks @ziomancer.</li>
<li>OpenShell/sandbox: pin verified file reads to an already-opened descriptor, walk the ancestor chain for symlinked parents on platforms without fd-path readlink, and re-check file identity so parent symlink swaps cannot redirect in-sandbox reads to host files outside the allowed mount root. (#69798) Thanks @drobison00.</li>
<li>Gateway/Control UI: require authenticated Control UI read access before serving <code>/__openclaw/control-ui-config.json</code> when <code>gateway.auth</code> is enabled, so unauthenticated callers can no longer read bootstrap metadata. (#70247) Thanks @drobison00.</li>
<li>Gateway/restart: default session-scoped restart sentinels to a one-shot agent continuation, so chat-initiated Gateway restarts acknowledge successful boot automatically. (#70269) Thanks @obviyus.</li>
<li>Build/npm publish: fail postpublish verification when root <code>dist/*</code> files import bundled plugin runtime dependencies without mirroring them in the root package manifest, so Slack-style plugin deps cannot silently ship on the wrong module-resolution path again. (#60112) thanks @medns.</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.4.22/OpenClaw-2026.4.22.zip" length="47883836" type="application/octet-stream" sparkle:edSignature="kzJ2j2sWX4H+ZIc4dXEFORYr9tk3w1txpjCJ38cdSFz6yWHU0M6Sx9zN0DB7JGIpv1QC+D+jFbWBkl4SJqW2AA=="/>
</item>
</channel>
</rss>

View File

@@ -1,19 +0,0 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.{kt,kts}]
indent_style = space
indent_size = 2
max_line_length = off
ktlint_standard_filename = disabled
ktlint_standard_function-expression-body = disabled
ktlint_standard_function-naming = disabled
ktlint_standard_if-else-bracing = disabled
ktlint_standard_max-line-length = disabled
ktlint_standard_no-wildcard-imports = disabled
ktlint_standard_property-naming = disabled

View File

@@ -15,7 +15,6 @@ Status: **extremely alpha**. The app is actively being rebuilt from the ground u
- [x] Request camera/location and other permissions in onboarding/settings flow
- [x] Push notifications for gateway/chat status updates
- [x] Security hardening (biometric lock, token handling, safer defaults)
- [x] Authenticated background presence beacons
- [x] Voice tab full functionality
- [x] Screen tab full functionality
- [ ] Full end-to-end QA and release hardening

View File

@@ -7,286 +7,284 @@ val androidStorePassword = providers.gradleProperty("OPENCLAW_ANDROID_STORE_PASS
val androidKeyAlias = providers.gradleProperty("OPENCLAW_ANDROID_KEY_ALIAS").orNull?.takeIf { it.isNotBlank() }
val androidKeyPassword = providers.gradleProperty("OPENCLAW_ANDROID_KEY_PASSWORD").orNull?.takeIf { it.isNotBlank() }
val resolvedAndroidStoreFile =
androidStoreFile?.let { storeFilePath ->
if (storeFilePath.startsWith("~/")) {
"${System.getProperty("user.home")}/${storeFilePath.removePrefix("~/")}"
} else {
storeFilePath
androidStoreFile?.let { storeFilePath ->
if (storeFilePath.startsWith("~/")) {
"${System.getProperty("user.home")}/${storeFilePath.removePrefix("~/")}"
} else {
storeFilePath
}
}
}
val hasAndroidReleaseSigning =
listOf(resolvedAndroidStoreFile, androidStorePassword, androidKeyAlias, androidKeyPassword).all { it != null }
listOf(resolvedAndroidStoreFile, androidStorePassword, androidKeyAlias, androidKeyPassword).all { it != null }
val wantsAndroidReleaseBuild =
gradle.startParameter.taskNames.any { taskName ->
taskName.contains("Release", ignoreCase = true) ||
Regex("""(^|:)(bundle|assemble)$""").containsMatchIn(taskName)
}
gradle.startParameter.taskNames.any { taskName ->
taskName.contains("Release", ignoreCase = true) ||
Regex("""(^|:)(bundle|assemble)$""").containsMatchIn(taskName)
}
if (wantsAndroidReleaseBuild && !hasAndroidReleaseSigning) {
error(
"Missing Android release signing properties. Set OPENCLAW_ANDROID_STORE_FILE, " +
"OPENCLAW_ANDROID_STORE_PASSWORD, OPENCLAW_ANDROID_KEY_ALIAS, and " +
"OPENCLAW_ANDROID_KEY_PASSWORD in ~/.gradle/gradle.properties.",
)
error(
"Missing Android release signing properties. Set OPENCLAW_ANDROID_STORE_FILE, " +
"OPENCLAW_ANDROID_STORE_PASSWORD, OPENCLAW_ANDROID_KEY_ALIAS, and " +
"OPENCLAW_ANDROID_KEY_PASSWORD in ~/.gradle/gradle.properties.",
)
}
plugins {
id("com.android.application")
id("org.jlleitschuh.gradle.ktlint")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
id("com.android.application")
id("org.jlleitschuh.gradle.ktlint")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
}
android {
namespace = "ai.openclaw.app"
compileSdk = 36
namespace = "ai.openclaw.app"
compileSdk = 36
// Release signing is local-only; keep the keystore path and passwords out of the repo.
signingConfigs {
if (hasAndroidReleaseSigning) {
create("release") {
storeFile = project.file(checkNotNull(resolvedAndroidStoreFile))
storePassword = checkNotNull(androidStorePassword)
keyAlias = checkNotNull(androidKeyAlias)
keyPassword = checkNotNull(androidKeyPassword)
}
// Release signing is local-only; keep the keystore path and passwords out of the repo.
signingConfigs {
if (hasAndroidReleaseSigning) {
create("release") {
storeFile = project.file(checkNotNull(resolvedAndroidStoreFile))
storePassword = checkNotNull(androidStorePassword)
keyAlias = checkNotNull(androidKeyAlias)
keyPassword = checkNotNull(androidKeyPassword)
}
}
}
}
sourceSets {
getByName("main") {
assets.directories.add("../../shared/OpenClawKit/Sources/OpenClawKit/Resources")
sourceSets {
getByName("main") {
assets.directories.add("../../shared/OpenClawKit/Sources/OpenClawKit/Resources")
}
}
}
defaultConfig {
applicationId = "ai.openclaw.app"
minSdk = 31
targetSdk = 36
versionCode = 2026042700
versionName = "2026.4.27"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
defaultConfig {
applicationId = "ai.openclaw.app"
minSdk = 31
targetSdk = 36
versionCode = 2026042600
versionName = "2026.4.26"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
}
}
}
flavorDimensions += "store"
flavorDimensions += "store"
productFlavors {
create("play") {
dimension = "store"
buildConfigField("boolean", "OPENCLAW_ENABLE_SMS", "false")
buildConfigField("boolean", "OPENCLAW_ENABLE_CALL_LOG", "false")
productFlavors {
create("play") {
dimension = "store"
buildConfigField("boolean", "OPENCLAW_ENABLE_SMS", "false")
buildConfigField("boolean", "OPENCLAW_ENABLE_CALL_LOG", "false")
}
create("thirdParty") {
dimension = "store"
buildConfigField("boolean", "OPENCLAW_ENABLE_SMS", "true")
buildConfigField("boolean", "OPENCLAW_ENABLE_CALL_LOG", "true")
}
}
create("thirdParty") {
dimension = "store"
buildConfigField("boolean", "OPENCLAW_ENABLE_SMS", "true")
buildConfigField("boolean", "OPENCLAW_ENABLE_CALL_LOG", "true")
buildTypes {
release {
if (hasAndroidReleaseSigning) {
signingConfig = signingConfigs.getByName("release")
}
isMinifyEnabled = true
isShrinkResources = true
ndk {
debugSymbolLevel = "SYMBOL_TABLE"
}
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
debug {
isMinifyEnabled = false
}
}
}
buildTypes {
release {
if (hasAndroidReleaseSigning) {
signingConfig = signingConfigs.getByName("release")
}
isMinifyEnabled = true
isShrinkResources = true
ndk {
debugSymbolLevel = "SYMBOL_TABLE"
}
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
buildFeatures {
compose = true
buildConfig = true
}
debug {
isMinifyEnabled = false
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
buildFeatures {
compose = true
buildConfig = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
packaging {
resources {
excludes +=
setOf(
"/META-INF/{AL2.0,LGPL2.1}",
"/META-INF/*.version",
"/META-INF/LICENSE*.txt",
"DebugProbesKt.bin",
"kotlin-tooling-metadata.json",
"org/bouncycastle/pqc/crypto/picnic/lowmcL1.bin.properties",
"org/bouncycastle/pqc/crypto/picnic/lowmcL3.bin.properties",
"org/bouncycastle/pqc/crypto/picnic/lowmcL5.bin.properties",
"org/bouncycastle/x509/CertPathReviewerMessages*.properties",
)
packaging {
resources {
excludes +=
setOf(
"/META-INF/{AL2.0,LGPL2.1}",
"/META-INF/*.version",
"/META-INF/LICENSE*.txt",
"DebugProbesKt.bin",
"kotlin-tooling-metadata.json",
"org/bouncycastle/pqc/crypto/picnic/lowmcL1.bin.properties",
"org/bouncycastle/pqc/crypto/picnic/lowmcL3.bin.properties",
"org/bouncycastle/pqc/crypto/picnic/lowmcL5.bin.properties",
"org/bouncycastle/x509/CertPathReviewerMessages*.properties",
)
}
}
}
lint {
disable +=
setOf(
"AndroidGradlePluginVersion",
"GradleDependency",
"HighAppVersionCode",
"IconLauncherShape",
"NewerVersionAvailable",
"OldTargetApi",
)
warningsAsErrors = true
}
lint {
disable +=
setOf(
"AndroidGradlePluginVersion",
"GradleDependency",
"IconLauncherShape",
"NewerVersionAvailable",
)
warningsAsErrors = true
}
testOptions {
unitTests.isIncludeAndroidResources = true
}
testOptions {
unitTests.isIncludeAndroidResources = true
}
}
androidComponents {
onVariants { variant ->
variant.outputs
.filterIsInstance<VariantOutputImpl>()
.forEach { output ->
val versionName = output.versionName.orNull ?: "0"
val buildType = variant.buildType
val flavorName = variant.flavorName?.takeIf { it.isNotBlank() }
val outputFileName =
if (flavorName == null) {
"openclaw-$versionName-$buildType.apk"
} else {
"openclaw-$versionName-$flavorName-$buildType.apk"
}
output.outputFileName = outputFileName
}
}
onVariants { variant ->
variant.outputs
.filterIsInstance<VariantOutputImpl>()
.forEach { output ->
val versionName = output.versionName.orNull ?: "0"
val buildType = variant.buildType
val flavorName = variant.flavorName?.takeIf { it.isNotBlank() }
val outputFileName =
if (flavorName == null) {
"openclaw-$versionName-$buildType.apk"
} else {
"openclaw-$versionName-$flavorName-$buildType.apk"
}
output.outputFileName = outputFileName
}
}
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
allWarningsAsErrors.set(true)
}
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
allWarningsAsErrors.set(true)
}
}
ktlint {
android.set(true)
ignoreFailures.set(false)
filter {
exclude("**/build/**")
}
android.set(true)
ignoreFailures.set(false)
filter {
exclude("**/build/**")
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2026.04.01")
implementation(composeBom)
androidTestImplementation(composeBom)
val composeBom = platform("androidx.compose:compose-bom:2026.03.01")
implementation(composeBom)
androidTestImplementation(composeBom)
implementation("androidx.core:core-ktx:1.18.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
implementation("androidx.activity:activity-compose:1.13.0")
implementation("androidx.webkit:webkit:1.15.0")
implementation("androidx.core:core-ktx:1.17.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
implementation("androidx.activity:activity-compose:1.13.0")
implementation("androidx.webkit:webkit:1.15.0")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
// material-icons-extended pulled in full icon set (~20 MB DEX). Only ~18 icons used.
// R8 will tree-shake unused icons when minify is enabled on release builds.
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
// material-icons-extended pulled in full icon set (~20 MB DEX). Only ~18 icons used.
// R8 will tree-shake unused icons when minify is enabled on release builds.
implementation("androidx.compose.material:material-icons-extended")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-tooling")
// Material Components (XML theme + resources)
implementation("com.google.android.material:material:1.13.0")
// Material Components (XML theme + resources)
implementation("com.google.android.material:material:1.13.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
implementation("androidx.security:security-crypto:1.1.0")
implementation("androidx.exifinterface:exifinterface:1.4.2")
implementation("com.squareup.okhttp3:okhttp:5.3.2")
implementation("org.bouncycastle:bcprov-jdk18on:1.84")
implementation("org.commonmark:commonmark:0.28.0")
implementation("org.commonmark:commonmark-ext-autolink:0.28.0")
implementation("org.commonmark:commonmark-ext-gfm-strikethrough:0.28.0")
implementation("org.commonmark:commonmark-ext-gfm-tables:0.28.0")
implementation("org.commonmark:commonmark-ext-task-list-items:0.28.0")
implementation("androidx.security:security-crypto:1.1.0")
implementation("androidx.exifinterface:exifinterface:1.4.2")
implementation("com.squareup.okhttp3:okhttp:5.3.2")
implementation("org.bouncycastle:bcprov-jdk18on:1.84")
implementation("org.commonmark:commonmark:0.28.0")
implementation("org.commonmark:commonmark-ext-autolink:0.28.0")
implementation("org.commonmark:commonmark-ext-gfm-strikethrough:0.28.0")
implementation("org.commonmark:commonmark-ext-gfm-tables:0.28.0")
implementation("org.commonmark:commonmark-ext-task-list-items:0.28.0")
// CameraX (for node.invoke camera.* parity)
implementation("androidx.camera:camera-core:1.6.0")
implementation("androidx.camera:camera-camera2:1.6.0")
implementation("androidx.camera:camera-lifecycle:1.6.0")
implementation("androidx.camera:camera-video:1.6.0")
implementation("com.google.android.gms:play-services-code-scanner:16.1.0")
// CameraX (for node.invoke camera.* parity)
implementation("androidx.camera:camera-core:1.5.2")
implementation("androidx.camera:camera-camera2:1.5.2")
implementation("androidx.camera:camera-lifecycle:1.5.2")
implementation("androidx.camera:camera-video:1.5.2")
implementation("com.google.android.gms:play-services-code-scanner:16.1.0")
// Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains.
implementation("dnsjava:dnsjava:3.6.4")
// Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains.
implementation("dnsjava:dnsjava:3.6.4")
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.1.11")
testImplementation("io.kotest:kotest-assertions-core-jvm:6.1.11")
testImplementation("com.squareup.okhttp3:mockwebserver:5.3.2")
testImplementation("org.robolectric:robolectric:4.16.1")
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.3")
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.1.11")
testImplementation("io.kotest:kotest-assertions-core-jvm:6.1.11")
testImplementation("com.squareup.okhttp3:mockwebserver:5.3.2")
testImplementation("org.robolectric:robolectric:4.16.1")
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.3")
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
useJUnitPlatform()
}
androidComponents {
onVariants(selector().withBuildType("release")) { variant ->
val variantName = variant.name
val variantNameCapitalized = variantName.replaceFirstChar(Char::titlecase)
val stripTaskName = "strip${variantNameCapitalized}DnsjavaServiceDescriptor"
val mergeTaskName = "merge${variantNameCapitalized}JavaResource"
val minifyTaskName = "minify${variantNameCapitalized}WithR8"
val mergedJar =
layout.buildDirectory.file(
"intermediates/merged_java_res/$variantName/$mergeTaskName/base.jar",
)
onVariants(selector().withBuildType("release")) { variant ->
val variantName = variant.name
val variantNameCapitalized = variantName.replaceFirstChar(Char::titlecase)
val stripTaskName = "strip${variantNameCapitalized}DnsjavaServiceDescriptor"
val mergeTaskName = "merge${variantNameCapitalized}JavaResource"
val minifyTaskName = "minify${variantNameCapitalized}WithR8"
val mergedJar =
layout.buildDirectory.file(
"intermediates/merged_java_res/$variantName/$mergeTaskName/base.jar",
)
val stripTask =
tasks.register(stripTaskName) {
inputs.file(mergedJar)
outputs.file(mergedJar)
val stripTask =
tasks.register(stripTaskName) {
inputs.file(mergedJar)
outputs.file(mergedJar)
doLast {
val jarFile = mergedJar.get().asFile
if (!jarFile.exists()) {
return@doLast
}
doLast {
val jarFile = mergedJar.get().asFile
if (!jarFile.exists()) {
return@doLast
}
val unpackDir = temporaryDir.resolve("merged-java-res")
delete(unpackDir)
copy {
from(zipTree(jarFile))
into(unpackDir)
exclude(dnsjavaInetAddressResolverService)
}
delete(jarFile)
ant.invokeMethod(
"zip",
mapOf(
"destfile" to jarFile.absolutePath,
"basedir" to unpackDir.absolutePath,
),
)
val unpackDir = temporaryDir.resolve("merged-java-res")
delete(unpackDir)
copy {
from(zipTree(jarFile))
into(unpackDir)
exclude(dnsjavaInetAddressResolverService)
}
delete(jarFile)
ant.invokeMethod(
"zip",
mapOf(
"destfile" to jarFile.absolutePath,
"basedir" to unpackDir.absolutePath,
),
)
}
}
tasks.matching { it.name == mergeTaskName }.configureEach {
finalizedBy(stripTask)
}
tasks.matching { it.name == minifyTaskName }.configureEach {
dependsOn(stripTask)
}
}
tasks.matching { it.name == mergeTaskName }.configureEach {
finalizedBy(stripTask)
}
tasks.matching { it.name == minifyTaskName }.configureEach {
dependsOn(stripTask)
}
}
}

View File

@@ -12,6 +12,8 @@
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-permission android:name="android.permission.READ_SMS" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
<uses-permission
@@ -19,6 +21,7 @@
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
<uses-permission android:name="android.permission.READ_CALL_LOG" />
<uses-permission android:name="android.permission.READ_CALENDAR" />
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
@@ -38,7 +41,7 @@
<application
android:name=".NodeApp"
android:allowBackup="false"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"

View File

@@ -8,8 +8,9 @@ object DeviceNames {
fun bestDefaultNodeName(context: Context): String {
val deviceName =
runCatching {
Settings.Global.getString(context.contentResolver, "device_name")
}.getOrNull()
Settings.Global.getString(context.contentResolver, "device_name")
}
.getOrNull()
?.trim()
.orEmpty()

View File

@@ -1,8 +1,6 @@
package ai.openclaw.app
enum class LocationMode(
val rawValue: String,
) {
enum class LocationMode(val rawValue: String) {
Off("off"),
WhileUsing("whileUsing"),
;

View File

@@ -1,18 +1,18 @@
package ai.openclaw.app
import ai.openclaw.app.ui.OpenClawTheme
import ai.openclaw.app.ui.RootScreen
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.core.view.WindowCompat
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.core.view.WindowCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import ai.openclaw.app.ui.RootScreen
import ai.openclaw.app.ui.OpenClawTheme
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {

View File

@@ -1,5 +1,9 @@
package ai.openclaw.app
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.viewModelScope
import ai.openclaw.app.chat.ChatMessage
import ai.openclaw.app.chat.ChatPendingToolCall
import ai.openclaw.app.chat.ChatSessionEntry
@@ -9,10 +13,6 @@ import ai.openclaw.app.node.CameraCaptureManager
import ai.openclaw.app.node.CanvasController
import ai.openclaw.app.node.SmsManager
import ai.openclaw.app.voice.VoiceConversationEntry
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -22,9 +22,7 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
@OptIn(ExperimentalCoroutinesApi::class)
class MainViewModel(
app: Application,
) : AndroidViewModel(app) {
class MainViewModel(app: Application) : AndroidViewModel(app) {
private val nodeApp = app as NodeApp
private val prefs = nodeApp.prefs
private val runtimeRef = MutableStateFlow<NodeRuntime?>(null)
@@ -145,10 +143,7 @@ class MainViewModel(
val sms: SmsManager
get() = ensureRuntime().sms
fun attachRuntimeUi(
owner: LifecycleOwner,
permissionRequester: PermissionRequester,
) {
fun attachRuntimeUi(owner: LifecycleOwner, permissionRequester: PermissionRequester) {
val runtime = runtimeRef.value ?: return
runtime.camera.attachLifecycleOwner(owner)
runtime.camera.attachPermissionRequester(permissionRequester)
@@ -250,7 +245,9 @@ class MainViewModel(
enabled: Boolean,
start: String,
end: String,
): Boolean = ensureRuntime().setNotificationForwardingQuietHours(enabled = enabled, start = start, end = end)
): Boolean {
return ensureRuntime().setNotificationForwardingQuietHours(enabled = enabled, start = start, end = end)
}
fun setNotificationForwardingMaxEventsPerMinute(value: Int) {
ensureRuntime().setNotificationForwardingMaxEventsPerMinute(value)
@@ -343,7 +340,9 @@ class MainViewModel(
ensureRuntime().handleCanvasA2UIActionFromWebView(payloadJson)
}
fun isTrustedCanvasActionUrl(rawUrl: String?): Boolean = ensureRuntime().isTrustedCanvasActionUrl(rawUrl)
fun isTrustedCanvasActionUrl(rawUrl: String?): Boolean {
return ensureRuntime().isTrustedCanvasActionUrl(rawUrl)
}
fun requestCanvasRehydrate(source: String = "screen_tab") {
ensureRuntime().requestCanvasRehydrate(source = source, force = true)
@@ -377,11 +376,7 @@ class MainViewModel(
ensureRuntime().abortChat()
}
fun sendChat(
message: String,
thinking: String,
attachments: List<OutgoingAttachment>,
) {
fun sendChat(message: String, thinking: String, attachments: List<OutgoingAttachment>) {
ensureRuntime().sendChat(message = message, thinking = thinking, attachments = attachments)
}
@@ -389,10 +384,11 @@ class MainViewModel(
message: String,
thinking: String,
attachments: List<OutgoingAttachment>,
): Boolean =
ensureRuntime().sendChatAwaitAcceptance(
): Boolean {
return ensureRuntime().sendChatAwaitAcceptance(
message = message,
thinking = thinking,
attachments = attachments,
)
}
}

View File

@@ -21,15 +21,13 @@ class NodeApp : Application() {
super.onCreate()
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy
.Builder()
StrictMode.ThreadPolicy.Builder()
.detectAll()
.penaltyLog()
.build(),
)
StrictMode.setVmPolicy(
StrictMode.VmPolicy
.Builder()
StrictMode.VmPolicy.Builder()
.detectAll()
.penaltyLog()
.build(),

View File

@@ -140,14 +140,10 @@ class NodeForegroundService : Service() {
mgr.createNotificationChannel(channel)
}
private fun buildNotification(
title: String,
text: String,
): Notification {
val launchIntent =
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
private fun buildNotification(title: String, text: String): Notification {
val launchIntent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val launchPending =
PendingIntent.getActivity(
this,
@@ -165,8 +161,7 @@ class NodeForegroundService : Service() {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat
.Builder(this, CHANNEL_ID)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title)
.setContentText(text)
@@ -238,8 +233,8 @@ internal fun voiceNotificationSuffix(
manualMicListening: Boolean,
talkListening: Boolean,
talkSpeaking: Boolean,
): String =
when (mode) {
): String {
return when (mode) {
VoiceCaptureMode.TalkMode ->
when {
talkSpeaking -> " · Talk: Speaking"
@@ -254,11 +249,11 @@ internal fun voiceNotificationSuffix(
}
VoiceCaptureMode.Off -> ""
}
}
private fun String?.toVoiceCaptureMode(): VoiceCaptureMode =
VoiceCaptureMode.entries.firstOrNull {
it.name == this
} ?: VoiceCaptureMode.Off
private fun String?.toVoiceCaptureMode(): VoiceCaptureMode {
return VoiceCaptureMode.entries.firstOrNull { it.name == this } ?: VoiceCaptureMode.Off
}
private data class VoiceNotificationBase(
val status: String,

View File

@@ -1,5 +1,11 @@
package ai.openclaw.app
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.SystemClock
import android.util.Log
import androidx.core.content.ContextCompat
import ai.openclaw.app.chat.ChatController
import ai.openclaw.app.chat.ChatMessage
import ai.openclaw.app.chat.ChatPendingToolCall
@@ -18,12 +24,6 @@ import ai.openclaw.app.protocol.OpenClawCanvasA2UIAction
import ai.openclaw.app.voice.MicCaptureManager
import ai.openclaw.app.voice.TalkModeManager
import ai.openclaw.app.voice.VoiceConversationEntry
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.SystemClock
import android.util.Log
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -75,137 +75,122 @@ class NodeRuntime(
private var connectedEndpoint: GatewayEndpoint? = null
private var activeGatewayAuth: GatewayConnectAuth? = null
private val cameraHandler: CameraHandler =
CameraHandler(
appContext = appContext,
camera = camera,
externalAudioCaptureActive = externalAudioCaptureActive,
showCameraHud = ::showCameraHud,
triggerCameraFlash = ::triggerCameraFlash,
invokeErrorFromThrowable = { invokeErrorFromThrowable(it) },
)
private val cameraHandler: CameraHandler = CameraHandler(
appContext = appContext,
camera = camera,
externalAudioCaptureActive = externalAudioCaptureActive,
showCameraHud = ::showCameraHud,
triggerCameraFlash = ::triggerCameraFlash,
invokeErrorFromThrowable = { invokeErrorFromThrowable(it) },
)
private val debugHandler: DebugHandler =
DebugHandler(
appContext = appContext,
identityStore = identityStore,
)
private val debugHandler: DebugHandler = DebugHandler(
appContext = appContext,
identityStore = identityStore,
)
private val locationHandler: LocationHandler =
LocationHandler(
appContext = appContext,
location = location,
json = json,
isForeground = { _isForeground.value },
locationPreciseEnabled = { locationPreciseEnabled.value },
)
private val locationHandler: LocationHandler = LocationHandler(
appContext = appContext,
location = location,
json = json,
isForeground = { _isForeground.value },
locationPreciseEnabled = { locationPreciseEnabled.value },
)
private val deviceHandler: DeviceHandler =
DeviceHandler(
appContext = appContext,
smsEnabled = BuildConfig.OPENCLAW_ENABLE_SMS,
callLogEnabled = BuildConfig.OPENCLAW_ENABLE_CALL_LOG,
)
private val deviceHandler: DeviceHandler = DeviceHandler(
appContext = appContext,
smsEnabled = BuildConfig.OPENCLAW_ENABLE_SMS,
callLogEnabled = BuildConfig.OPENCLAW_ENABLE_CALL_LOG,
)
private val notificationsHandler: NotificationsHandler =
NotificationsHandler(
appContext = appContext,
)
private val notificationsHandler: NotificationsHandler = NotificationsHandler(
appContext = appContext,
)
private val systemHandler: SystemHandler =
SystemHandler(
appContext = appContext,
)
private val systemHandler: SystemHandler = SystemHandler(
appContext = appContext,
)
private val photosHandler: PhotosHandler =
PhotosHandler(
appContext = appContext,
)
private val photosHandler: PhotosHandler = PhotosHandler(
appContext = appContext,
)
private val contactsHandler: ContactsHandler =
ContactsHandler(
appContext = appContext,
)
private val contactsHandler: ContactsHandler = ContactsHandler(
appContext = appContext,
)
private val calendarHandler: CalendarHandler =
CalendarHandler(
appContext = appContext,
)
private val calendarHandler: CalendarHandler = CalendarHandler(
appContext = appContext,
)
private val callLogHandler: CallLogHandler =
CallLogHandler(
appContext = appContext,
)
private val callLogHandler: CallLogHandler = CallLogHandler(
appContext = appContext,
)
private val motionHandler: MotionHandler =
MotionHandler(
appContext = appContext,
)
private val motionHandler: MotionHandler = MotionHandler(
appContext = appContext,
)
private val smsHandlerImpl: SmsHandler =
SmsHandler(
sms = sms,
)
private val smsHandlerImpl: SmsHandler = SmsHandler(
sms = sms,
)
private val a2uiHandler: A2UIHandler =
A2UIHandler(
canvas = canvas,
json = json,
getNodeCanvasHostUrl = { nodeSession.currentCanvasHostUrl() },
getOperatorCanvasHostUrl = { operatorSession.currentCanvasHostUrl() },
)
private val a2uiHandler: A2UIHandler = A2UIHandler(
canvas = canvas,
json = json,
getNodeCanvasHostUrl = { nodeSession.currentCanvasHostUrl() },
getOperatorCanvasHostUrl = { operatorSession.currentCanvasHostUrl() },
)
private val connectionManager: ConnectionManager =
ConnectionManager(
prefs = prefs,
cameraEnabled = { cameraEnabled.value },
locationMode = { locationMode.value },
voiceWakeMode = { VoiceWakeMode.Off },
motionActivityAvailable = { motionHandler.isActivityAvailable() },
motionPedometerAvailable = { motionHandler.isPedometerAvailable() },
sendSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canSendSms() },
readSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canReadSms() },
smsSearchPossible = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.hasTelephonyFeature() },
callLogAvailable = { BuildConfig.OPENCLAW_ENABLE_CALL_LOG },
hasRecordAudioPermission = { hasRecordAudioPermission() },
manualTls = { manualTls.value },
)
private val connectionManager: ConnectionManager = ConnectionManager(
prefs = prefs,
cameraEnabled = { cameraEnabled.value },
locationMode = { locationMode.value },
voiceWakeMode = { VoiceWakeMode.Off },
motionActivityAvailable = { motionHandler.isActivityAvailable() },
motionPedometerAvailable = { motionHandler.isPedometerAvailable() },
sendSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canSendSms() },
readSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canReadSms() },
smsSearchPossible = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.hasTelephonyFeature() },
callLogAvailable = { BuildConfig.OPENCLAW_ENABLE_CALL_LOG },
hasRecordAudioPermission = { hasRecordAudioPermission() },
manualTls = { manualTls.value },
)
private val invokeDispatcher: InvokeDispatcher =
InvokeDispatcher(
canvas = canvas,
cameraHandler = cameraHandler,
locationHandler = locationHandler,
deviceHandler = deviceHandler,
notificationsHandler = notificationsHandler,
systemHandler = systemHandler,
photosHandler = photosHandler,
contactsHandler = contactsHandler,
calendarHandler = calendarHandler,
motionHandler = motionHandler,
smsHandler = smsHandlerImpl,
a2uiHandler = a2uiHandler,
debugHandler = debugHandler,
callLogHandler = callLogHandler,
isForeground = { _isForeground.value },
cameraEnabled = { cameraEnabled.value },
locationEnabled = { locationMode.value != LocationMode.Off },
sendSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canSendSms() },
readSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canReadSms() },
smsFeatureEnabled = { BuildConfig.OPENCLAW_ENABLE_SMS },
smsTelephonyAvailable = { sms.hasTelephonyFeature() },
callLogAvailable = { BuildConfig.OPENCLAW_ENABLE_CALL_LOG },
debugBuild = { BuildConfig.DEBUG },
refreshNodeCanvasCapability = { nodeSession.refreshNodeCanvasCapability() },
onCanvasA2uiPush = {
_canvasA2uiHydrated.value = true
_canvasRehydratePending.value = false
_canvasRehydrateErrorText.value = null
},
onCanvasA2uiReset = { _canvasA2uiHydrated.value = false },
motionActivityAvailable = { motionHandler.isActivityAvailable() },
motionPedometerAvailable = { motionHandler.isPedometerAvailable() },
)
private val invokeDispatcher: InvokeDispatcher = InvokeDispatcher(
canvas = canvas,
cameraHandler = cameraHandler,
locationHandler = locationHandler,
deviceHandler = deviceHandler,
notificationsHandler = notificationsHandler,
systemHandler = systemHandler,
photosHandler = photosHandler,
contactsHandler = contactsHandler,
calendarHandler = calendarHandler,
motionHandler = motionHandler,
smsHandler = smsHandlerImpl,
a2uiHandler = a2uiHandler,
debugHandler = debugHandler,
callLogHandler = callLogHandler,
isForeground = { _isForeground.value },
cameraEnabled = { cameraEnabled.value },
locationEnabled = { locationMode.value != LocationMode.Off },
sendSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canSendSms() },
readSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canReadSms() },
smsFeatureEnabled = { BuildConfig.OPENCLAW_ENABLE_SMS },
smsTelephonyAvailable = { sms.hasTelephonyFeature() },
callLogAvailable = { BuildConfig.OPENCLAW_ENABLE_CALL_LOG },
debugBuild = { BuildConfig.DEBUG },
refreshNodeCanvasCapability = { nodeSession.refreshNodeCanvasCapability() },
onCanvasA2uiPush = {
_canvasA2uiHydrated.value = true
_canvasRehydratePending.value = false
_canvasRehydrateErrorText.value = null
},
onCanvasA2uiReset = { _canvasA2uiHydrated.value = false },
motionActivityAvailable = { motionHandler.isActivityAvailable() },
motionPedometerAvailable = { motionHandler.isPedometerAvailable() },
)
data class GatewayTrustPrompt(
val endpoint: GatewayEndpoint,
@@ -262,8 +247,6 @@ class NodeRuntime(
private var gatewayAgents: List<GatewayAgentSummary> = emptyList()
private var didAutoRequestCanvasRehydrate = false
private val canvasRehydrateSeq = AtomicLong(0)
@Volatile private var nodePresenceAliveLastSuccessAtMs: Long? = null
private var operatorConnected = false
private var operatorStatusText: String = "Offline"
private var nodeStatusText: String = "Offline"
@@ -319,7 +302,6 @@ class NodeRuntime(
_canvasRehydrateErrorText.value = null
updateStatus()
showLocalCanvasOnConnect()
publishNodePresenceAliveBeacon(NodePresenceAliveBeacon.Trigger.Connect)
val endpoint = connectedEndpoint
val auth = activeGatewayAuth
if (endpoint != null && auth != null) {
@@ -362,22 +344,21 @@ class NodeRuntime(
).also {
it.applyMainSessionKey(_mainSessionKey.value)
}
private val voiceReplySpeakerLazy: Lazy<TalkModeManager> =
lazy {
// Reuse the existing TalkMode speech engine for native Android TTS playback
// without enabling the legacy talk capture loop.
TalkModeManager(
context = appContext,
scope = scope,
session = operatorSession,
supportsChatSubscribe = false,
isConnected = { operatorConnected },
onBeforeSpeak = { micCapture.pauseForTts() },
onAfterSpeak = { micCapture.resumeAfterTts() },
).also { speaker ->
speaker.setPlaybackEnabled(prefs.speakerEnabled.value)
}
private val voiceReplySpeakerLazy: Lazy<TalkModeManager> = lazy {
// Reuse the existing TalkMode speech engine for native Android TTS playback
// without enabling the legacy talk capture loop.
TalkModeManager(
context = appContext,
scope = scope,
session = operatorSession,
supportsChatSubscribe = false,
isConnected = { operatorConnected },
onBeforeSpeak = { micCapture.pauseForTts() },
onAfterSpeak = { micCapture.resumeAfterTts() },
).also { speaker ->
speaker.setPlaybackEnabled(prefs.speakerEnabled.value)
}
}
private val voiceReplySpeaker: TalkModeManager
get() = voiceReplySpeakerLazy.value
@@ -523,10 +504,7 @@ class NodeRuntime(
}
}
fun requestCanvasRehydrate(
source: String = "manual",
force: Boolean = true,
) {
fun requestCanvasRehydrate(source: String = "manual", force: Boolean = true) {
scope.launch {
if (!_nodeConnected.value) {
_canvasRehydratePending.value = false
@@ -589,22 +567,16 @@ class NodeRuntime(
val manualTls: StateFlow<Boolean> = prefs.manualTls
val gatewayToken: StateFlow<String> = prefs.gatewayToken
val onboardingCompleted: StateFlow<Boolean> = prefs.onboardingCompleted
fun setGatewayToken(value: String) = prefs.setGatewayToken(value)
fun setGatewayBootstrapToken(value: String) = prefs.setGatewayBootstrapToken(value)
fun setGatewayPassword(value: String) = prefs.setGatewayPassword(value)
fun resetGatewaySetupAuth() {
prefs.clearGatewaySetupAuth()
val deviceId = identityStore.loadOrCreate().deviceId
deviceAuthStore.clearToken(deviceId, "node")
deviceAuthStore.clearToken(deviceId, "operator")
}
fun setOnboardingCompleted(value: Boolean) = prefs.setOnboardingCompleted(value)
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
val notificationForwardingEnabled: StateFlow<Boolean> = prefs.notificationForwardingEnabled
@@ -677,60 +649,6 @@ class NodeRuntime(
reconnectPreferredGatewayOnForeground()
} else {
stopManualVoiceSession()
publishNodePresenceAliveBeacon(NodePresenceAliveBeacon.Trigger.Background, throttleRecentSuccess = true)
}
}
private fun publishNodePresenceAliveBeacon(
trigger: NodePresenceAliveBeacon.Trigger,
throttleRecentSuccess: Boolean = false,
) {
scope.launch {
sendNodePresenceAliveBeacon(trigger = trigger, throttleRecentSuccess = throttleRecentSuccess)
}
}
private suspend fun sendNodePresenceAliveBeacon(
trigger: NodePresenceAliveBeacon.Trigger,
throttleRecentSuccess: Boolean,
) {
if (!_nodeConnected.value) return
val nowMs = System.currentTimeMillis()
if (
throttleRecentSuccess &&
NodePresenceAliveBeacon.shouldSkipRecentSuccess(
nowMs = nowMs,
lastSuccessAtMs = nodePresenceAliveLastSuccessAtMs,
)
) {
return
}
val client = connectionManager.buildClientInfo(clientId = "openclaw-android", clientMode = "node")
val payloadJson =
NodePresenceAliveBeacon.makePayloadJson(
trigger = trigger,
sentAtMs = nowMs,
displayName = client.displayName?.trim()?.takeIf { it.isNotEmpty() } ?: "Android",
version = client.version,
platform = NodePresenceAliveBeacon.androidPlatformLabel(),
deviceFamily = client.deviceFamily,
modelIdentifier = client.modelIdentifier,
)
val result =
nodeSession.sendNodeEventDetailed(
event = NodePresenceAliveBeacon.EVENT_NAME,
payloadJson = payloadJson,
)
if (!result.ok) return
val response = NodePresenceAliveBeacon.decodeResponse(result.payloadJson)
if (response?.handled == true) {
nodePresenceAliveLastSuccessAtMs = nowMs
} else {
Log.d(
"OpenClawNode",
"node.presence.alive not handled: ${NodePresenceAliveBeacon.sanitizeReasonForLog(response?.reason)}",
)
}
}
@@ -830,7 +748,9 @@ class NodeRuntime(
enabled: Boolean,
start: String,
end: String,
): Boolean = prefs.setNotificationForwardingQuietHours(enabled = enabled, start = start, end = end)
): Boolean {
return prefs.setNotificationForwardingQuietHours(enabled = enabled, start = start, end = end)
}
fun setNotificationForwardingMaxEventsPerMinute(value: Int) {
prefs.setNotificationForwardingMaxEventsPerMinute(value)
@@ -1007,11 +927,10 @@ class NodeRuntime(
_statusText.value = "Verify gateway TLS fingerprint…"
scope.launch {
val tlsProbe = tlsFingerprintProbe(endpoint.host, endpoint.port)
val fp =
tlsProbe.fingerprintSha256 ?: run {
_statusText.value = gatewayTlsProbeFailureMessage(tlsProbe.failure)
return@launch
}
val fp = tlsProbe.fingerprintSha256 ?: run {
_statusText.value = gatewayTlsProbeFailureMessage(tlsProbe.failure)
return@launch
}
_pendingGatewayTrust.value =
GatewayTrustPrompt(endpoint = endpoint, fingerprintSha256 = fp, auth = auth)
}
@@ -1036,13 +955,14 @@ class NodeRuntime(
beginConnect(endpoint = endpoint, auth = resolveGatewayConnectAuth(auth))
}
internal fun resolveGatewayConnectAuth(explicitAuth: GatewayConnectAuth? = null): GatewayConnectAuth =
explicitAuth
internal fun resolveGatewayConnectAuth(explicitAuth: GatewayConnectAuth? = null): GatewayConnectAuth {
return explicitAuth
?: GatewayConnectAuth(
token = prefs.loadGatewayToken(),
bootstrapToken = prefs.loadGatewayBootstrapToken(),
password = prefs.loadGatewayPassword(),
)
}
fun acceptGatewayTrustPrompt() {
val prompt = _pendingGatewayTrust.value ?: return
@@ -1056,19 +976,21 @@ class NodeRuntime(
_statusText.value = "Offline"
}
private fun gatewayTlsProbeFailureMessage(failure: GatewayTlsProbeFailure?): String =
when (failure) {
private fun gatewayTlsProbeFailureMessage(failure: GatewayTlsProbeFailure?): String {
return when (failure) {
GatewayTlsProbeFailure.TLS_UNAVAILABLE ->
"Failed: this host requires wss:// or Tailscale Serve. No TLS endpoint detected."
GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE, null ->
"Failed: couldn't reach the secure gateway endpoint for this host."
}
}
private fun hasRecordAudioPermission(): Boolean =
(
private fun hasRecordAudioPermission(): Boolean {
return (
ContextCompat.checkSelfPermission(appContext, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
)
)
}
fun connectManual() {
val host = manualHost.value.trim()
@@ -1131,26 +1053,15 @@ class NodeRuntime(
}
val userActionObj = (root["userAction"] as? JsonObject) ?: root
val actionId =
(userActionObj["id"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty {
java.util.UUID
.randomUUID()
.toString()
}
val actionId = (userActionObj["id"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty {
java.util.UUID.randomUUID().toString()
}
val name = OpenClawCanvasA2UIAction.extractActionName(userActionObj) ?: return@launch
val surfaceId =
(userActionObj["surfaceId"] as? JsonPrimitive)
?.content
?.trim()
.orEmpty()
.ifEmpty { "main" }
(userActionObj["surfaceId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "main" }
val sourceComponentId =
(userActionObj["sourceComponentId"] as? JsonPrimitive)
?.content
?.trim()
.orEmpty()
.ifEmpty { "-" }
(userActionObj["sourceComponentId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "-" }
val contextJson = (userActionObj["context"] as? JsonObject)?.toString()
val sessionKey = resolveMainSessionKey()
@@ -1201,7 +1112,9 @@ class NodeRuntime(
}
}
fun isTrustedCanvasActionUrl(rawUrl: String?): Boolean = a2uiHandler.isTrustedCanvasActionUrl(rawUrl)
fun isTrustedCanvasActionUrl(rawUrl: String?): Boolean {
return a2uiHandler.isTrustedCanvasActionUrl(rawUrl)
}
fun loadChat(sessionKey: String) {
val key = sessionKey.trim().ifEmpty { resolveMainSessionKey() }
@@ -1228,11 +1141,7 @@ class NodeRuntime(
chat.abort()
}
fun sendChat(
message: String,
thinking: String,
attachments: List<OutgoingAttachment>,
) {
fun sendChat(message: String, thinking: String, attachments: List<OutgoingAttachment>) {
chat.sendMessage(message = message, thinkingLevel = thinking, attachments = attachments)
}
@@ -1240,12 +1149,11 @@ class NodeRuntime(
message: String,
thinking: String,
attachments: List<OutgoingAttachment>,
): Boolean = chat.sendMessageAwaitAcceptance(message = message, thinkingLevel = thinking, attachments = attachments)
): Boolean {
return chat.sendMessageAwaitAcceptance(message = message, thinkingLevel = thinking, attachments = attachments)
}
private fun handleGatewayEvent(
event: String,
payloadJson: String?,
) {
private fun handleGatewayEvent(event: String, payloadJson: String?) {
micCapture.handleGatewayEvent(event, payloadJson)
talkMode.handleGatewayEvent(event, payloadJson)
chat.handleGatewayEvent(event, payloadJson)
@@ -1291,12 +1199,7 @@ class NodeRuntime(
val id = obj["id"].asStringOrNull()?.trim().orEmpty()
if (id.isEmpty()) return@mapNotNull null
val name = obj["name"].asStringOrNull()?.trim()
val emoji =
obj["identity"]
.asObjectOrNull()
?.get("emoji")
.asStringOrNull()
?.trim()
val emoji = obj["identity"].asObjectOrNull()?.get("emoji").asStringOrNull()?.trim()
GatewayAgentSummary(
id = id,
name = name?.takeIf { it.isNotEmpty() },
@@ -1453,11 +1356,7 @@ class NodeRuntime(
_cameraFlashToken.value = SystemClock.elapsedRealtimeNanos()
}
private fun showCameraHud(
message: String,
kind: CameraHudKind,
autoHideMs: Long? = null,
) {
private fun showCameraHud(message: String, kind: CameraHudKind, autoHideMs: Long? = null) {
val token = cameraHudSeq.incrementAndGet()
_cameraHud.value = CameraHudState(token = token, kind = kind, message = message)
@@ -1468,6 +1367,7 @@ class NodeRuntime(
}
}
}
}
internal fun resolveOperatorSessionConnectAuth(
@@ -1516,7 +1416,9 @@ internal fun resolveOperatorSessionConnectAuth(
internal fun shouldConnectOperatorSession(
auth: NodeRuntime.GatewayConnectAuth,
storedOperatorToken: String?,
): Boolean = resolveOperatorSessionConnectAuth(auth, storedOperatorToken) != null
): Boolean {
return resolveOperatorSessionConnectAuth(auth, storedOperatorToken) != null
}
private enum class HomeCanvasGatewayState {
Connected,

View File

@@ -3,15 +3,15 @@ package ai.openclaw.app
import java.time.Instant
import java.time.ZoneId
enum class NotificationPackageFilterMode(
val rawValue: String,
) {
enum class NotificationPackageFilterMode(val rawValue: String) {
Allowlist("allowlist"),
Blocklist("blocklist"),
;
companion object {
fun fromRawValue(raw: String?): NotificationPackageFilterMode = entries.firstOrNull { it.rawValue == raw?.trim()?.lowercase() } ?: Blocklist
fun fromRawValue(raw: String?): NotificationPackageFilterMode {
return entries.firstOrNull { it.rawValue == raw?.trim()?.lowercase() } ?: Blocklist
}
}
}
@@ -50,8 +50,7 @@ internal fun NotificationForwardingPolicy.isWithinQuietHours(
return true
}
val now =
Instant
.ofEpochMilli(nowEpochMs)
Instant.ofEpochMilli(nowEpochMs)
.atZone(zoneId)
.toLocalTime()
val nowMinutes = now.hour * 60 + now.minute
@@ -83,10 +82,7 @@ internal class NotificationBurstLimiter {
private var windowStartMs: Long = -1L
private var eventsInWindow: Int = 0
fun allow(
nowEpochMs: Long,
maxEventsPerMinute: Int,
): Boolean {
fun allow(nowEpochMs: Long, maxEventsPerMinute: Int): Boolean {
if (maxEventsPerMinute <= 0) {
return false
}

View File

@@ -1,32 +1,30 @@
package ai.openclaw.app
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.content.Intent
import android.Manifest
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import androidx.appcompat.app.AlertDialog
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.app.ActivityCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.suspendCancellableCoroutine
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.resume
class PermissionRequester(
private val activity: ComponentActivity,
) {
class PermissionRequester(private val activity: ComponentActivity) {
private val mutex = Mutex()
private var pending: CompletableDeferred<Map<String, Boolean>>? = null
private val mainHandler = Handler(Looper.getMainLooper())
@@ -76,10 +74,10 @@ class PermissionRequester(
// Merge: if something was already granted, treat it as granted even if launcher omitted it.
val merged =
permissions.associateWith { perm ->
val nowGranted =
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
result[perm] == true || nowGranted
}
val nowGranted =
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
result[perm] == true || nowGranted
}
val denied =
merged.filterValues { !it }.keys.filter {
@@ -106,7 +104,6 @@ class PermissionRequester(
observer?.let(lifecycle::removeObserver)
observer = null
}
fun finish(result: Boolean?) {
if (!finished.compareAndSet(false, true)) return
removeObserver()
@@ -128,8 +125,7 @@ class PermissionRequester(
}
}
dialog =
AlertDialog
.Builder(activity)
AlertDialog.Builder(activity)
.setTitle("Permission required")
.setMessage(buildRationaleMessage(permissions))
.setPositiveButton("Continue") { _, _ -> finish(true) }
@@ -158,8 +154,7 @@ class PermissionRequester(
observer = actualObserver
lifecycle.addObserver(actualObserver)
dialog =
AlertDialog
.Builder(activity)
AlertDialog.Builder(activity)
.setTitle("Enable permission in Settings")
.setMessage(buildSettingsMessage(permissions))
.setPositiveButton("Open Settings") { _, _ ->
@@ -170,7 +165,8 @@ class PermissionRequester(
Uri.fromParts("package", activity.packageName, null),
)
activity.startActivity(intent)
}.setNegativeButton("Cancel", null)
}
.setNegativeButton("Cancel", null)
.setOnDismissListener { removeObserver() }
.show()
}

View File

@@ -46,8 +46,7 @@ class SecurePrefs(
appContext.getSharedPreferences(plainPrefsName, Context.MODE_PRIVATE)
private val masterKey by lazy {
MasterKey
.Builder(appContext)
MasterKey.Builder(appContext)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
}
@@ -421,20 +420,16 @@ class SecurePrefs(
return plainPrefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }
}
fun saveGatewayTlsFingerprint(
stableId: String,
fingerprint: String,
) {
fun saveGatewayTlsFingerprint(stableId: String, fingerprint: String) {
val key = "gateway.tls.$stableId"
plainPrefs.edit { putString(key, fingerprint.trim()) }
}
fun getString(key: String): String? = securePrefs.getString(key, null)
fun getString(key: String): String? {
return securePrefs.getString(key, null)
}
fun putString(
key: String,
value: String,
) {
fun putString(key: String, value: String) {
securePrefs.edit { putString(key, value) }
}
@@ -442,17 +437,15 @@ class SecurePrefs(
securePrefs.edit { remove(key) }
}
private fun createSecurePrefs(
context: Context,
name: String,
): SharedPreferences =
EncryptedSharedPreferences.create(
private fun createSecurePrefs(context: Context, name: String): SharedPreferences {
return EncryptedSharedPreferences.create(
context,
name,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
private fun loadOrCreateInstanceId(): String {
val existing = plainPrefs.getString("node.instanceId", null)?.trim()
@@ -511,7 +504,8 @@ class SecurePrefs(
is JsonPrimitive -> item.content.trim().takeIf { it.isNotEmpty() }
else -> null
}
}.toSet()
}
.toSet()
} catch (_: Throwable) {
emptySet()
}

View File

@@ -15,17 +15,10 @@ internal fun isCanonicalMainSessionKey(raw: String?): Boolean {
internal fun resolveAgentIdFromMainSessionKey(raw: String?): String? {
val trimmed = raw?.trim().orEmpty()
if (!trimmed.startsWith("agent:")) return null
return trimmed
.removePrefix("agent:")
.substringBefore(':')
.trim()
.ifEmpty { null }
return trimmed.removePrefix("agent:").substringBefore(':').trim().ifEmpty { null }
}
internal fun buildNodeMainSessionKey(
deviceId: String,
agentId: String?,
): String {
internal fun buildNodeMainSessionKey(deviceId: String, agentId: String?): String {
val resolvedAgentId = agentId?.trim().orEmpty().ifEmpty { "main" }
return "agent:$resolvedAgentId:node-${deviceId.take(12)}"
}

View File

@@ -1,14 +1,14 @@
package ai.openclaw.app
enum class VoiceWakeMode(
val rawValue: String,
) {
enum class VoiceWakeMode(val rawValue: String) {
Off("off"),
Foreground("foreground"),
Always("always"),
;
companion object {
fun fromRawValue(raw: String?): VoiceWakeMode = entries.firstOrNull { it.rawValue == raw?.trim()?.lowercase() } ?: Foreground
fun fromRawValue(raw: String?): VoiceWakeMode {
return entries.firstOrNull { it.rawValue == raw?.trim()?.lowercase() } ?: Foreground
}
}
}

View File

@@ -4,26 +4,18 @@ object WakeWords {
const val maxWords: Int = 32
const val maxWordLength: Int = 64
fun parseCommaSeparated(input: String): List<String> = input.split(",").map { it.trim() }.filter { it.isNotEmpty() }
fun parseCommaSeparated(input: String): List<String> {
return input.split(",").map { it.trim() }.filter { it.isNotEmpty() }
}
fun parseIfChanged(
input: String,
current: List<String>,
): List<String>? {
fun parseIfChanged(input: String, current: List<String>): List<String>? {
val parsed = parseCommaSeparated(input)
return if (parsed == current) null else parsed
}
fun sanitize(
words: List<String>,
defaults: List<String>,
): List<String> {
fun sanitize(words: List<String>, defaults: List<String>): List<String> {
val cleaned =
words
.map { it.trim() }
.filter { it.isNotEmpty() }
.take(maxWords)
.map { it.take(maxWordLength) }
words.map { it.trim() }.filter { it.isNotEmpty() }.take(maxWords).map { it.take(maxWordLength) }
return cleaned.ifEmpty { defaults }
}
}

View File

@@ -1,6 +1,8 @@
package ai.openclaw.app.chat
import ai.openclaw.app.gateway.GatewaySession
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@@ -15,8 +17,6 @@ import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
class ChatController(
private val scope: CoroutineScope,
@@ -173,12 +173,12 @@ class ChatController(
}
_messages.value =
_messages.value +
ChatMessage(
id = UUID.randomUUID().toString(),
role = "user",
content = userContent,
timestampMs = System.currentTimeMillis(),
)
ChatMessage(
id = UUID.randomUUID().toString(),
role = "user",
content = userContent,
timestampMs = System.currentTimeMillis(),
)
armPendingRunTimeout(runId)
synchronized(pendingRuns) {
@@ -255,10 +255,7 @@ class ChatController(
}
}
fun handleGatewayEvent(
event: String,
payloadJson: String?,
) {
fun handleGatewayEvent(event: String, payloadJson: String?) {
when (event) {
"tick" -> {
scope.launch { pollHealthIfNeeded(force = false) }
@@ -282,10 +279,7 @@ class ChatController(
}
}
private suspend fun bootstrap(
forceHealth: Boolean,
refreshSessions: Boolean,
) {
private suspend fun bootstrap(forceHealth: Boolean, refreshSessions: Boolean) {
_errorText.value = null
_healthOk.value = false
clearPendingRuns()
@@ -304,10 +298,7 @@ class ChatController(
val history = parseHistory(historyJson, sessionKey = key, previousMessages = _messages.value)
_messages.value = history.messages
_sessionId.value = history.sessionId
history.thinkingLevel
?.trim()
?.takeIf { it.isNotEmpty() }
?.let { _thinkingLevel.value = it }
history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it }
pollHealthIfNeeded(force = forceHealth)
if (refreshSessions) {
@@ -380,10 +371,7 @@ class ChatController(
val history = parseHistory(historyJson, sessionKey = _sessionKey.value, previousMessages = _messages.value)
_messages.value = history.messages
_sessionId.value = history.sessionId
history.thinkingLevel
?.trim()
?.takeIf { it.isNotEmpty() }
?.let { _thinkingLevel.value = it }
history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it }
} catch (_: Throwable) {
// best-effort
}
@@ -554,24 +542,22 @@ class ChatController(
}
}
private fun parseRunId(resJson: String): String? =
try {
json
.parseToJsonElement(resJson)
.asObjectOrNull()
?.get("runId")
.asStringOrNull()
private fun parseRunId(resJson: String): String? {
return try {
json.parseToJsonElement(resJson).asObjectOrNull()?.get("runId").asStringOrNull()
} catch (_: Throwable) {
null
}
}
private fun normalizeThinking(raw: String): String =
when (raw.trim().lowercase()) {
private fun normalizeThinking(raw: String): String {
return when (raw.trim().lowercase()) {
"low" -> "low"
"medium" -> "medium"
"high" -> "high"
else -> "off"
}
}
}
internal data class MainSessionState(
@@ -596,10 +582,7 @@ internal fun applyMainSessionKey(
)
}
internal fun reconcileMessageIds(
previous: List<ChatMessage>,
incoming: List<ChatMessage>,
): List<ChatMessage> {
internal fun reconcileMessageIds(previous: List<ChatMessage>, incoming: List<ChatMessage>): List<ChatMessage> {
if (previous.isEmpty() || incoming.isEmpty()) return incoming
val idsByKey = LinkedHashMap<String, ArrayDeque<String>>()
@@ -630,15 +613,9 @@ internal fun messageIdentityKey(message: ChatMessage): String? {
listOf(
part.type.trim().lowercase(),
part.text?.trim().orEmpty(),
part.mimeType
?.trim()
?.lowercase()
.orEmpty(),
part.mimeType?.trim()?.lowercase().orEmpty(),
part.fileName?.trim().orEmpty(),
part.base64
?.hashCode()
?.toString()
.orEmpty(),
part.base64?.hashCode()?.toString().orEmpty(),
).joinToString(separator = "\u001F")
}

View File

@@ -19,44 +19,21 @@ private data class PersistedDeviceAuthMetadata(
)
interface DeviceAuthTokenStore {
fun loadEntry(
deviceId: String,
role: String,
): DeviceAuthEntry?
fun loadToken(
deviceId: String,
role: String,
): String? = loadEntry(deviceId, role)?.token
fun saveToken(
deviceId: String,
role: String,
token: String,
scopes: List<String> = emptyList(),
)
fun clearToken(
deviceId: String,
role: String,
)
fun loadEntry(deviceId: String, role: String): DeviceAuthEntry?
fun loadToken(deviceId: String, role: String): String? = loadEntry(deviceId, role)?.token
fun saveToken(deviceId: String, role: String, token: String, scopes: List<String> = emptyList())
fun clearToken(deviceId: String, role: String)
}
class DeviceAuthStore(
private val prefs: SecurePrefs,
) : DeviceAuthTokenStore {
class DeviceAuthStore(private val prefs: SecurePrefs) : DeviceAuthTokenStore {
private val json = Json { ignoreUnknownKeys = true }
override fun loadEntry(
deviceId: String,
role: String,
): DeviceAuthEntry? {
override fun loadEntry(deviceId: String, role: String): DeviceAuthEntry? {
val key = tokenKey(deviceId, role)
val token = prefs.getString(key)?.trim()?.takeIf { it.isNotEmpty() } ?: return null
val normalizedRole = normalizeRole(role)
val metadata =
prefs
.getString(metadataKey(deviceId, role))
prefs.getString(metadataKey(deviceId, role))
?.let { raw ->
runCatching { json.decodeFromString<PersistedDeviceAuthMetadata>(raw) }.getOrNull()
}
@@ -68,12 +45,7 @@ class DeviceAuthStore(
)
}
override fun saveToken(
deviceId: String,
role: String,
token: String,
scopes: List<String>,
) {
override fun saveToken(deviceId: String, role: String, token: String, scopes: List<String>) {
val normalizedScopes = normalizeScopes(scopes)
val key = tokenKey(deviceId, role)
prefs.putString(key, token.trim())
@@ -88,28 +60,19 @@ class DeviceAuthStore(
)
}
override fun clearToken(
deviceId: String,
role: String,
) {
override fun clearToken(deviceId: String, role: String) {
val key = tokenKey(deviceId, role)
prefs.remove(key)
prefs.remove(metadataKey(deviceId, role))
}
private fun tokenKey(
deviceId: String,
role: String,
): String {
private fun tokenKey(deviceId: String, role: String): String {
val normalizedDevice = normalizeDeviceId(deviceId)
val normalizedRole = normalizeRole(role)
return "gateway.deviceToken.$normalizedDevice.$normalizedRole"
}
private fun metadataKey(
deviceId: String,
role: String,
): String {
private fun metadataKey(deviceId: String, role: String): String {
val normalizedDevice = normalizeDeviceId(deviceId)
val normalizedRole = normalizeRole(role)
return "gateway.deviceTokenMeta.$normalizedDevice.$normalizedRole"
@@ -119,10 +82,11 @@ class DeviceAuthStore(
private fun normalizeRole(role: String): String = role.trim().lowercase()
private fun normalizeScopes(scopes: List<String>): List<String> =
scopes
private fun normalizeScopes(scopes: List<String>): List<String> {
return scopes
.map { it.trim() }
.filter { it.isNotEmpty() }
.distinct()
.sorted()
}
}

View File

@@ -2,10 +2,10 @@ package ai.openclaw.app.gateway
import android.content.Context
import android.util.Base64
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.File
import java.security.MessageDigest
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
data class DeviceIdentity(
@@ -15,12 +15,9 @@ data class DeviceIdentity(
val createdAtMs: Long,
)
class DeviceIdentityStore(
context: Context,
) {
class DeviceIdentityStore(context: Context) {
private val json = Json { ignoreUnknownKeys = true }
private val identityFile = File(context.filesDir, "openclaw/identity/device.json")
@Volatile private var cachedIdentity: DeviceIdentity? = null
@Synchronized
@@ -44,27 +41,15 @@ class DeviceIdentityStore(
return fresh
}
fun signPayload(
payload: String,
identity: DeviceIdentity,
): String? =
try {
fun signPayload(payload: String, identity: DeviceIdentity): String? {
return try {
// Use BC lightweight API directly — JCA provider registration is broken by R8
val privateKeyBytes = Base64.decode(identity.privateKeyPkcs8Base64, Base64.DEFAULT)
val pkInfo =
org.bouncycastle.asn1.pkcs.PrivateKeyInfo
.getInstance(privateKeyBytes)
val pkInfo = org.bouncycastle.asn1.pkcs.PrivateKeyInfo.getInstance(privateKeyBytes)
val parsed = pkInfo.parsePrivateKey()
val rawPrivate =
org.bouncycastle.asn1.DEROctetString
.getInstance(parsed)
.octets
val privateKey =
org.bouncycastle.crypto.params
.Ed25519PrivateKeyParameters(rawPrivate, 0)
val signer =
org.bouncycastle.crypto.signers
.Ed25519Signer()
val rawPrivate = org.bouncycastle.asn1.DEROctetString.getInstance(parsed).octets
val privateKey = org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters(rawPrivate, 0)
val signer = org.bouncycastle.crypto.signers.Ed25519Signer()
signer.init(true, privateKey)
val payloadBytes = payload.toByteArray(Charsets.UTF_8)
signer.update(payloadBytes, 0, payloadBytes.size)
@@ -73,21 +58,14 @@ class DeviceIdentityStore(
android.util.Log.e("DeviceAuth", "signPayload FAILED: ${e.javaClass.simpleName}: ${e.message}", e)
null
}
}
fun verifySelfSignature(
payload: String,
signatureBase64Url: String,
identity: DeviceIdentity,
): Boolean =
try {
fun verifySelfSignature(payload: String, signatureBase64Url: String, identity: DeviceIdentity): Boolean {
return try {
val rawPublicKey = Base64.decode(identity.publicKeyRawBase64, Base64.DEFAULT)
val pubKey =
org.bouncycastle.crypto.params
.Ed25519PublicKeyParameters(rawPublicKey, 0)
val pubKey = org.bouncycastle.crypto.params.Ed25519PublicKeyParameters(rawPublicKey, 0)
val sigBytes = base64UrlDecode(signatureBase64Url)
val verifier =
org.bouncycastle.crypto.signers
.Ed25519Signer()
val verifier = org.bouncycastle.crypto.signers.Ed25519Signer()
verifier.init(false, pubKey)
val payloadBytes = payload.toByteArray(Charsets.UTF_8)
verifier.update(payloadBytes, 0, payloadBytes.size)
@@ -96,6 +74,7 @@ class DeviceIdentityStore(
android.util.Log.e("DeviceAuth", "self-verify exception: ${e.message}", e)
false
}
}
private fun base64UrlDecode(input: String): ByteArray {
val normalized = input.replace('-', '+').replace('_', '/')
@@ -103,15 +82,18 @@ class DeviceIdentityStore(
return Base64.decode(padded, Base64.DEFAULT)
}
fun publicKeyBase64Url(identity: DeviceIdentity): String? =
try {
fun publicKeyBase64Url(identity: DeviceIdentity): String? {
return try {
val raw = Base64.decode(identity.publicKeyRawBase64, Base64.DEFAULT)
base64UrlEncode(raw)
} catch (_: Throwable) {
null
}
}
private fun load(): DeviceIdentity? = readIdentity(identityFile)
private fun load(): DeviceIdentity? {
return readIdentity(identityFile)
}
private fun readIdentity(file: File): DeviceIdentity? {
return try {
@@ -143,22 +125,15 @@ class DeviceIdentityStore(
private fun generate(): DeviceIdentity {
// Use BC lightweight API directly to avoid JCA provider issues with R8
val kpGen =
org.bouncycastle.crypto.generators
.Ed25519KeyPairGenerator()
kpGen.init(
org.bouncycastle.crypto.params
.Ed25519KeyGenerationParameters(java.security.SecureRandom()),
)
val kpGen = org.bouncycastle.crypto.generators.Ed25519KeyPairGenerator()
kpGen.init(org.bouncycastle.crypto.params.Ed25519KeyGenerationParameters(java.security.SecureRandom()))
val kp = kpGen.generateKeyPair()
val pubKey = kp.public as org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
val privKey = kp.private as org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
val rawPublic = pubKey.encoded // 32 bytes
val rawPublic = pubKey.encoded // 32 bytes
val deviceId = sha256Hex(rawPublic)
// Encode private key as PKCS8 for storage
val privKeyInfo =
org.bouncycastle.crypto.util.PrivateKeyInfoFactory
.createPrivateKeyInfo(privKey)
val privKeyInfo = org.bouncycastle.crypto.util.PrivateKeyInfoFactory.createPrivateKeyInfo(privKey)
val pkcs8Bytes = privKeyInfo.encoded
return DeviceIdentity(
deviceId = deviceId,
@@ -168,13 +143,14 @@ class DeviceIdentityStore(
)
}
private fun deriveDeviceId(publicKeyRawBase64: String): String? =
try {
private fun deriveDeviceId(publicKeyRawBase64: String): String? {
return try {
val raw = Base64.decode(publicKeyRawBase64, Base64.DEFAULT)
sha256Hex(raw)
} catch (_: Throwable) {
null
}
}
private fun sha256Hex(data: ByteArray): String {
val digest = MessageDigest.getInstance("SHA-256").digest(data)
@@ -188,11 +164,9 @@ class DeviceIdentityStore(
return String(out)
}
private fun base64UrlEncode(data: ByteArray): String =
Base64.encodeToString(
data,
Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING,
)
private fun base64UrlEncode(data: ByteArray): String {
return Base64.encodeToString(data, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
}
companion object {
private val HEX = "0123456789abcdef".toCharArray()

View File

@@ -1,17 +1,21 @@
package ai.openclaw.app.gateway
import android.annotation.TargetApi
import android.content.Context
import android.net.ConnectivityManager
import android.net.DnsResolver
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.os.Build
import android.os.CancellationSignal
import android.util.Log
import java.io.IOException
import java.net.InetSocketAddress
import java.nio.ByteBuffer
import java.nio.charset.CodingErrorAction
import java.time.Duration
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executor
import java.util.concurrent.Executors
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -28,27 +32,19 @@ import org.xbill.DNS.ExtendedResolver
import org.xbill.DNS.Message
import org.xbill.DNS.Name
import org.xbill.DNS.PTRRecord
import org.xbill.DNS.Rcode
import org.xbill.DNS.Record
import org.xbill.DNS.Rcode
import org.xbill.DNS.Resolver
import org.xbill.DNS.SRVRecord
import org.xbill.DNS.Section
import org.xbill.DNS.SimpleResolver
import org.xbill.DNS.TXTRecord
import org.xbill.DNS.TextParseException
import org.xbill.DNS.TXTRecord
import org.xbill.DNS.Type
import java.io.IOException
import java.net.InetAddress
import java.net.InetSocketAddress
import java.nio.ByteBuffer
import java.nio.charset.CodingErrorAction
import java.time.Duration
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executor
import java.util.concurrent.Executors
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@Suppress("DEPRECATION")
class GatewayDiscovery(
context: Context,
private val scope: CoroutineScope,
@@ -70,38 +66,15 @@ class GatewayDiscovery(
private var unicastJob: Job? = null
private val dnsExecutor: Executor = Executors.newCachedThreadPool()
private val availableNetworks = ConcurrentHashMap.newKeySet<Network>()
private val serviceInfoCallbacks = ConcurrentHashMap<String, Any>()
@Volatile private var lastWideAreaRcode: Int? = null
@Volatile private var lastWideAreaCount: Int = 0
private val networkCallback =
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
availableNetworks.add(network)
}
override fun onLost(network: Network) {
availableNetworks.remove(network)
}
}
private val discoveryListener =
object : NsdManager.DiscoveryListener {
override fun onStartDiscoveryFailed(
serviceType: String,
errorCode: Int,
) {}
override fun onStopDiscoveryFailed(
serviceType: String,
errorCode: Int,
) {}
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {}
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {}
override fun onDiscoveryStarted(serviceType: String) {}
override fun onDiscoveryStopped(serviceType: String) {}
override fun onServiceFound(serviceInfo: NsdServiceInfo) {
@@ -113,29 +86,17 @@ class GatewayDiscovery(
val serviceName = BonjourEscapes.decode(serviceInfo.serviceName)
val id = stableId(serviceName, "local.")
localById.remove(id)
unregisterServiceInfoCallback(id)
publish()
}
}
init {
startNetworkTracking()
startLocalDiscovery()
if (!wideAreaDomain.isNullOrBlank()) {
startUnicastDiscovery(wideAreaDomain)
}
}
private fun startNetworkTracking() {
val cm = connectivity ?: return
cm.activeNetwork?.let(availableNetworks::add)
try {
cm.registerNetworkCallback(NetworkRequest.Builder().build(), networkCallback)
} catch (_: Throwable) {
// ignore (best-effort)
}
}
private fun startLocalDiscovery() {
try {
nsd.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, discoveryListener)
@@ -167,124 +128,43 @@ class GatewayDiscovery(
}
private fun resolve(serviceInfo: NsdServiceInfo) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
resolveWithServiceInfoCallback(serviceInfo)
} else {
resolveLegacy(serviceInfo)
}
}
@TargetApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
private fun resolveWithServiceInfoCallback(serviceInfo: NsdServiceInfo) {
val serviceName = BonjourEscapes.decode(serviceInfo.serviceName)
val id = stableId(serviceName, "local.")
if (serviceInfoCallbacks.containsKey(id)) return
val callback =
object : NsdManager.ServiceInfoCallback {
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
serviceInfoCallbacks.remove(id, this)
}
override fun onServiceInfoCallbackUnregistered() {
serviceInfoCallbacks.remove(id, this)
}
override fun onServiceLost() {
localById.remove(id)
publish()
}
override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
upsertResolvedService(serviceInfo)
}
}
serviceInfoCallbacks[id] = callback
try {
nsd.registerServiceInfoCallback(serviceInfo, dnsExecutor, callback)
} catch (_: Throwable) {
serviceInfoCallbacks.remove(id, callback)
}
}
private fun unregisterServiceInfoCallback(id: String) {
val callback = serviceInfoCallbacks.remove(id) ?: return
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return
try {
nsd.unregisterServiceInfoCallback(callback as NsdManager.ServiceInfoCallback)
} catch (_: Throwable) {
// ignore (best-effort)
}
}
private fun resolveLegacy(serviceInfo: NsdServiceInfo) {
val listener =
nsd.resolveService(
serviceInfo,
object : NsdManager.ResolveListener {
override fun onResolveFailed(
serviceInfo: NsdServiceInfo,
errorCode: Int,
) {}
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {}
override fun onServiceResolved(resolved: NsdServiceInfo) {
upsertResolvedService(resolved)
val host = resolved.host?.hostAddress ?: return
val port = resolved.port
if (port <= 0) return
val rawServiceName = resolved.serviceName
val serviceName = BonjourEscapes.decode(rawServiceName)
val displayName = BonjourEscapes.decode(txt(resolved, "displayName") ?: serviceName)
val lanHost = txt(resolved, "lanHost")
val tailnetDns = txt(resolved, "tailnetDns")
val gatewayPort = txtInt(resolved, "gatewayPort")
val canvasPort = txtInt(resolved, "canvasPort")
val tlsEnabled = txtBool(resolved, "gatewayTls")
val tlsFingerprint = txt(resolved, "gatewayTlsSha256")
val id = stableId(serviceName, "local.")
localById[id] =
GatewayEndpoint(
stableId = id,
name = displayName,
host = host,
port = port,
lanHost = lanHost,
tailnetDns = tailnetDns,
gatewayPort = gatewayPort,
canvasPort = canvasPort,
tlsEnabled = tlsEnabled,
tlsFingerprintSha256 = tlsFingerprint,
)
publish()
}
}
try {
NsdManager::class.java
.getMethod("resolveService", NsdServiceInfo::class.java, NsdManager.ResolveListener::class.java)
.invoke(nsd, serviceInfo, listener)
} catch (_: Throwable) {
// ignore (best-effort)
}
}
private fun upsertResolvedService(resolved: NsdServiceInfo) {
val host = resolvedHostAddress(resolved) ?: return
val port = resolved.port
if (port <= 0) return
val rawServiceName = resolved.serviceName
val serviceName = BonjourEscapes.decode(rawServiceName)
val displayName = BonjourEscapes.decode(txt(resolved, "displayName") ?: serviceName)
val lanHost = txt(resolved, "lanHost")
val tailnetDns = txt(resolved, "tailnetDns")
val gatewayPort = txtInt(resolved, "gatewayPort")
val canvasPort = txtInt(resolved, "canvasPort")
val tlsEnabled = txtBool(resolved, "gatewayTls")
val tlsFingerprint = txt(resolved, "gatewayTlsSha256")
val id = stableId(serviceName, "local.")
localById[id] =
GatewayEndpoint(
stableId = id,
name = displayName,
host = host,
port = port,
lanHost = lanHost,
tailnetDns = tailnetDns,
gatewayPort = gatewayPort,
canvasPort = canvasPort,
tlsEnabled = tlsEnabled,
tlsFingerprintSha256 = tlsFingerprint,
)
publish()
}
private fun resolvedHostAddress(resolved: NsdServiceInfo): String? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
return resolved.hostAddresses.firstOrNull()?.hostAddress
}
return legacyHostAddress(resolved)
}
private fun legacyHostAddress(resolved: NsdServiceInfo): String? {
return try {
val host = NsdServiceInfo::class.java.getMethod("getHost").invoke(resolved) as? InetAddress
host?.hostAddress
} catch (_: Throwable) {
null
}
},
)
}
private fun publish() {
@@ -313,17 +193,15 @@ class GatewayDiscovery(
}
}
private fun stableId(
serviceName: String,
domain: String,
): String = "$serviceType|$domain|${normalizeName(serviceName)}"
private fun stableId(serviceName: String, domain: String): String {
return "${serviceType}|${domain}|${normalizeName(serviceName)}"
}
private fun normalizeName(raw: String): String = raw.trim().split(Regex("\\s+")).joinToString(" ")
private fun normalizeName(raw: String): String {
return raw.trim().split(Regex("\\s+")).joinToString(" ")
}
private fun txt(
info: NsdServiceInfo,
key: String,
): String? {
private fun txt(info: NsdServiceInfo, key: String): String? {
val bytes = info.attributes[key] ?: return null
return try {
String(bytes, Charsets.UTF_8).trim().ifEmpty { null }
@@ -332,21 +210,17 @@ class GatewayDiscovery(
}
}
private fun txtInt(
info: NsdServiceInfo,
key: String,
): Int? = txt(info, key)?.toIntOrNull()
private fun txtInt(info: NsdServiceInfo, key: String): Int? {
return txt(info, key)?.toIntOrNull()
}
private fun txtBool(
info: NsdServiceInfo,
key: String,
): Boolean {
private fun txtBool(info: NsdServiceInfo, key: String): Boolean {
val raw = txt(info, key)?.trim()?.lowercase() ?: return false
return raw == "1" || raw == "true" || raw == "yes"
}
private suspend fun refreshUnicast(domain: String) {
val ptrName = "${serviceType}$domain"
val ptrName = "${serviceType}${domain}"
val ptrMsg = lookupUnicastMessage(ptrName, Type.PTR) ?: return
val ptrRecords = records(ptrMsg, Section.ANSWER).mapNotNull { it as? PTRRecord }
@@ -419,11 +293,8 @@ class GatewayDiscovery(
}
}
private fun decodeInstanceName(
instanceFqdn: String,
domain: String,
): String {
val suffix = "${serviceType}$domain"
private fun decodeInstanceName(instanceFqdn: String, domain: String): String {
val suffix = "${serviceType}${domain}"
val withoutSuffix =
if (instanceFqdn.endsWith(suffix)) {
instanceFqdn.removeSuffix(suffix)
@@ -433,12 +304,11 @@ class GatewayDiscovery(
return normalizeName(stripTrailingDot(withoutSuffix))
}
private fun stripTrailingDot(raw: String): String = raw.removeSuffix(".")
private fun stripTrailingDot(raw: String): String {
return raw.removeSuffix(".")
}
private suspend fun lookupUnicastMessage(
name: String,
type: Int,
): Message? {
private suspend fun lookupUnicastMessage(name: String, type: Int): Message? {
val query =
try {
Message.newQuery(
@@ -480,17 +350,15 @@ class GatewayDiscovery(
}
}
private fun records(
msg: Message?,
section: Int,
): List<Record> = msg?.getSection(section).orEmpty()
private fun records(msg: Message?, section: Int): List<Record> {
return msg?.getSection(section).orEmpty()
}
private fun keyName(raw: String): String = raw.trim().lowercase()
private fun keyName(raw: String): String {
return raw.trim().lowercase()
}
private fun recordsByName(
msg: Message,
section: Int,
): Map<String, List<Record>> {
private fun recordsByName(msg: Message, section: Int): Map<String, List<Record>> {
val next = LinkedHashMap<String, MutableList<Record>>()
for (r in records(msg, section)) {
val name = r.name?.toString() ?: continue
@@ -499,11 +367,7 @@ class GatewayDiscovery(
return next
}
private fun recordByName(
msg: Message,
fqdn: String,
type: Int,
): Record? {
private fun recordByName(msg: Message, fqdn: String, type: Int): Record? {
val key = keyName(fqdn)
val byNameAnswer = recordsByName(msg, Section.ANSWER)
val fromAnswer = byNameAnswer[key].orEmpty().firstOrNull { it.type == type }
@@ -513,10 +377,7 @@ class GatewayDiscovery(
return byNameAdditional[key].orEmpty().firstOrNull { it.type == type }
}
private fun resolveHostFromMessage(
msg: Message?,
hostname: String,
): String? {
private fun resolveHostFromMessage(msg: Message?, hostname: String): String? {
val m = msg ?: return null
val key = keyName(hostname)
val additional = recordsByName(m, Section.ADDITIONAL)[key].orEmpty()
@@ -529,7 +390,7 @@ class GatewayDiscovery(
val cm = connectivity ?: return null
// Prefer VPN (Tailscale) when present; otherwise use the active network.
trackedNetworks(cm).firstOrNull { n ->
cm.allNetworks.firstOrNull { n ->
val caps = cm.getNetworkCapabilities(n) ?: return@firstOrNull false
caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
}?.let { return it }
@@ -537,19 +398,12 @@ class GatewayDiscovery(
return cm.activeNetwork
}
private fun trackedNetworks(cm: ConnectivityManager): List<Network> {
return buildList {
cm.activeNetwork?.let(::add)
addAll(availableNetworks)
}.distinct()
}
private fun createDirectResolver(): Resolver? {
val cm = connectivity ?: return null
val candidateNetworks =
buildList {
trackedNetworks(cm)
cm.allNetworks
.firstOrNull { n ->
val caps = cm.getNetworkCapabilities(n) ?: return@firstOrNull false
caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
@@ -562,7 +416,8 @@ class GatewayDiscovery(
.asSequence()
.flatMap { n ->
cm.getLinkProperties(n)?.dnsServers?.asSequence() ?: emptySequence()
}.distinctBy { it.hostAddress ?: it.toString() }
}
.distinctBy { it.hostAddress ?: it.toString() }
.toList()
if (servers.isEmpty()) return null
@@ -585,10 +440,7 @@ class GatewayDiscovery(
}
}
private suspend fun rawQuery(
network: android.net.Network?,
wireQuery: ByteArray,
): ByteArray =
private suspend fun rawQuery(network: android.net.Network?, wireQuery: ByteArray): ByteArray =
suspendCancellableCoroutine { cont ->
val signal = CancellationSignal()
cont.invokeOnCancellation { signal.cancel() }
@@ -600,10 +452,7 @@ class GatewayDiscovery(
dnsExecutor,
signal,
object : DnsResolver.Callback<ByteArray> {
override fun onAnswer(
answer: ByteArray,
rcode: Int,
) {
override fun onAnswer(answer: ByteArray, rcode: Int) {
cont.resume(answer)
}
@@ -614,10 +463,7 @@ class GatewayDiscovery(
)
}
private fun txtValue(
records: List<TXTRecord>,
key: String,
): String? {
private fun txtValue(records: List<TXTRecord>, key: String): String? {
val prefix = "$key="
for (r in records) {
val strings: List<String> =
@@ -636,15 +482,11 @@ class GatewayDiscovery(
return null
}
private fun txtIntValue(
records: List<TXTRecord>,
key: String,
): Int? = txtValue(records, key)?.toIntOrNull()
private fun txtIntValue(records: List<TXTRecord>, key: String): Int? {
return txtValue(records, key)?.toIntOrNull()
}
private fun txtBoolValue(
records: List<TXTRecord>,
key: String,
): Boolean {
private fun txtBoolValue(records: List<TXTRecord>, key: String): Boolean {
val raw = txtValue(records, key)?.trim()?.lowercase() ?: return false
return raw == "1" || raw == "true" || raw == "yes"
}

View File

@@ -13,10 +13,7 @@ data class GatewayEndpoint(
val tlsFingerprintSha256: String? = null,
) {
companion object {
fun manual(
host: String,
port: Int,
): GatewayEndpoint =
fun manual(host: String, port: Int): GatewayEndpoint =
GatewayEndpoint(
stableId = "manual|${host.lowercase()}|$port",
name = "$host:$port",

View File

@@ -1,6 +1,10 @@
package ai.openclaw.app.gateway
import android.util.Log
import java.util.Locale
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -26,10 +30,6 @@ import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.util.Locale
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
data class GatewayClientInfo(
val id: String,
@@ -77,9 +77,8 @@ private data class SelectedConnectAuth(
val attemptedDeviceTokenRetry: Boolean,
)
private class GatewayConnectFailure(
val gatewayError: GatewaySession.ErrorShape,
) : IllegalStateException(gatewayError.message)
private class GatewayConnectFailure(val gatewayError: GatewaySession.ErrorShape) :
IllegalStateException(gatewayError.message)
class GatewaySession(
private val scope: CoroutineScope,
@@ -104,18 +103,11 @@ class GatewaySession(
val timeoutMs: Long?,
)
data class InvokeResult(
val ok: Boolean,
val payloadJson: String?,
val error: ErrorShape?,
) {
data class InvokeResult(val ok: Boolean, val payloadJson: String?, val error: ErrorShape?) {
companion object {
fun ok(payloadJson: String?) = InvokeResult(ok = true, payloadJson = payloadJson, error = null)
fun error(
code: String,
message: String,
) = InvokeResult(ok = false, payloadJson = null, error = ErrorShape(code = code, message = message))
fun error(code: String, message: String) =
InvokeResult(ok = false, payloadJson = null, error = ErrorShape(code = code, message = message))
}
}
@@ -125,18 +117,13 @@ class GatewaySession(
val details: GatewayConnectErrorDetails? = null,
)
data class RpcResult(
val ok: Boolean,
val payloadJson: String?,
val error: ErrorShape?,
)
data class RpcResult(val ok: Boolean, val payloadJson: String?, val error: ErrorShape?)
private val json = Json { ignoreUnknownKeys = true }
private val writeLock = Mutex()
private val pending = ConcurrentHashMap<String, CompletableDeferred<RpcResponse>>()
@Volatile private var canvasHostUrl: String? = null
@Volatile private var mainSessionKey: String? = null
private data class DesiredConnection(
@@ -150,13 +137,9 @@ class GatewaySession(
private var desired: DesiredConnection? = null
private var job: Job? = null
@Volatile private var currentConnection: Connection? = null
@Volatile private var pendingDeviceTokenRetry = false
@Volatile private var deviceTokenRetryBudgetUsed = false
@Volatile private var reconnectPausedForAuthFailure = false
fun connect(
@@ -197,78 +180,32 @@ class GatewaySession(
}
fun currentCanvasHostUrl(): String? = canvasHostUrl
fun currentMainSessionKey(): String? = mainSessionKey
suspend fun sendNodeEvent(
event: String,
payloadJson: String?,
): Boolean {
suspend fun sendNodeEvent(event: String, payloadJson: String?): Boolean {
val conn = currentConnection ?: return false
return try {
conn.request(
"node.event",
buildNodeEventParams(event = event, payloadJson = payloadJson),
timeoutMs = 8_000,
)
true
} catch (err: Throwable) {
Log.w("OpenClawGateway", "node.event failed: ${err::class.java.simpleName}")
false
}
}
suspend fun sendNodeEventDetailed(
event: String,
payloadJson: String?,
timeoutMs: Long = 8_000,
): RpcResult {
val conn =
currentConnection
?: return RpcResult(
ok = false,
payloadJson = null,
error = ErrorShape("UNAVAILABLE", "not connected"),
)
val params = buildNodeEventParams(event = event, payloadJson = payloadJson)
val params =
buildJsonObject {
put("event", JsonPrimitive(event))
put("payloadJSON", JsonPrimitive(payloadJson ?: "{}"))
}
try {
val res = conn.request("node.event", params, timeoutMs = timeoutMs)
return RpcResult(ok = res.ok, payloadJson = res.payloadJson, error = res.error)
conn.request("node.event", params, timeoutMs = 8_000)
return true
} catch (err: Throwable) {
Log.w("OpenClawGateway", "node.event failed: ${err::class.java.simpleName}")
return RpcResult(
ok = false,
payloadJson = null,
error = ErrorShape("UNAVAILABLE", "node.event failed"),
)
Log.w("OpenClawGateway", "node.event failed: ${err.message ?: err::class.java.simpleName}")
return false
}
}
private fun buildNodeEventParams(
event: String,
payloadJson: String?,
): JsonObject =
buildJsonObject {
put("event", JsonPrimitive(event))
put("payloadJSON", JsonPrimitive(payloadJson ?: "{}"))
}
suspend fun request(
method: String,
paramsJson: String?,
timeoutMs: Long = 15_000,
): String {
suspend fun request(method: String, paramsJson: String?, timeoutMs: Long = 15_000): String {
val res = requestDetailed(method = method, paramsJson = paramsJson, timeoutMs = timeoutMs)
if (res.ok) return res.payloadJson ?: ""
val err = res.error
throw IllegalStateException("${err?.code ?: "UNAVAILABLE"}: ${err?.message ?: "request failed"}")
}
suspend fun requestDetailed(
method: String,
paramsJson: String?,
timeoutMs: Long = 15_000,
): RpcResult {
suspend fun requestDetailed(method: String, paramsJson: String?, timeoutMs: Long = 15_000): RpcResult {
val conn = currentConnection ?: throw IllegalStateException("not connected")
val params =
if (paramsJson.isNullOrBlank()) {
@@ -302,12 +239,7 @@ class GatewaySession(
return false
}
val payloadObj = response.payloadJson?.let(::parseJsonOrNull)?.asObjectOrNull()
val refreshedCapability =
payloadObj
?.get("canvasCapability")
.asStringOrNull()
?.trim()
.orEmpty()
val refreshedCapability = payloadObj?.get("canvasCapability").asStringOrNull()?.trim().orEmpty()
if (refreshedCapability.isEmpty()) {
Log.w("OpenClawGateway", "node.canvas.capability.refresh missing canvasCapability")
return false
@@ -326,12 +258,7 @@ class GatewaySession(
return true
}
private data class RpcResponse(
val id: String,
val ok: Boolean,
val payloadJson: String?,
val error: ErrorShape?,
)
private data class RpcResponse(val id: String, val ok: Boolean, val payloadJson: String?, val error: ErrorShape?)
private inner class Connection(
private val endpoint: GatewayEndpoint,
@@ -362,11 +289,7 @@ class GatewaySession(
}
}
suspend fun request(
method: String,
params: JsonElement?,
timeoutMs: Long,
): RpcResponse {
suspend fun request(method: String, params: JsonElement?, timeoutMs: Long): RpcResponse {
val id = UUID.randomUUID().toString()
val deferred = CompletableDeferred<RpcResponse>()
pending[id] = deferred
@@ -404,16 +327,13 @@ class GatewaySession(
}
private fun buildClient(): OkHttpClient {
val builder =
OkHttpClient
.Builder()
.writeTimeout(60, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(0, java.util.concurrent.TimeUnit.SECONDS)
.pingInterval(30, java.util.concurrent.TimeUnit.SECONDS)
val tlsConfig =
buildGatewayTlsConfig(tls) { fingerprint ->
onTlsFingerprint?.invoke(tls?.stableId ?: endpoint.stableId, fingerprint)
}
val builder = OkHttpClient.Builder()
.writeTimeout(60, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(0, java.util.concurrent.TimeUnit.SECONDS)
.pingInterval(30, java.util.concurrent.TimeUnit.SECONDS)
val tlsConfig = buildGatewayTlsConfig(tls) { fingerprint ->
onTlsFingerprint?.invoke(tls?.stableId ?: endpoint.stableId, fingerprint)
}
if (tlsConfig != null) {
builder.sslSocketFactory(tlsConfig.sslSocketFactory, tlsConfig.trustManager)
builder.hostnameVerifier(tlsConfig.hostnameVerifier)
@@ -422,10 +342,7 @@ class GatewaySession(
}
private inner class Listener : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) {
override fun onOpen(webSocket: WebSocket, response: Response) {
scope.launch {
try {
val nonce = awaitConnectNonce()
@@ -437,18 +354,11 @@ class GatewaySession(
}
}
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
override fun onMessage(webSocket: WebSocket, text: String) {
scope.launch { handleMessage(text) }
}
override fun onFailure(
webSocket: WebSocket,
t: Throwable,
response: Response?,
) {
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
if (!connectDeferred.isCompleted) {
connectDeferred.completeExceptionally(t)
}
@@ -459,11 +369,7 @@ class GatewaySession(
}
}
override fun onClosed(
webSocket: WebSocket,
code: Int,
reason: String,
) {
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
if (!connectDeferred.isCompleted) {
connectDeferred.completeExceptionally(IllegalStateException("Gateway closed: $reason"))
}
@@ -514,7 +420,7 @@ class GatewaySession(
deviceTokenRetryBudgetUsed = true
} else if (
selectedAuth.attemptedDeviceTokenRetry &&
shouldClearStoredDeviceTokenAfterRetry(error)
shouldClearStoredDeviceTokenAfterRetry(error)
) {
deviceAuthStore.clearToken(identity.deviceId, options.role)
}
@@ -530,11 +436,8 @@ class GatewaySession(
return tls != null
}
private fun filteredBootstrapHandoffScopes(
role: String,
scopes: List<String>,
): List<String>? =
when (role.trim()) {
private fun filteredBootstrapHandoffScopes(role: String, scopes: List<String>): List<String>? {
return when (role.trim()) {
"node" -> emptyList()
"operator" -> {
val allowedOperatorScopes =
@@ -548,6 +451,7 @@ class GatewaySession(
}
else -> null
}
}
private fun persistBootstrapHandoffToken(
deviceId: String,
@@ -589,25 +493,20 @@ class GatewaySession(
val deviceToken = authObj?.get("deviceToken").asStringOrNull()
val authRole = authObj?.get("role").asStringOrNull() ?: options.role
val authScopes =
authObj
?.get("scopes")
.asArrayOrNull()
authObj?.get("scopes").asArrayOrNull()
?.mapNotNull { it.asStringOrNull() }
?: emptyList()
if (!deviceToken.isNullOrBlank()) {
persistIssuedDeviceToken(authSource, deviceId, authRole, deviceToken, authScopes)
}
if (shouldPersistBootstrapHandoffTokens(authSource)) {
authObj
?.get("deviceTokens")
.asArrayOrNull()
authObj?.get("deviceTokens").asArrayOrNull()
?.mapNotNull { it.asObjectOrNull() }
?.forEach { tokenEntry ->
val handoffToken = tokenEntry["deviceToken"].asStringOrNull()
val handoffRole = tokenEntry["role"].asStringOrNull()
val handoffScopes =
tokenEntry["scopes"]
.asArrayOrNull()
tokenEntry["scopes"].asArrayOrNull()
?.mapNotNull { it.asStringOrNull() }
?: emptyList()
if (!handoffToken.isNullOrBlank() && !handoffRole.isNullOrBlank()) {
@@ -618,10 +517,8 @@ class GatewaySession(
val rawCanvas = obj["canvasHostUrl"].asStringOrNull()
canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint, isTlsConnection = tls != null)
val sessionDefaults =
obj["snapshot"]
.asObjectOrNull()
?.get("sessionDefaults")
.asObjectOrNull()
obj["snapshot"].asObjectOrNull()
?.get("sessionDefaults").asObjectOrNull()
mainSessionKey = sessionDefaults?.get("mainSessionKey").asStringOrNull()
onConnected(serverName, remoteAddress, mainSessionKey)
}
@@ -768,12 +665,13 @@ class GatewaySession(
onEvent(event, payloadJson)
}
private suspend fun awaitConnectNonce(): String =
try {
private suspend fun awaitConnectNonce(): String {
return try {
withTimeout(2_000) { connectNonceDeferred.await() }
} catch (err: Throwable) {
throw IllegalStateException("connect challenge timeout", err)
}
}
private fun extractConnectNonce(payloadJson: String?): String? {
if (payloadJson.isNullOrBlank()) return null
@@ -882,7 +780,7 @@ class GatewaySession(
onDisconnected("Gateway error: ${err.message ?: err::class.java.simpleName}")
if (
err is GatewayConnectFailure &&
shouldPauseReconnectAfterAuthFailure(err.gatewayError)
shouldPauseReconnectAfterAuthFailure(err.gatewayError)
) {
reconnectPausedForAuthFailure = true
continue
@@ -893,27 +791,26 @@ class GatewaySession(
}
}
private suspend fun connectOnce(target: DesiredConnection) =
withContext(Dispatchers.IO) {
val conn =
Connection(
target.endpoint,
target.token,
target.bootstrapToken,
target.password,
target.options,
target.tls,
)
currentConnection = conn
try {
conn.connect()
conn.awaitClose()
} finally {
currentConnection = null
canvasHostUrl = null
mainSessionKey = null
}
private suspend fun connectOnce(target: DesiredConnection) = withContext(Dispatchers.IO) {
val conn =
Connection(
target.endpoint,
target.token,
target.bootstrapToken,
target.password,
target.options,
target.tls,
)
currentConnection = conn
try {
conn.connect()
conn.awaitClose()
} finally {
currentConnection = null
canvasHostUrl = null
mainSessionKey = null
}
}
private fun normalizeCanvasHostUrl(
raw: String?,
@@ -924,12 +821,7 @@ class GatewaySession(
val parsed = trimmed.takeIf { it.isNotBlank() }?.let { runCatching { java.net.URI(it) }.getOrNull() }
val host = parsed?.host?.trim().orEmpty()
val port = parsed?.port ?: -1
val scheme =
parsed
?.scheme
?.trim()
.orEmpty()
.ifBlank { "http" }
val scheme = parsed?.scheme?.trim().orEmpty().ifBlank { "http" }
val suffix = buildUrlSuffix(parsed)
// If raw URL is a non-loopback address and this connection uses TLS,
@@ -941,7 +833,7 @@ class GatewaySession(
!scheme.equals("https", ignoreCase = true) ||
(port > 0 && port != endpoint.port) ||
(port <= 0 && endpoint.port != 443)
)
)
if (needsTlsRewrite) {
return buildCanvasUrl(host = host, scheme = "https", port = endpoint.port, suffix = suffix)
}
@@ -961,12 +853,7 @@ class GatewaySession(
return buildCanvasUrl(host = fallbackHost, scheme = fallbackScheme, port = fallbackPort, suffix = suffix)
}
private fun buildCanvasUrl(
host: String,
scheme: String,
port: Int,
suffix: String,
): String {
private fun buildCanvasUrl(host: String, scheme: String, port: Int, suffix: String): String {
val loweredScheme = scheme.lowercase()
val formattedHost = formatGatewayAuthorityHost(host)
val portSuffix = if ((loweredScheme == "https" && port == 443) || (loweredScheme == "http" && port == 80)) "" else ":$port"
@@ -999,7 +886,7 @@ class GatewaySession(
explicitGatewayToken
?: if (
explicitPassword == null &&
(explicitBootstrapToken == null || storedToken != null)
(explicitBootstrapToken == null || storedToken != null)
) {
storedToken
} else {
@@ -1046,8 +933,8 @@ class GatewaySession(
detailCode == "AUTH_TOKEN_MISMATCH"
}
private fun shouldPauseReconnectAfterAuthFailure(error: ErrorShape): Boolean =
when (error.details?.code) {
private fun shouldPauseReconnectAfterAuthFailure(error: ErrorShape): Boolean {
return when (error.details?.code) {
"AUTH_TOKEN_MISSING",
"AUTH_BOOTSTRAP_TOKEN_INVALID",
"AUTH_PASSWORD_MISSING",
@@ -1055,13 +942,15 @@ class GatewaySession(
"AUTH_RATE_LIMITED",
"PAIRING_REQUIRED",
"CONTROL_UI_DEVICE_IDENTITY_REQUIRED",
"DEVICE_IDENTITY_REQUIRED",
-> true
"DEVICE_IDENTITY_REQUIRED" -> true
"AUTH_TOKEN_MISMATCH" -> deviceTokenRetryBudgetUsed && !pendingDeviceTokenRetry
else -> false
}
}
private fun shouldClearStoredDeviceTokenAfterRetry(error: ErrorShape): Boolean = error.details?.code == "AUTH_DEVICE_TOKEN_MISMATCH"
private fun shouldClearStoredDeviceTokenAfterRetry(error: ErrorShape): Boolean {
return error.details?.code == "AUTH_DEVICE_TOKEN_MISMATCH"
}
private fun isTrustedDeviceRetryEndpoint(
endpoint: GatewayEndpoint,
@@ -1074,23 +963,18 @@ class GatewaySession(
}
}
internal fun buildGatewayWebSocketUrl(
host: String,
port: Int,
useTls: Boolean,
): String {
internal fun buildGatewayWebSocketUrl(host: String, port: Int, useTls: Boolean): String {
val scheme = if (useTls) "wss" else "ws"
return "$scheme://${formatGatewayAuthority(host, port)}"
}
internal fun formatGatewayAuthority(
host: String,
port: Int,
): String = "${formatGatewayAuthorityHost(host)}:$port"
internal fun formatGatewayAuthority(host: String, port: Int): String {
return "${formatGatewayAuthorityHost(host)}:$port"
}
private fun formatGatewayAuthorityHost(host: String): String {
val normalizedHost = host.trim().trim('[', ']')
return if (normalizedHost.contains(":")) "[$normalizedHost]" else normalizedHost
return if (normalizedHost.contains(":")) "[${normalizedHost}]" else normalizedHost
}
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject

View File

@@ -13,15 +13,14 @@ import java.security.SecureRandom
import java.security.cert.CertificateException
import java.security.cert.X509Certificate
import java.util.Locale
import java.util.concurrent.atomic.AtomicReference
import javax.net.ssl.HostnameVerifier
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SNIHostName
import javax.net.ssl.HostnameVerifier
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLException
import javax.net.ssl.SSLParameters
import javax.net.ssl.SSLSocket
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.SNIHostName
import javax.net.ssl.SSLSocket
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
@@ -55,21 +54,14 @@ fun buildGatewayTlsConfig(
if (params == null) return null
val expected = params.expectedFingerprint?.let(::normalizeFingerprint)
val defaultTrust = defaultTrustManager()
@SuppressLint("CustomX509TrustManager")
val trustManager =
object : X509TrustManager {
override fun checkClientTrusted(
chain: Array<X509Certificate>,
authType: String,
) {
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {
defaultTrust.checkClientTrusted(chain, authType)
}
override fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
) {
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
if (chain.isEmpty()) throw CertificateException("empty certificate chain")
val fingerprint = sha256Hex(chain[0].encoded)
if (expected != null) {
@@ -114,29 +106,18 @@ suspend fun probeGatewayTlsFingerprint(
if (port !in 1..65535) return GatewayTlsProbeResult(failure = GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE)
return withContext(Dispatchers.IO) {
val fingerprintRef = AtomicReference<String?>(null)
val probeTrustManager =
@SuppressLint("CustomX509TrustManager")
val trustAll =
@SuppressLint("CustomX509TrustManager", "TrustAllX509TrustManager")
object : X509TrustManager {
override fun checkClientTrusted(
chain: Array<X509Certificate>,
authType: String,
): Unit = throw CertificateException("gateway TLS probe does not accept client certificates")
override fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
) {
if (chain.isEmpty()) throw CertificateException("empty certificate chain")
fingerprintRef.set(sha256Hex(chain[0].encoded))
throw CertificateException("gateway TLS probe captured fingerprint")
}
@SuppressLint("TrustAllX509TrustManager")
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {}
@SuppressLint("TrustAllX509TrustManager")
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {}
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
}
val context = SSLContext.getInstance("TLS")
context.init(null, arrayOf(probeTrustManager), SecureRandom())
context.init(null, arrayOf(trustAll), SecureRandom())
val socket = (context.socketFactory.createSocket() as SSLSocket)
try {
@@ -160,16 +141,13 @@ suspend fun probeGatewayTlsFingerprint(
?: return@withContext GatewayTlsProbeResult(failure = GatewayTlsProbeFailure.TLS_UNAVAILABLE)
GatewayTlsProbeResult(fingerprintSha256 = sha256Hex(cert.encoded))
} catch (err: Throwable) {
fingerprintRef.get()?.let { return@withContext GatewayTlsProbeResult(fingerprintSha256 = it) }
val failure =
when (err) {
is SSLException,
is EOFException,
-> GatewayTlsProbeFailure.TLS_UNAVAILABLE
is EOFException -> GatewayTlsProbeFailure.TLS_UNAVAILABLE
is ConnectException,
is SocketTimeoutException,
is UnknownHostException,
-> GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE
is UnknownHostException -> GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE
else -> GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE
}
GatewayTlsProbeResult(failure = failure)
@@ -201,9 +179,7 @@ private fun sha256Hex(data: ByteArray): String {
}
private fun normalizeFingerprint(raw: String): String {
val stripped =
raw
.trim()
.replace(Regex("^sha-?256\\s*:?\\s*", RegexOption.IGNORE_CASE), "")
val stripped = raw.trim()
.replace(Regex("^sha-?256\\s*:?\\s*", RegexOption.IGNORE_CASE), "")
return stripped.lowercase(Locale.US).filter { it in '0'..'9' || it in 'a'..'f' }
}

View File

@@ -1,5 +1,6 @@
package ai.openclaw.app.node
import ai.openclaw.app.gateway.GatewaySession
import kotlinx.coroutines.delay
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
@@ -12,11 +13,12 @@ class A2UIHandler(
private val getNodeCanvasHostUrl: () -> String?,
private val getOperatorCanvasHostUrl: () -> String?,
) {
fun isTrustedCanvasActionUrl(rawUrl: String?): Boolean =
CanvasActionTrust.isTrustedCanvasActionUrl(
fun isTrustedCanvasActionUrl(rawUrl: String?): Boolean {
return CanvasActionTrust.isTrustedCanvasActionUrl(
rawUrl = rawUrl,
trustedA2uiUrls = listOfNotNull(resolveA2uiHostUrl()),
)
}
fun resolveA2uiHostUrl(): String? {
val nodeRaw = getNodeCanvasHostUrl()?.trim().orEmpty()
@@ -24,7 +26,7 @@ class A2UIHandler(
val raw = if (nodeRaw.isNotBlank()) nodeRaw else operatorRaw
if (raw.isBlank()) return null
val base = raw.trimEnd('/')
return "$base/__openclaw__/a2ui/?platform=android"
return "${base}/__openclaw__/a2ui/?platform=android"
}
suspend fun ensureA2uiReady(a2uiUrl: String): Boolean {
@@ -48,10 +50,7 @@ class A2UIHandler(
return false
}
fun decodeA2uiMessages(
command: String,
paramsJson: String?,
): String {
fun decodeA2uiMessages(command: String, paramsJson: String?): String {
val raw = paramsJson?.trim().orEmpty()
if (raw.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: paramsJSON required")
@@ -77,7 +76,8 @@ class A2UIHandler(
?: throw IllegalArgumentException("A2UI JSONL line ${idx + 1}: expected a JSON object")
validateA2uiV0_8(msg, idx + 1)
msg
}.toList()
}
.toList()
return JsonArray(messages).toString()
}
@@ -86,17 +86,14 @@ class A2UIHandler(
arr.mapIndexed { idx, el ->
val msg =
el as? JsonObject
?: throw IllegalArgumentException("A2UI messages[$idx]: expected a JSON object")
?: throw IllegalArgumentException("A2UI messages[${idx}]: expected a JSON object")
validateA2uiV0_8(msg, idx + 1)
msg
}
return JsonArray(out).toString()
}
private fun validateA2uiV0_8(
msg: JsonObject,
lineNumber: Int,
) {
private fun validateA2uiV0_8(msg: JsonObject, lineNumber: Int) {
if (msg.containsKey("createSurface")) {
throw IllegalArgumentException(
"A2UI JSONL line $lineNumber: looks like A2UI v0.9 (`createSurface`). Canvas supports v0.8 messages only.",
@@ -138,18 +135,19 @@ class A2UIHandler(
})()
"""
fun a2uiApplyMessagesJS(messagesJson: String): String =
"""
(() => {
try {
const host = globalThis.openclawA2UI;
if (!host) return { ok: false, error: "missing openclawA2UI" };
const messages = $messagesJson;
return host.applyMessages(messages);
} catch (e) {
return { ok: false, error: String(e?.message ?? e) };
}
})()
fun a2uiApplyMessagesJS(messagesJson: String): String {
return """
(() => {
try {
const host = globalThis.openclawA2UI;
if (!host) return { ok: false, error: "missing openclawA2UI" };
const messages = $messagesJson;
return host.applyMessages(messages);
} catch (e) {
return { ok: false, error: String(e?.message ?? e) };
}
})()
""".trimIndent()
}
}
}

View File

@@ -1,6 +1,5 @@
package ai.openclaw.app.node
import ai.openclaw.app.gateway.GatewaySession
import android.Manifest
import android.content.ContentResolver
import android.content.ContentUris
@@ -8,15 +7,16 @@ import android.content.ContentValues
import android.content.Context
import android.provider.CalendarContract
import androidx.core.content.ContextCompat
import ai.openclaw.app.gateway.GatewaySession
import java.time.Instant
import java.time.temporal.ChronoUnit
import java.util.TimeZone
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.time.Instant
import java.time.temporal.ChronoUnit
import java.util.TimeZone
private const val DEFAULT_CALENDAR_LIMIT = 50
@@ -52,30 +52,23 @@ internal interface CalendarDataSource {
fun hasWritePermission(context: Context): Boolean
fun events(
context: Context,
request: CalendarEventsRequest,
): List<CalendarEventRecord>
fun events(context: Context, request: CalendarEventsRequest): List<CalendarEventRecord>
fun add(
context: Context,
request: CalendarAddRequest,
): CalendarEventRecord
fun add(context: Context, request: CalendarAddRequest): CalendarEventRecord
}
private object SystemCalendarDataSource : CalendarDataSource {
override fun hasReadPermission(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) ==
override fun hasReadPermission(context: Context): Boolean {
return ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
}
override fun hasWritePermission(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) ==
override fun hasWritePermission(context: Context): Boolean {
return ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
}
override fun events(
context: Context,
request: CalendarEventsRequest,
): List<CalendarEventRecord> {
override fun events(context: Context, request: CalendarEventsRequest): List<CalendarEventRecord> {
val resolver = context.contentResolver
val builder = CalendarContract.Instances.CONTENT_URI.buildUpon()
ContentUris.appendId(builder, request.startMs)
@@ -96,12 +89,7 @@ private object SystemCalendarDataSource : CalendarDataSource {
val out = mutableListOf<CalendarEventRecord>()
while (cursor.moveToNext() && out.size < request.limit) {
val id = cursor.getLong(0)
val title =
cursor
.getString(1)
?.trim()
.orEmpty()
.ifEmpty { "(untitled)" }
val title = cursor.getString(1)?.trim().orEmpty().ifEmpty { "(untitled)" }
val beginMs = cursor.getLong(2)
val endMs = cursor.getLong(3)
val isAllDay = cursor.getInt(4) == 1
@@ -122,10 +110,7 @@ private object SystemCalendarDataSource : CalendarDataSource {
}
}
override fun add(
context: Context,
request: CalendarAddRequest,
): CalendarEventRecord {
override fun add(context: Context, request: CalendarAddRequest): CalendarEventRecord {
val resolver = context.contentResolver
val resolvedCalendarId = resolveCalendarId(resolver, request.calendarId, request.calendarTitle)
val values =
@@ -139,12 +124,10 @@ private object SystemCalendarDataSource : CalendarDataSource {
request.location?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
request.notes?.let { put(CalendarContract.Events.DESCRIPTION, it) }
}
val uri =
resolver.insert(CalendarContract.Events.CONTENT_URI, values)
?: throw IllegalStateException("calendar insert failed")
val eventId =
uri.lastPathSegment?.toLongOrNull()
?: throw IllegalStateException("calendar insert failed")
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
?: throw IllegalStateException("calendar insert failed")
val eventId = uri.lastPathSegment?.toLongOrNull()
?: throw IllegalStateException("calendar insert failed")
return loadEventById(resolver, eventId)
?: throw IllegalStateException("calendar insert failed")
}
@@ -166,54 +149,45 @@ private object SystemCalendarDataSource : CalendarDataSource {
throw IllegalArgumentException("CALENDAR_NOT_FOUND: no default calendar")
}
private fun calendarExists(
resolver: ContentResolver,
id: Long,
): Boolean {
private fun calendarExists(resolver: ContentResolver, id: Long): Boolean {
val projection = arrayOf(CalendarContract.Calendars._ID)
resolver
.query(
CalendarContract.Calendars.CONTENT_URI,
projection,
"${CalendarContract.Calendars._ID}=?",
arrayOf(id.toString()),
null,
).use { cursor ->
return cursor != null && cursor.moveToFirst()
}
resolver.query(
CalendarContract.Calendars.CONTENT_URI,
projection,
"${CalendarContract.Calendars._ID}=?",
arrayOf(id.toString()),
null,
).use { cursor ->
return cursor != null && cursor.moveToFirst()
}
}
private fun findCalendarByTitle(
resolver: ContentResolver,
title: String,
): Long? {
private fun findCalendarByTitle(resolver: ContentResolver, title: String): Long? {
val projection = arrayOf(CalendarContract.Calendars._ID)
resolver
.query(
CalendarContract.Calendars.CONTENT_URI,
projection,
"${CalendarContract.Calendars.CALENDAR_DISPLAY_NAME}=?",
arrayOf(title),
"${CalendarContract.Calendars.IS_PRIMARY} DESC",
).use { cursor ->
if (cursor == null || !cursor.moveToFirst()) return null
return cursor.getLong(0)
}
resolver.query(
CalendarContract.Calendars.CONTENT_URI,
projection,
"${CalendarContract.Calendars.CALENDAR_DISPLAY_NAME}=?",
arrayOf(title),
"${CalendarContract.Calendars.IS_PRIMARY} DESC",
).use { cursor ->
if (cursor == null || !cursor.moveToFirst()) return null
return cursor.getLong(0)
}
}
private fun findDefaultCalendarId(resolver: ContentResolver): Long? {
val projection = arrayOf(CalendarContract.Calendars._ID)
resolver
.query(
CalendarContract.Calendars.CONTENT_URI,
projection,
"${CalendarContract.Calendars.VISIBLE}=1",
null,
"${CalendarContract.Calendars.IS_PRIMARY} DESC, ${CalendarContract.Calendars._ID} ASC",
).use { cursor ->
if (cursor == null || !cursor.moveToFirst()) return null
return cursor.getLong(0)
}
resolver.query(
CalendarContract.Calendars.CONTENT_URI,
projection,
"${CalendarContract.Calendars.VISIBLE}=1",
null,
"${CalendarContract.Calendars.IS_PRIMARY} DESC, ${CalendarContract.Calendars._ID} ASC",
).use { cursor ->
if (cursor == null || !cursor.moveToFirst()) return null
return cursor.getLong(0)
}
}
private fun loadEventById(
@@ -230,30 +204,24 @@ private object SystemCalendarDataSource : CalendarDataSource {
CalendarContract.Events.EVENT_LOCATION,
CalendarContract.Events.CALENDAR_DISPLAY_NAME,
)
resolver
.query(
CalendarContract.Events.CONTENT_URI,
projection,
"${CalendarContract.Events._ID}=?",
arrayOf(eventId.toString()),
null,
).use { cursor ->
if (cursor == null || !cursor.moveToFirst()) return null
return CalendarEventRecord(
identifier = cursor.getLong(0).toString(),
title =
cursor
.getString(1)
?.trim()
.orEmpty()
.ifEmpty { "(untitled)" },
startISO = Instant.ofEpochMilli(cursor.getLong(2)).toString(),
endISO = Instant.ofEpochMilli(cursor.getLong(3)).toString(),
isAllDay = cursor.getInt(4) == 1,
location = cursor.getString(5)?.trim()?.ifEmpty { null },
calendarTitle = cursor.getString(6)?.trim()?.ifEmpty { null },
)
}
resolver.query(
CalendarContract.Events.CONTENT_URI,
projection,
"${CalendarContract.Events._ID}=?",
arrayOf(eventId.toString()),
null,
).use { cursor ->
if (cursor == null || !cursor.moveToFirst()) return null
return CalendarEventRecord(
identifier = cursor.getLong(0).toString(),
title = cursor.getString(1)?.trim().orEmpty().ifEmpty { "(untitled)" },
startISO = Instant.ofEpochMilli(cursor.getLong(2)).toString(),
endISO = Instant.ofEpochMilli(cursor.getLong(3)).toString(),
isAllDay = cursor.getInt(4) == 1,
location = cursor.getString(5)?.trim()?.ifEmpty { null },
calendarTitle = cursor.getString(6)?.trim()?.ifEmpty { null },
)
}
}
}
@@ -369,12 +337,10 @@ class CalendarHandler private constructor(
} catch (_: Throwable) {
null
} ?: return null
val start =
parseISO((params["startISO"] as? JsonPrimitive)?.content)
?: return null
val end =
parseISO((params["endISO"] as? JsonPrimitive)?.content)
?: return null
val start = parseISO((params["startISO"] as? JsonPrimitive)?.content)
?: return null
val end = parseISO((params["endISO"] as? JsonPrimitive)?.content)
?: return null
return CalendarAddRequest(
title = (params["title"] as? JsonPrimitive)?.content?.trim().orEmpty(),
startMs = start.toEpochMilli(),
@@ -397,8 +363,8 @@ class CalendarHandler private constructor(
}
}
private fun eventJson(event: CalendarEventRecord): JsonObject =
buildJsonObject {
private fun eventJson(event: CalendarEventRecord): JsonObject {
return buildJsonObject {
put("identifier", JsonPrimitive(event.identifier))
put("title", JsonPrimitive(event.title))
put("startISO", JsonPrimitive(event.startISO))
@@ -407,6 +373,7 @@ class CalendarHandler private constructor(
event.location?.let { put("location", JsonPrimitive(it)) }
event.calendarTitle?.let { put("calendarTitle", JsonPrimitive(it)) }
}
}
companion object {
internal fun forTesting(

View File

@@ -1,15 +1,16 @@
package ai.openclaw.app.node
import ai.openclaw.app.gateway.GatewaySession
import android.Manifest
import android.content.Context
import android.provider.CallLog
import androidx.core.content.ContextCompat
import ai.openclaw.app.gateway.GatewaySession
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.put
private const val DEFAULT_CALL_LOG_LIMIT = 25
@@ -37,32 +38,26 @@ internal data class CallLogSearchRequest(
internal interface CallLogDataSource {
fun hasReadPermission(context: Context): Boolean
fun search(
context: Context,
request: CallLogSearchRequest,
): List<CallLogRecord>
fun search(context: Context, request: CallLogSearchRequest): List<CallLogRecord>
}
private object SystemCallLogDataSource : CallLogDataSource {
override fun hasReadPermission(context: Context): Boolean =
ContextCompat.checkSelfPermission(
override fun hasReadPermission(context: Context): Boolean {
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.READ_CALL_LOG,
Manifest.permission.READ_CALL_LOG
) == android.content.pm.PackageManager.PERMISSION_GRANTED
}
override fun search(
context: Context,
request: CallLogSearchRequest,
): List<CallLogRecord> {
override fun search(context: Context, request: CallLogSearchRequest): List<CallLogRecord> {
val resolver = context.contentResolver
val projection =
arrayOf(
CallLog.Calls.NUMBER,
CallLog.Calls.CACHED_NAME,
CallLog.Calls.DATE,
CallLog.Calls.DURATION,
CallLog.Calls.TYPE,
)
val projection = arrayOf(
CallLog.Calls.NUMBER,
CallLog.Calls.CACHED_NAME,
CallLog.Calls.DATE,
CallLog.Calls.DURATION,
CallLog.Calls.TYPE,
)
// Build selection and selectionArgs for filtering
val selections = mutableListOf<String>()
@@ -110,42 +105,40 @@ private object SystemCallLogDataSource : CallLogDataSource {
val sortOrder = "${CallLog.Calls.DATE} DESC"
resolver
.query(
CallLog.Calls.CONTENT_URI,
projection,
selection,
selectionArgsArray,
sortOrder,
).use { cursor ->
if (cursor == null) return emptyList()
resolver.query(
CallLog.Calls.CONTENT_URI,
projection,
selection,
selectionArgsArray,
sortOrder,
).use { cursor ->
if (cursor == null) return emptyList()
val numberIndex = cursor.getColumnIndex(CallLog.Calls.NUMBER)
val cachedNameIndex = cursor.getColumnIndex(CallLog.Calls.CACHED_NAME)
val dateIndex = cursor.getColumnIndex(CallLog.Calls.DATE)
val durationIndex = cursor.getColumnIndex(CallLog.Calls.DURATION)
val typeIndex = cursor.getColumnIndex(CallLog.Calls.TYPE)
val numberIndex = cursor.getColumnIndex(CallLog.Calls.NUMBER)
val cachedNameIndex = cursor.getColumnIndex(CallLog.Calls.CACHED_NAME)
val dateIndex = cursor.getColumnIndex(CallLog.Calls.DATE)
val durationIndex = cursor.getColumnIndex(CallLog.Calls.DURATION)
val typeIndex = cursor.getColumnIndex(CallLog.Calls.TYPE)
// Skip offset rows
if (request.offset > 0 && cursor.moveToPosition(request.offset - 1)) {
// Successfully moved to offset position
}
val out = mutableListOf<CallLogRecord>()
var count = 0
while (cursor.moveToNext() && count < request.limit) {
out +=
CallLogRecord(
number = cursor.getString(numberIndex),
cachedName = cursor.getString(cachedNameIndex),
date = cursor.getLong(dateIndex),
duration = cursor.getLong(durationIndex),
type = cursor.getInt(typeIndex),
)
count++
}
return out
// Skip offset rows
if (request.offset > 0 && cursor.moveToPosition(request.offset - 1)) {
// Successfully moved to offset position
}
val out = mutableListOf<CallLogRecord>()
var count = 0
while (cursor.moveToNext() && count < request.limit) {
out += CallLogRecord(
number = cursor.getString(numberIndex),
cachedName = cursor.getString(cachedNameIndex),
date = cursor.getLong(dateIndex),
duration = cursor.getLong(durationIndex),
type = cursor.getInt(typeIndex),
)
count++
}
return out
}
}
}
@@ -163,12 +156,11 @@ class CallLogHandler private constructor(
)
}
val request =
parseSearchRequest(paramsJson)
?: return GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: expected JSON object",
)
val request = parseSearchRequest(paramsJson)
?: return GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: expected JSON object",
)
return try {
val callLogs = dataSource.search(appContext, request)
@@ -205,19 +197,16 @@ class CallLogHandler private constructor(
)
}
val params =
try {
Json.parseToJsonElement(paramsJson).asObjectOrNull()
} catch (_: Throwable) {
null
} ?: return null
val params = try {
Json.parseToJsonElement(paramsJson).asObjectOrNull()
} catch (_: Throwable) {
null
} ?: return null
val limit =
((params["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: DEFAULT_CALL_LOG_LIMIT)
.coerceIn(1, 200)
val offset =
((params["offset"] as? JsonPrimitive)?.content?.toIntOrNull() ?: 0)
.coerceAtLeast(0)
val limit = ((params["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: DEFAULT_CALL_LOG_LIMIT)
.coerceIn(1, 200)
val offset = ((params["offset"] as? JsonPrimitive)?.content?.toIntOrNull() ?: 0)
.coerceAtLeast(0)
val cachedName = (params["cachedName"] as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() }
val number = (params["number"] as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() }
val date = (params["date"] as? JsonPrimitive)?.content?.toLongOrNull()
@@ -239,14 +228,15 @@ class CallLogHandler private constructor(
)
}
private fun callLogJson(callLog: CallLogRecord): JsonObject =
buildJsonObject {
private fun callLogJson(callLog: CallLogRecord): JsonObject {
return buildJsonObject {
put("number", JsonPrimitive(callLog.number))
put("cachedName", JsonPrimitive(callLog.cachedName))
put("date", JsonPrimitive(callLog.date))
put("duration", JsonPrimitive(callLog.duration))
put("type", JsonPrimitive(callLog.type))
}
}
companion object {
internal fun forTesting(

View File

@@ -1,23 +1,24 @@
package ai.openclaw.app.node
import ai.openclaw.app.PermissionRequester
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.content.pm.PackageManager
import android.hardware.camera2.CameraCharacteristics
import android.util.Base64
import androidx.camera.camera2.interop.Camera2CameraInfo
import androidx.camera.core.CameraInfo
import androidx.exifinterface.media.ExifInterface
import androidx.lifecycle.LifecycleOwner
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.video.FallbackStrategy
import androidx.camera.video.FileOutputOptions
import androidx.camera.video.FallbackStrategy
import androidx.camera.video.Quality
import androidx.camera.video.QualitySelector
import androidx.camera.video.Recorder
@@ -27,33 +28,22 @@ import androidx.camera.video.VideoRecordEvent
import androidx.core.content.ContextCompat
import androidx.core.content.ContextCompat.checkSelfPermission
import androidx.core.graphics.scale
import androidx.exifinterface.media.ExifInterface
import androidx.lifecycle.LifecycleOwner
import ai.openclaw.app.PermissionRequester
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonObject
import java.io.ByteArrayOutputStream
import java.io.File
import java.util.concurrent.Executor
import kotlin.math.roundToInt
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.math.roundToInt
class CameraCaptureManager(
private val context: Context,
) {
data class Payload(
val payloadJson: String,
)
data class FilePayload(
val file: File,
val durationMs: Long,
val hasAudio: Boolean,
)
class CameraCaptureManager(private val context: Context) {
data class Payload(val payloadJson: String)
data class FilePayload(val file: File, val durationMs: Long, val hasAudio: Boolean)
data class CameraDeviceInfo(
val id: String,
val name: String,
@@ -62,7 +52,6 @@ class CameraCaptureManager(
)
@Volatile private var lifecycleOwner: LifecycleOwner? = null
@Volatile private var permissionRequester: PermissionRequester? = null
fun attachLifecycleOwner(owner: LifecycleOwner) {
@@ -85,9 +74,8 @@ class CameraCaptureManager(
val granted = checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
if (granted) return
val requester =
permissionRequester
?: throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
val requester = permissionRequester
?: throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
val results = requester.requestIfMissing(listOf(Manifest.permission.CAMERA))
if (results[Manifest.permission.CAMERA] != true) {
throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
@@ -98,9 +86,8 @@ class CameraCaptureManager(
val granted = checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
if (granted) return
val requester =
permissionRequester
?: throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission")
val requester = permissionRequester
?: throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission")
val results = requester.requestIfMissing(listOf(Manifest.permission.RECORD_AUDIO))
if (results[Manifest.permission.RECORD_AUDIO] != true) {
throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission")
@@ -124,10 +111,9 @@ class CameraCaptureManager(
provider.unbindAll()
provider.bindToLifecycle(owner, selector, capture)
val (bytes, orientation) = capture.takeJpegWithExif(context.mainExecutor(), context.cacheDir)
val decoded =
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
?: throw IllegalStateException("UNAVAILABLE: failed to decode captured image")
val (bytes, orientation) = capture.takeJpegWithExif(context.mainExecutor())
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
?: throw IllegalStateException("UNAVAILABLE: failed to decode captured image")
val rotated = rotateBitmapByExif(decoded, orientation)
val scaled =
if (maxWidth > 0 && rotated.width > maxWidth) {
@@ -191,30 +177,23 @@ class CameraCaptureManager(
val deviceId = parseDeviceId(params)
if (includeAudio) ensureMicPermission()
android.util.Log.w(
"CameraCaptureManager",
"clip: start facing=$facing duration=$durationMs audio=$includeAudio deviceId=${deviceId ?: "-"}",
)
android.util.Log.w("CameraCaptureManager", "clip: start facing=$facing duration=$durationMs audio=$includeAudio deviceId=${deviceId ?: "-"}")
val provider = context.cameraProvider()
android.util.Log.w("CameraCaptureManager", "clip: got camera provider")
// Use LOWEST quality for smallest files over WebSocket
val recorder =
Recorder
.Builder()
.setQualitySelector(
QualitySelector.from(Quality.LOWEST, FallbackStrategy.lowerQualityOrHigherThan(Quality.LOWEST)),
).build()
val recorder = Recorder.Builder()
.setQualitySelector(
QualitySelector.from(Quality.LOWEST, FallbackStrategy.lowerQualityOrHigherThan(Quality.LOWEST))
)
.build()
val videoCapture = VideoCapture.withOutput(recorder)
val selector = resolveCameraSelector(provider, facing, deviceId)
// CameraX requires a Preview use case for the camera to start producing frames;
// without it, the encoder may get no data (ERROR_NO_VALID_DATA).
val preview =
androidx.camera.core.Preview
.Builder()
.build()
val preview = androidx.camera.core.Preview.Builder().build()
// Provide a dummy SurfaceTexture so the preview pipeline activates
val surfaceTexture = android.graphics.SurfaceTexture(0)
surfaceTexture.setDefaultBufferSize(640, 480)
@@ -235,7 +214,7 @@ class CameraCaptureManager(
android.util.Log.w("CameraCaptureManager", "clip: warming up camera 1.5s...")
kotlinx.coroutines.delay(1_500)
val file = File.createTempFile("openclaw-clip-", ".mp4", context.cacheDir)
val file = File.createTempFile("openclaw-clip-", ".mp4")
val outputOptions = FileOutputOptions.Builder(file).build()
val finalized = kotlinx.coroutines.CompletableDeferred<VideoRecordEvent.Finalize>()
@@ -245,16 +224,14 @@ class CameraCaptureManager(
.prepareRecording(context, outputOptions)
.apply {
if (includeAudio) withAudioEnabled()
}.start(context.mainExecutor()) { event ->
}
.start(context.mainExecutor()) { event ->
android.util.Log.w("CameraCaptureManager", "clip: event ${event.javaClass.simpleName}")
if (event is VideoRecordEvent.Status) {
android.util.Log.w("CameraCaptureManager", "clip: recording status update")
}
if (event is VideoRecordEvent.Finalize) {
android.util.Log.w(
"CameraCaptureManager",
"clip: finalize hasError=${event.hasError()} error=${event.error} cause=${event.cause}",
)
android.util.Log.w("CameraCaptureManager", "clip: finalize hasError=${event.hasError()} error=${event.error} cause=${event.cause}")
finalized.complete(event)
}
}
@@ -277,11 +254,7 @@ class CameraCaptureManager(
throw IllegalStateException("UNAVAILABLE: camera clip finalize timed out")
}
if (finalizeEvent.hasError()) {
android.util.Log.e(
"CameraCaptureManager",
"clip: FAILED error=${finalizeEvent.error}, cause=${finalizeEvent.cause}",
finalizeEvent.cause,
)
android.util.Log.e("CameraCaptureManager", "clip: FAILED error=${finalizeEvent.error}, cause=${finalizeEvent.cause}", finalizeEvent.cause)
// Check file size for debugging
val fileSize = withContext(Dispatchers.IO) { if (file.exists()) file.length() else -1 }
android.util.Log.e("CameraCaptureManager", "clip: file exists=${file.exists()} size=$fileSize")
@@ -298,10 +271,7 @@ class CameraCaptureManager(
FilePayload(file = file, durationMs = durationMs.toLong(), hasAudio = includeAudio)
}
private fun rotateBitmapByExif(
bitmap: Bitmap,
orientation: Int,
): Bitmap {
private fun rotateBitmapByExif(bitmap: Bitmap, orientation: Int): Bitmap {
val matrix = Matrix()
when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
@@ -334,13 +304,15 @@ class CameraCaptureManager(
}
}
private fun parseQuality(params: JsonObject?): Double? = parseJsonDouble(params, "quality")
private fun parseQuality(params: JsonObject?): Double? =
parseJsonDouble(params, "quality")
private fun parseMaxWidth(params: JsonObject?): Int? =
parseJsonInt(params, "maxWidth")
?.takeIf { it > 0 }
private fun parseDurationMs(params: JsonObject?): Int? = parseJsonInt(params, "durationMs")
private fun parseDurationMs(params: JsonObject?): Int? =
parseJsonInt(params, "durationMs")
private fun parseDeviceId(params: JsonObject?): String? =
parseJsonString(params, "deviceId")
@@ -363,8 +335,7 @@ class CameraCaptureManager(
if (!availableIds.contains(deviceId)) {
throw IllegalStateException("INVALID_REQUEST: unknown camera deviceId '$deviceId'")
}
return CameraSelector
.Builder()
return CameraSelector.Builder()
.addCameraFilter { infos -> infos.filter { cameraIdOrNull(it) == deviceId } }
.build()
}
@@ -401,7 +372,8 @@ class CameraCaptureManager(
}
@SuppressLint("UnsafeOptInUsageError")
private fun cameraIdOrNull(info: CameraInfo): String? = runCatching { Camera2CameraInfo.from(info).cameraId }.getOrNull()
private fun cameraIdOrNull(info: CameraInfo): String? =
runCatching { Camera2CameraInfo.from(info).cameraId }.getOrNull()
}
private suspend fun Context.cameraProvider(): ProcessCameraProvider =
@@ -420,12 +392,9 @@ private suspend fun Context.cameraProvider(): ProcessCameraProvider =
}
/** Returns (jpegBytes, exifOrientation) so caller can rotate the decoded bitmap. */
private suspend fun ImageCapture.takeJpegWithExif(
executor: Executor,
tempDir: File,
): Pair<ByteArray, Int> =
private suspend fun ImageCapture.takeJpegWithExif(executor: Executor): Pair<ByteArray, Int> =
suspendCancellableCoroutine { cont ->
val file = File.createTempFile("openclaw-snap-", ".jpg", tempDir)
val file = File.createTempFile("openclaw-snap-", ".jpg")
val options = ImageCapture.OutputFileOptions.Builder(file).build()
takePicture(
options,
@@ -439,11 +408,10 @@ private suspend fun ImageCapture.takeJpegWithExif(
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
try {
val exif = ExifInterface(file.absolutePath)
val orientation =
exif.getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL,
)
val orientation = exif.getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL,
)
val bytes = file.readBytes()
cont.resume(Pair(bytes, orientation))
} catch (e: Exception) {

View File

@@ -1,9 +1,9 @@
package ai.openclaw.app.node
import ai.openclaw.app.BuildConfig
import ai.openclaw.app.CameraHudKind
import ai.openclaw.app.gateway.GatewaySession
import android.content.Context
import ai.openclaw.app.CameraHudKind
import ai.openclaw.app.BuildConfig
import ai.openclaw.app.gateway.GatewaySession
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.withContext
@@ -16,7 +16,8 @@ import kotlinx.serialization.json.put
internal const val CAMERA_CLIP_MAX_RAW_BYTES: Long = 18L * 1024L * 1024L
internal fun isCameraClipWithinPayloadLimit(rawBytes: Long): Boolean = rawBytes in 0L..CAMERA_CLIP_MAX_RAW_BYTES
internal fun isCameraClipWithinPayloadLimit(rawBytes: Long): Boolean =
rawBytes in 0L..CAMERA_CLIP_MAX_RAW_BYTES
class CameraHandler(
private val appContext: Context,
@@ -26,8 +27,8 @@ class CameraHandler(
private val triggerCameraFlash: () -> Unit,
private val invokeErrorFromThrowable: (err: Throwable) -> Pair<String, String>,
) {
suspend fun handleList(_paramsJson: String?): GatewaySession.InvokeResult =
try {
suspend fun handleList(_paramsJson: String?): GatewaySession.InvokeResult {
return try {
val devices = camera.listDevices()
val payload =
buildJsonObject {
@@ -52,10 +53,10 @@ class CameraHandler(
val (code, message) = invokeErrorFromThrowable(err)
GatewaySession.InvokeResult.error(code = code, message = message)
}
}
suspend fun handleSnap(paramsJson: String?): GatewaySession.InvokeResult {
val logFile = if (BuildConfig.DEBUG) java.io.File(appContext.cacheDir, "camera_debug.log") else null
fun camLog(msg: String) {
if (!BuildConfig.DEBUG) return
val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.US).format(java.util.Date())
@@ -94,7 +95,6 @@ class CameraHandler(
suspend fun handleClip(paramsJson: String?): GatewaySession.InvokeResult {
val clipLogFile = if (BuildConfig.DEBUG) java.io.File(appContext.cacheDir, "camera_debug.log") else null
fun clipLog(msg: String) {
if (!BuildConfig.DEBUG) return
val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.US).format(java.util.Date())
@@ -133,19 +133,18 @@ class CameraHandler(
)
}
val bytes =
withContext(Dispatchers.IO) {
try {
filePayload.file.readBytes()
} finally {
filePayload.file.delete()
}
val bytes = withContext(Dispatchers.IO) {
try {
filePayload.file.readBytes()
} finally {
filePayload.file.delete()
}
}
val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)
clipLog("returning base64 payload")
showCameraHud("Clip captured", CameraHudKind.Success, 1800)
return GatewaySession.InvokeResult.ok(
"""{"format":"mp4","base64":"$base64","durationMs":${filePayload.durationMs},"hasAudio":${filePayload.hasAudio}}""",
"""{"format":"mp4","base64":"$base64","durationMs":${filePayload.durationMs},"hasAudio":${filePayload.hasAudio}}"""
)
} catch (err: Throwable) {
clipLog("outer error: ${err::class.java.simpleName}: ${err.message}")

View File

@@ -5,10 +5,7 @@ import java.net.URI
object CanvasActionTrust {
const val scaffoldAssetUrl: String = "file:///android_asset/CanvasScaffold/scaffold.html"
fun isTrustedCanvasActionUrl(
rawUrl: String?,
trustedA2uiUrls: List<String>,
): Boolean {
fun isTrustedCanvasActionUrl(rawUrl: String?, trustedA2uiUrls: List<String>): Boolean {
val candidate = rawUrl?.trim().orEmpty()
if (candidate.isEmpty()) return false
if (candidate == scaffoldAssetUrl) return true
@@ -24,10 +21,7 @@ object CanvasActionTrust {
}
}
private fun matchesTrustedRemoteA2uiUrlExact(
candidateUri: URI,
trustedUrl: String,
): Boolean {
private fun matchesTrustedRemoteA2uiUrlExact(candidateUri: URI, trustedUrl: String): Boolean {
val trustedUri = parseUri(trustedUrl) ?: return false
val normalizedTrusted = normalizeTrustedRemoteA2uiUri(trustedUri) ?: return false
return candidateUri == normalizedTrusted
@@ -39,11 +33,7 @@ object CanvasActionTrust {
val scheme = uri.scheme?.lowercase() ?: return null
if (scheme != "http" && scheme != "https") return null
val host =
uri.host
?.trim()
?.takeIf { it.isNotEmpty() }
?.lowercase() ?: return null
val host = uri.host?.trim()?.takeIf { it.isNotEmpty() }?.lowercase() ?: return null
return try {
URI(scheme, uri.userInfo, host, uri.port, uri.rawPath, uri.rawQuery, null)

View File

@@ -1,46 +1,39 @@
package ai.openclaw.app.node
import ai.openclaw.app.BuildConfig
import android.graphics.Bitmap
import android.graphics.Canvas
import android.os.Looper
import android.util.Base64
import android.util.Log
import android.webkit.WebView
import androidx.core.graphics.createBitmap
import androidx.core.graphics.scale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
import android.util.Base64
import org.json.JSONObject
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import org.json.JSONObject
import java.io.ByteArrayOutputStream
import ai.openclaw.app.BuildConfig
import kotlin.coroutines.resume
class CanvasController {
enum class SnapshotFormat(
val rawValue: String,
) {
enum class SnapshotFormat(val rawValue: String) {
Png("png"),
Jpeg("jpeg"),
}
@Volatile private var webView: WebView? = null
@Volatile private var url: String? = null
@Volatile private var debugStatusEnabled: Boolean = false
@Volatile private var debugStatusTitle: String? = null
@Volatile private var debugStatusSubtitle: String? = null
@Volatile private var homeCanvasStateJson: String? = null
private val _currentUrl = MutableStateFlow<String?>(null)
val currentUrl: StateFlow<String?> = _currentUrl.asStateFlow()
@@ -89,10 +82,7 @@ class CanvasController {
applyDebugStatus()
}
fun setDebugStatus(
title: String?,
subtitle: String?,
) {
fun setDebugStatus(title: String?, subtitle: String?) {
debugStatusTitle = title
debugStatusSubtitle = subtitle
applyDebugStatus()
@@ -141,8 +131,7 @@ class CanvasController {
withWebViewOnMain { wv ->
val titleJs = title?.let { JSONObject.quote(it) } ?: "null"
val subtitleJs = subtitle?.let { JSONObject.quote(it) } ?: "null"
val js =
"""
val js = """
(() => {
try {
const api = globalThis.__openclaw;
@@ -156,7 +145,7 @@ class CanvasController {
}
} catch (_) {}
})();
""".trimIndent()
""".trimIndent()
wv.evaluateJavascript(js, null)
}
}
@@ -164,8 +153,7 @@ class CanvasController {
private fun applyHomeCanvasState() {
val payload = homeCanvasStateJson ?: "null"
withWebViewOnMain { wv ->
val js =
"""
val js = """
(() => {
try {
const api = globalThis.__openclaw;
@@ -173,7 +161,7 @@ class CanvasController {
api.renderHome($payload);
} catch (_) {}
})();
""".trimIndent()
""".trimIndent()
wv.evaluateJavascript(js, null)
}
}
@@ -206,11 +194,7 @@ class CanvasController {
}
}
suspend fun snapshotBase64(
format: SnapshotFormat,
quality: Double?,
maxWidth: Int?,
): String =
suspend fun snapshotBase64(format: SnapshotFormat, quality: Double?, maxWidth: Int?): String =
withContext(Dispatchers.Main) {
val wv = webView ?: throw IllegalStateException("no webview")
val bmp = wv.captureBitmap()
@@ -246,11 +230,7 @@ class CanvasController {
}
companion object {
data class SnapshotParams(
val format: SnapshotFormat,
val quality: Double?,
val maxWidth: Int?,
)
data class SnapshotParams(val format: SnapshotFormat, val quality: Double?, val maxWidth: Int?)
fun parseNavigateUrl(paramsJson: String?): String {
val obj = parseParamsObject(paramsJson) ?: return ""
@@ -289,12 +269,13 @@ class CanvasController {
return q.coerceIn(0.1, 1.0)
}
fun parseSnapshotParams(paramsJson: String?): SnapshotParams =
SnapshotParams(
fun parseSnapshotParams(paramsJson: String?): SnapshotParams {
return SnapshotParams(
format = parseSnapshotFormat(paramsJson),
quality = parseSnapshotQuality(paramsJson),
maxWidth = parseSnapshotMaxWidth(paramsJson),
)
}
private val json = Json { ignoreUnknownKeys = true }

View File

@@ -1,15 +1,15 @@
package ai.openclaw.app.node
import android.os.Build
import ai.openclaw.app.BuildConfig
import ai.openclaw.app.LocationMode
import ai.openclaw.app.SecurePrefs
import ai.openclaw.app.VoiceWakeMode
import ai.openclaw.app.gateway.GatewayClientInfo
import ai.openclaw.app.gateway.GatewayConnectOptions
import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.gateway.GatewayTlsParams
import ai.openclaw.app.gateway.isLoopbackGatewayHost
import android.os.Build
import ai.openclaw.app.LocationMode
import ai.openclaw.app.VoiceWakeMode
class ConnectionManager(
private val prefs: SecurePrefs,
@@ -115,27 +115,22 @@ class ConnectionManager(
}
}
fun resolveModelIdentifier(): String? =
listOfNotNull(Build.MANUFACTURER, Build.MODEL)
fun resolveModelIdentifier(): String? {
return listOfNotNull(Build.MANUFACTURER, Build.MODEL)
.joinToString(" ")
.trim()
.ifEmpty { null }
}
fun buildUserAgent(): String {
val version = resolvedVersionName()
val release =
Build.VERSION.RELEASE
?.trim()
.orEmpty()
val release = Build.VERSION.RELEASE?.trim().orEmpty()
val releaseLabel = if (release.isEmpty()) "unknown" else release
return "OpenClawAndroid/$version (Android $releaseLabel; SDK ${Build.VERSION.SDK_INT})"
}
fun buildClientInfo(
clientId: String,
clientMode: String,
): GatewayClientInfo =
GatewayClientInfo(
fun buildClientInfo(clientId: String, clientMode: String): GatewayClientInfo {
return GatewayClientInfo(
id = clientId,
displayName = prefs.displayName.value,
version = resolvedVersionName(),
@@ -145,9 +140,10 @@ class ConnectionManager(
deviceFamily = "Android",
modelIdentifier = resolveModelIdentifier(),
)
}
fun buildNodeConnectOptions(): GatewayConnectOptions =
GatewayConnectOptions(
fun buildNodeConnectOptions(): GatewayConnectOptions {
return GatewayConnectOptions(
role = "node",
scopes = emptyList(),
caps = buildCapabilities(),
@@ -156,9 +152,10 @@ class ConnectionManager(
client = buildClientInfo(clientId = "openclaw-android", clientMode = "node"),
userAgent = buildUserAgent(),
)
}
fun buildOperatorConnectOptions(): GatewayConnectOptions =
GatewayConnectOptions(
fun buildOperatorConnectOptions(): GatewayConnectOptions {
return GatewayConnectOptions(
role = "operator",
scopes = listOf("operator.read", "operator.write", "operator.talk.secrets"),
caps = emptyList(),
@@ -167,6 +164,7 @@ class ConnectionManager(
client = buildClientInfo(clientId = "openclaw-android", clientMode = "ui"),
userAgent = buildUserAgent(),
)
}
fun resolveTlsParams(endpoint: GatewayEndpoint): GatewayTlsParams? {
val stored = prefs.loadGatewayTlsFingerprint(endpoint.stableId)

View File

@@ -1,12 +1,13 @@
package ai.openclaw.app.node
import ai.openclaw.app.gateway.GatewaySession
import android.Manifest
import android.content.ContentProviderOperation
import android.content.ContentResolver
import android.content.ContentValues
import android.content.Context
import android.provider.ContactsContract
import androidx.core.content.ContextCompat
import ai.openclaw.app.gateway.GatewaySession
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
@@ -46,30 +47,23 @@ internal interface ContactsDataSource {
fun hasWritePermission(context: Context): Boolean
fun search(
context: Context,
request: ContactsSearchRequest,
): List<ContactRecord>
fun search(context: Context, request: ContactsSearchRequest): List<ContactRecord>
fun add(
context: Context,
request: ContactsAddRequest,
): ContactRecord
fun add(context: Context, request: ContactsAddRequest): ContactRecord
}
private object SystemContactsDataSource : ContactsDataSource {
override fun hasReadPermission(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) ==
override fun hasReadPermission(context: Context): Boolean {
return ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
}
override fun hasWritePermission(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CONTACTS) ==
override fun hasWritePermission(context: Context): Boolean {
return ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CONTACTS) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
}
override fun search(
context: Context,
request: ContactsSearchRequest,
): List<ContactRecord> {
override fun search(context: Context, request: ContactsSearchRequest): List<ContactRecord> {
val resolver = context.contentResolver
val projection =
arrayOf(
@@ -86,43 +80,37 @@ private object SystemContactsDataSource : ContactsDataSource {
selectionArgs = arrayOf("%${escapeLikePattern(request.query)}%")
}
val sortOrder = "${ContactsContract.Contacts.DISPLAY_NAME_PRIMARY} COLLATE NOCASE ASC LIMIT ${request.limit}"
resolver
.query(
ContactsContract.Contacts.CONTENT_URI,
projection,
selection,
selectionArgs,
sortOrder,
).use { cursor ->
if (cursor == null) return emptyList()
val idIndex = cursor.getColumnIndexOrThrow(ContactsContract.Contacts._ID)
val displayNameIndex = cursor.getColumnIndexOrThrow(ContactsContract.Contacts.DISPLAY_NAME_PRIMARY)
val out = mutableListOf<ContactRecord>()
while (cursor.moveToNext() && out.size < request.limit) {
val contactId = cursor.getLong(idIndex)
val displayName = cursor.getString(displayNameIndex).orEmpty()
out += loadContactRecord(resolver, contactId, fallbackDisplayName = displayName)
}
return out
resolver.query(
ContactsContract.Contacts.CONTENT_URI,
projection,
selection,
selectionArgs,
sortOrder,
).use { cursor ->
if (cursor == null) return emptyList()
val idIndex = cursor.getColumnIndexOrThrow(ContactsContract.Contacts._ID)
val displayNameIndex = cursor.getColumnIndexOrThrow(ContactsContract.Contacts.DISPLAY_NAME_PRIMARY)
val out = mutableListOf<ContactRecord>()
while (cursor.moveToNext() && out.size < request.limit) {
val contactId = cursor.getLong(idIndex)
val displayName = cursor.getString(displayNameIndex).orEmpty()
out += loadContactRecord(resolver, contactId, fallbackDisplayName = displayName)
}
return out
}
}
override fun add(
context: Context,
request: ContactsAddRequest,
): ContactRecord {
override fun add(context: Context, request: ContactsAddRequest): ContactRecord {
val resolver = context.contentResolver
val operations = ArrayList<ContentProviderOperation>()
operations +=
ContentProviderOperation
.newInsert(ContactsContract.RawContacts.CONTENT_URI)
ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI)
.withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, null)
.withValue(ContactsContract.RawContacts.ACCOUNT_NAME, null)
.build()
if (!request.givenName.isNullOrEmpty() || !request.familyName.isNullOrEmpty() || !request.displayName.isNullOrEmpty()) {
operations +=
ContentProviderOperation
.newInsert(ContactsContract.Data.CONTENT_URI)
ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
.withValue(ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME, request.givenName)
@@ -132,8 +120,7 @@ private object SystemContactsDataSource : ContactsDataSource {
}
if (!request.organizationName.isNullOrEmpty()) {
operations +=
ContentProviderOperation
.newInsert(ContactsContract.Data.CONTENT_URI)
ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Organization.CONTENT_ITEM_TYPE)
.withValue(ContactsContract.CommonDataKinds.Organization.COMPANY, request.organizationName)
@@ -141,8 +128,7 @@ private object SystemContactsDataSource : ContactsDataSource {
}
request.phoneNumbers.forEach { number ->
operations +=
ContentProviderOperation
.newInsert(ContactsContract.Data.CONTENT_URI)
ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE)
.withValue(ContactsContract.CommonDataKinds.Phone.NUMBER, number)
@@ -151,8 +137,7 @@ private object SystemContactsDataSource : ContactsDataSource {
}
request.emails.forEach { email ->
operations +=
ContentProviderOperation
.newInsert(ContactsContract.Data.CONTENT_URI)
ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE)
.withValue(ContactsContract.CommonDataKinds.Email.ADDRESS, email)
@@ -161,15 +146,12 @@ private object SystemContactsDataSource : ContactsDataSource {
}
val results = resolver.applyBatch(ContactsContract.AUTHORITY, operations)
val rawContactUri =
results.firstOrNull()?.uri
?: throw IllegalStateException("contact insert failed")
val rawContactId =
rawContactUri.lastPathSegment?.toLongOrNull()
?: throw IllegalStateException("contact insert failed")
val contactId =
resolveContactIdForRawContact(resolver, rawContactId)
?: throw IllegalStateException("contact insert failed")
val rawContactUri = results.firstOrNull()?.uri
?: throw IllegalStateException("contact insert failed")
val rawContactId = rawContactUri.lastPathSegment?.toLongOrNull()
?: throw IllegalStateException("contact insert failed")
val contactId = resolveContactIdForRawContact(resolver, rawContactId)
?: throw IllegalStateException("contact insert failed")
return loadContactRecord(
resolver = resolver,
contactId = contactId,
@@ -177,23 +159,19 @@ private object SystemContactsDataSource : ContactsDataSource {
)
}
private fun resolveContactIdForRawContact(
resolver: ContentResolver,
rawContactId: Long,
): Long? {
private fun resolveContactIdForRawContact(resolver: ContentResolver, rawContactId: Long): Long? {
val projection = arrayOf(ContactsContract.RawContacts.CONTACT_ID)
resolver
.query(
ContactsContract.RawContacts.CONTENT_URI,
projection,
"${ContactsContract.RawContacts._ID}=?",
arrayOf(rawContactId.toString()),
null,
).use { cursor ->
if (cursor == null || !cursor.moveToFirst()) return null
val index = cursor.getColumnIndexOrThrow(ContactsContract.RawContacts.CONTACT_ID)
return cursor.getLong(index)
}
resolver.query(
ContactsContract.RawContacts.CONTENT_URI,
projection,
"${ContactsContract.RawContacts._ID}=?",
arrayOf(rawContactId.toString()),
null,
).use { cursor ->
if (cursor == null || !cursor.moveToFirst()) return null
val index = cursor.getColumnIndexOrThrow(ContactsContract.RawContacts.CONTACT_ID)
return cursor.getLong(index)
}
}
private fun loadContactRecord(
@@ -228,80 +206,69 @@ private object SystemContactsDataSource : ContactsDataSource {
val displayName: String?,
)
private fun loadNameRow(
resolver: ContentResolver,
contactId: Long,
): NameRow {
private fun loadNameRow(resolver: ContentResolver, contactId: Long): NameRow {
val projection =
arrayOf(
ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME,
ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME,
ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME,
)
resolver
.query(
ContactsContract.Data.CONTENT_URI,
projection,
"${ContactsContract.Data.CONTACT_ID}=? AND ${ContactsContract.Data.MIMETYPE}=?",
arrayOf(
contactId.toString(),
ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE,
),
null,
).use { cursor ->
if (cursor == null || !cursor.moveToFirst()) {
return NameRow(givenName = null, familyName = null, displayName = null)
}
val given = cursor.getString(0)?.trim()?.ifEmpty { null }
val family = cursor.getString(1)?.trim()?.ifEmpty { null }
val display = cursor.getString(2)?.trim()?.ifEmpty { null }
return NameRow(givenName = given, familyName = family, displayName = display)
resolver.query(
ContactsContract.Data.CONTENT_URI,
projection,
"${ContactsContract.Data.CONTACT_ID}=? AND ${ContactsContract.Data.MIMETYPE}=?",
arrayOf(
contactId.toString(),
ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE,
),
null,
).use { cursor ->
if (cursor == null || !cursor.moveToFirst()) {
return NameRow(givenName = null, familyName = null, displayName = null)
}
val given = cursor.getString(0)?.trim()?.ifEmpty { null }
val family = cursor.getString(1)?.trim()?.ifEmpty { null }
val display = cursor.getString(2)?.trim()?.ifEmpty { null }
return NameRow(givenName = given, familyName = family, displayName = display)
}
}
private fun loadOrganization(
resolver: ContentResolver,
contactId: Long,
): String? {
private fun loadOrganization(resolver: ContentResolver, contactId: Long): String? {
val projection = arrayOf(ContactsContract.CommonDataKinds.Organization.COMPANY)
resolver
.query(
ContactsContract.Data.CONTENT_URI,
projection,
"${ContactsContract.Data.CONTACT_ID}=? AND ${ContactsContract.Data.MIMETYPE}=?",
arrayOf(contactId.toString(), ContactsContract.CommonDataKinds.Organization.CONTENT_ITEM_TYPE),
null,
).use { cursor ->
if (cursor == null || !cursor.moveToFirst()) return null
return cursor.getString(0)?.trim()?.ifEmpty { null }
}
resolver.query(
ContactsContract.Data.CONTENT_URI,
projection,
"${ContactsContract.Data.CONTACT_ID}=? AND ${ContactsContract.Data.MIMETYPE}=?",
arrayOf(contactId.toString(), ContactsContract.CommonDataKinds.Organization.CONTENT_ITEM_TYPE),
null,
).use { cursor ->
if (cursor == null || !cursor.moveToFirst()) return null
return cursor.getString(0)?.trim()?.ifEmpty { null }
}
}
private fun escapeLikePattern(pattern: String): String = pattern.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
private fun escapeLikePattern(pattern: String): String =
pattern.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
private fun loadPhones(
resolver: ContentResolver,
contactId: Long,
): List<String> =
queryContactValues(
private fun loadPhones(resolver: ContentResolver, contactId: Long): List<String> {
return queryContactValues(
resolver = resolver,
contentUri = ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
valueColumn = ContactsContract.CommonDataKinds.Phone.NUMBER,
contactIdColumn = ContactsContract.CommonDataKinds.Phone.CONTACT_ID,
contactId = contactId,
)
}
private fun loadEmails(
resolver: ContentResolver,
contactId: Long,
): List<String> =
queryContactValues(
private fun loadEmails(resolver: ContentResolver, contactId: Long): List<String> {
return queryContactValues(
resolver = resolver,
contentUri = ContactsContract.CommonDataKinds.Email.CONTENT_URI,
valueColumn = ContactsContract.CommonDataKinds.Email.ADDRESS,
contactIdColumn = ContactsContract.CommonDataKinds.Email.CONTACT_ID,
contactId = contactId,
)
}
private fun queryContactValues(
resolver: ContentResolver,
@@ -311,22 +278,21 @@ private object SystemContactsDataSource : ContactsDataSource {
contactId: Long,
): List<String> {
val projection = arrayOf(valueColumn)
resolver
.query(
contentUri,
projection,
"$contactIdColumn=?",
arrayOf(contactId.toString()),
null,
).use { cursor ->
if (cursor == null) return emptyList()
val out = LinkedHashSet<String>()
while (cursor.moveToNext()) {
val value = cursor.getString(0)?.trim().orEmpty()
if (value.isNotEmpty()) out += value
}
return out.toList()
resolver.query(
contentUri,
projection,
"$contactIdColumn=?",
arrayOf(contactId.toString()),
null,
).use { cursor ->
if (cursor == null) return emptyList()
val out = LinkedHashSet<String>()
while (cursor.moveToNext()) {
val value = cursor.getString(0)?.trim().orEmpty()
if (value.isNotEmpty()) out += value
}
return out.toList()
}
}
}
@@ -446,8 +412,8 @@ class ContactsHandler private constructor(
}
}
private fun contactJson(contact: ContactRecord): JsonObject =
buildJsonObject {
private fun contactJson(contact: ContactRecord): JsonObject {
return buildJsonObject {
put("identifier", JsonPrimitive(contact.identifier))
put("displayName", JsonPrimitive(contact.displayName))
put("givenName", JsonPrimitive(contact.givenName))
@@ -456,6 +422,7 @@ class ContactsHandler private constructor(
put("phoneNumbers", buildJsonArray { contact.phoneNumbers.forEach { add(JsonPrimitive(it)) } })
put("emails", buildJsonArray { contact.emails.forEach { add(JsonPrimitive(it)) } })
}
}
companion object {
internal fun forTesting(

View File

@@ -1,17 +1,16 @@
package ai.openclaw.app.node
import android.content.Context
import ai.openclaw.app.BuildConfig
import ai.openclaw.app.gateway.DeviceIdentityStore
import ai.openclaw.app.gateway.GatewaySession
import android.content.Context
import kotlinx.serialization.json.JsonPrimitive
private const val LOGCAT_PATH = "/system/bin/logcat"
class DebugHandler(
private val appContext: Context,
private val identityStore: DeviceIdentityStore,
) {
fun handleEd25519(): GatewaySession.InvokeResult {
if (!BuildConfig.DEBUG) {
return GatewaySession.InvokeResult.error(code = "UNAVAILABLE", message = "debug commands are disabled in release builds")
@@ -41,10 +40,9 @@ class DebugHandler(
// Check available providers
val providers = java.security.Security.getProviders()
val ed25519Providers =
providers.filter { p ->
p.services.any { s -> s.algorithm.contains("Ed25519", ignoreCase = true) }
}
val ed25519Providers = providers.filter { p ->
p.services.any { s -> s.algorithm.contains("Ed25519", ignoreCase = true) }
}
results.add("Ed25519 providers: ${ed25519Providers.map { "${it.name} v${it.version}" }}")
results.add("Provider order: ${providers.take(5).map { it.name }}")
@@ -67,10 +65,7 @@ class DebugHandler(
val diagnostics = results.joinToString("\n")
return GatewaySession.InvokeResult.ok("""{"diagnostics":${JsonPrimitive(diagnostics)}}""")
} catch (e: Throwable) {
return GatewaySession.InvokeResult.error(
code = "ED25519_TEST_FAILED",
message = "${e.javaClass.simpleName}: ${e.message}\n${e.stackTraceToString().take(500)}",
)
return GatewaySession.InvokeResult.error(code = "ED25519_TEST_FAILED", message = "${e.javaClass.simpleName}: ${e.message}\n${e.stackTraceToString().take(500)}")
}
}
@@ -80,67 +75,44 @@ class DebugHandler(
}
val pid = android.os.Process.myPid()
val rt = Runtime.getRuntime()
val info = "v6 pid=$pid thread=${Thread.currentThread().name} free=${rt.freeMemory() / 1024}K total=${rt.totalMemory() / 1024}K max=${rt.maxMemory() / 1024}K uptime=${android.os.SystemClock.elapsedRealtime() / 1000}s sdk=${android.os.Build.VERSION.SDK_INT} device=${android.os.Build.MODEL}\n"
val info = "v6 pid=$pid thread=${Thread.currentThread().name} free=${rt.freeMemory()/1024}K total=${rt.totalMemory()/1024}K max=${rt.maxMemory()/1024}K uptime=${android.os.SystemClock.elapsedRealtime()/1000}s sdk=${android.os.Build.VERSION.SDK_INT} device=${android.os.Build.MODEL}\n"
// Run logcat on current dispatcher thread (no withContext) with file redirect
val logResult =
try {
val tmpFile = java.io.File(appContext.cacheDir, "debug_logs.txt")
if (tmpFile.exists()) tmpFile.delete()
val pb = ProcessBuilder(LOGCAT_PATH, "-d", "-t", "200", "--pid=$pid")
pb.redirectOutput(tmpFile)
pb.redirectErrorStream(true)
val proc = pb.start()
val finished = proc.waitFor(4, java.util.concurrent.TimeUnit.SECONDS)
if (!finished) proc.destroyForcibly()
val raw =
if (tmpFile.exists() && tmpFile.length() > 0) {
tmpFile.readText().take(128000)
} else {
"(no output, finished=$finished, exists=${tmpFile.exists()})"
}
tmpFile.delete()
val spamPatterns =
listOf(
"setRequestedFrameRate",
"I View :",
"BLASTBufferQueue",
"VRI[Pop-Up",
"InsetsController:",
"VRI[MainActivity",
"InsetsSource:",
"handleResized",
"ProfileInstaller",
"I VRI[",
"onStateChanged: host=",
"D StrictMode:",
"E StrictMode:",
"ImeFocusController",
"InputTransport",
"IncorrectContextUseViolation",
)
val sb = StringBuilder()
for (line in raw.lineSequence()) {
if (line.isBlank()) continue
if (spamPatterns.any { line.contains(it) }) continue
if (sb.length + line.length > 16000) {
sb.append("\n(truncated)")
break
}
if (sb.isNotEmpty()) sb.append('\n')
sb.append(line)
}
sb.toString().ifEmpty { "(all ${raw.lines().size} lines filtered as spam)" }
} catch (e: Throwable) {
"(logcat error: ${e::class.java.simpleName}: ${e.message})"
val logResult = try {
val tmpFile = java.io.File(appContext.cacheDir, "debug_logs.txt")
if (tmpFile.exists()) tmpFile.delete()
val pb = ProcessBuilder("logcat", "-d", "-t", "200", "--pid=$pid")
pb.redirectOutput(tmpFile)
pb.redirectErrorStream(true)
val proc = pb.start()
val finished = proc.waitFor(4, java.util.concurrent.TimeUnit.SECONDS)
if (!finished) proc.destroyForcibly()
val raw = if (tmpFile.exists() && tmpFile.length() > 0) {
tmpFile.readText().take(128000)
} else {
"(no output, finished=$finished, exists=${tmpFile.exists()})"
}
tmpFile.delete()
val spamPatterns = listOf("setRequestedFrameRate", "I View :", "BLASTBufferQueue", "VRI[Pop-Up",
"InsetsController:", "VRI[MainActivity", "InsetsSource:", "handleResized", "ProfileInstaller",
"I VRI[", "onStateChanged: host=", "D StrictMode:", "E StrictMode:", "ImeFocusController",
"InputTransport", "IncorrectContextUseViolation")
val sb = StringBuilder()
for (line in raw.lineSequence()) {
if (line.isBlank()) continue
if (spamPatterns.any { line.contains(it) }) continue
if (sb.length + line.length > 16000) { sb.append("\n(truncated)"); break }
if (sb.isNotEmpty()) sb.append('\n')
sb.append(line)
}
sb.toString().ifEmpty { "(all ${raw.lines().size} lines filtered as spam)" }
} catch (e: Throwable) {
"(logcat error: ${e::class.java.simpleName}: ${e.message})"
}
// Also include camera debug log if it exists
val camLogFile = java.io.File(appContext.cacheDir, "camera_debug.log")
val camLog =
if (camLogFile.exists() && camLogFile.length() > 0) {
"\n--- camera_debug.log ---\n" + camLogFile.readText().take(4000)
} else {
""
}
val camLog = if (camLogFile.exists() && camLogFile.length() > 0) {
"\n--- camera_debug.log ---\n" + camLogFile.readText().take(4000)
} else ""
return GatewaySession.InvokeResult.ok("""{"logs":${JsonPrimitive(info + logResult + camLog)}}""")
}
}

View File

@@ -1,7 +1,6 @@
package ai.openclaw.app.node
import ai.openclaw.app.BuildConfig
import ai.openclaw.app.gateway.GatewaySession
import android.Manifest
import android.app.ActivityManager
import android.content.Context
@@ -17,11 +16,13 @@ import android.os.PowerManager
import android.os.StatFs
import android.os.SystemClock
import androidx.core.content.ContextCompat
import ai.openclaw.app.gateway.GatewaySession
import java.util.Locale
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.util.Locale
class DeviceHandler(
private val appContext: Context,
@@ -34,16 +35,19 @@ class DeviceHandler(
telephonyAvailable: Boolean,
smsSendGranted: Boolean,
smsReadGranted: Boolean,
): Boolean = smsEnabled && telephonyAvailable && (smsSendGranted || smsReadGranted)
): Boolean {
return smsEnabled && telephonyAvailable && (smsSendGranted || smsReadGranted)
}
internal fun isSmsPromptable(
smsEnabled: Boolean,
telephonyAvailable: Boolean,
smsSendGranted: Boolean,
smsReadGranted: Boolean,
): Boolean = smsEnabled && telephonyAvailable && (!smsSendGranted || !smsReadGranted)
): Boolean {
return smsEnabled && telephonyAvailable && (!smsSendGranted || !smsReadGranted)
}
}
private data class BatterySnapshot(
val status: Int,
val plugged: Int,
@@ -51,13 +55,21 @@ class DeviceHandler(
val temperatureC: Double?,
)
fun handleDeviceStatus(_paramsJson: String?): GatewaySession.InvokeResult = GatewaySession.InvokeResult.ok(statusPayloadJson())
fun handleDeviceStatus(_paramsJson: String?): GatewaySession.InvokeResult {
return GatewaySession.InvokeResult.ok(statusPayloadJson())
}
fun handleDeviceInfo(_paramsJson: String?): GatewaySession.InvokeResult = GatewaySession.InvokeResult.ok(infoPayloadJson())
fun handleDeviceInfo(_paramsJson: String?): GatewaySession.InvokeResult {
return GatewaySession.InvokeResult.ok(infoPayloadJson())
}
fun handleDevicePermissions(_paramsJson: String?): GatewaySession.InvokeResult = GatewaySession.InvokeResult.ok(permissionsPayloadJson())
fun handleDevicePermissions(_paramsJson: String?): GatewaySession.InvokeResult {
return GatewaySession.InvokeResult.ok(permissionsPayloadJson())
}
fun handleDeviceHealth(_paramsJson: String?): GatewaySession.InvokeResult = GatewaySession.InvokeResult.ok(healthPayloadJson())
fun handleDeviceHealth(_paramsJson: String?): GatewaySession.InvokeResult {
return GatewaySession.InvokeResult.ok(healthPayloadJson())
}
private fun statusPayloadJson(): String {
val battery = readBatterySnapshot()
@@ -121,20 +133,14 @@ class DeviceHandler(
val model = Build.MODEL?.trim().orEmpty()
val manufacturer = Build.MANUFACTURER?.trim().orEmpty()
val modelIdentifier = Build.DEVICE?.trim().orEmpty()
val systemVersion =
Build.VERSION.RELEASE
?.trim()
.orEmpty()
val systemVersion = Build.VERSION.RELEASE?.trim().orEmpty()
val locale = Locale.getDefault().toLanguageTag().trim()
val appVersion = BuildConfig.VERSION_NAME.trim()
val appBuild = BuildConfig.VERSION_CODE.toString()
return buildJsonObject {
put("deviceName", JsonPrimitive(model.ifEmpty { "Android" }))
put(
"modelIdentifier",
JsonPrimitive(modelIdentifier.ifEmpty { listOf(manufacturer, model).filter { it.isNotEmpty() }.joinToString(" ") }),
)
put("modelIdentifier", JsonPrimitive(modelIdentifier.ifEmpty { listOf(manufacturer, model).filter { it.isNotEmpty() }.joinToString(" ") }))
put("systemName", JsonPrimitive("Android"))
put("systemVersion", JsonPrimitive(systemVersion.ifEmpty { Build.VERSION.SDK_INT.toString() }))
put("appVersion", JsonPrimitive(appVersion.ifEmpty { "dev" }))
@@ -194,17 +200,7 @@ class DeviceHandler(
put(
"status",
JsonPrimitive(
if (hasAnySmsCapability(
smsEnabled,
canSendSms,
smsSendGranted,
smsReadGranted,
)
) {
"granted"
} else {
"denied"
},
if (hasAnySmsCapability(smsEnabled, canSendSms, smsSendGranted, smsReadGranted)) "granted" else "denied",
),
)
put("promptable", JsonPrimitive(isSmsPromptable(smsEnabled, canSendSms, smsSendGranted, smsReadGranted)))
@@ -371,22 +367,24 @@ class DeviceHandler(
return rawLevel.toDouble() / rawScale.toDouble()
}
private fun mapBatteryState(status: Int): String =
when (status) {
private fun mapBatteryState(status: Int): String {
return when (status) {
BatteryManager.BATTERY_STATUS_CHARGING -> "charging"
BatteryManager.BATTERY_STATUS_FULL -> "full"
BatteryManager.BATTERY_STATUS_DISCHARGING, BatteryManager.BATTERY_STATUS_NOT_CHARGING -> "unplugged"
else -> "unknown"
}
}
private fun mapChargingType(plugged: Int): String =
when (plugged) {
private fun mapChargingType(plugged: Int): String {
return when (plugged) {
BatteryManager.BATTERY_PLUGGED_AC -> "ac"
BatteryManager.BATTERY_PLUGGED_USB -> "usb"
BatteryManager.BATTERY_PLUGGED_WIRELESS -> "wireless"
BatteryManager.BATTERY_PLUGGED_DOCK -> "dock"
else -> "none"
}
}
private fun mapThermalState(powerManager: PowerManager?): String {
val thermal = powerManager?.currentThermalStatus ?: return "nominal"
@@ -396,8 +394,7 @@ class DeviceHandler(
PowerManager.THERMAL_STATUS_SEVERE -> "serious"
PowerManager.THERMAL_STATUS_CRITICAL,
PowerManager.THERMAL_STATUS_EMERGENCY,
PowerManager.THERMAL_STATUS_SHUTDOWN,
-> "critical"
PowerManager.THERMAL_STATUS_SHUTDOWN -> "critical"
else -> "nominal"
}
}
@@ -411,24 +408,19 @@ class DeviceHandler(
}
}
private fun permissionStateJson(
granted: Boolean,
promptableWhenDenied: Boolean,
) = buildJsonObject {
put("status", JsonPrimitive(if (granted) "granted" else "denied"))
put("promptable", JsonPrimitive(!granted && promptableWhenDenied))
private fun permissionStateJson(granted: Boolean, promptableWhenDenied: Boolean) =
buildJsonObject {
put("status", JsonPrimitive(if (granted) "granted" else "denied"))
put("promptable", JsonPrimitive(!granted && promptableWhenDenied))
}
private fun hasPermission(permission: String): Boolean {
return (
ContextCompat.checkSelfPermission(appContext, permission) == PackageManager.PERMISSION_GRANTED
)
}
private fun hasPermission(permission: String): Boolean =
(
ContextCompat.checkSelfPermission(appContext, permission) == PackageManager.PERMISSION_GRANTED
)
private fun mapMemoryPressure(
totalBytes: Long,
availableBytes: Long,
lowMemory: Boolean,
): String {
private fun mapMemoryPressure(totalBytes: Long, availableBytes: Long, lowMemory: Boolean): String {
if (totalBytes <= 0L) return if (lowMemory) "critical" else "unknown"
if (lowMemory) return "critical"
val freeRatio = availableBytes.toDouble() / totalBytes.toDouble()

View File

@@ -1,9 +1,5 @@
package ai.openclaw.app.node
import ai.openclaw.app.NotificationBurstLimiter
import ai.openclaw.app.SecurePrefs
import ai.openclaw.app.allowsPackage
import ai.openclaw.app.isWithinQuietHours
import android.app.Notification
import android.app.NotificationManager
import android.app.RemoteInput
@@ -12,7 +8,10 @@ import android.content.Context
import android.content.Intent
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import androidx.core.content.edit
import ai.openclaw.app.NotificationBurstLimiter
import ai.openclaw.app.SecurePrefs
import ai.openclaw.app.allowsPackage
import ai.openclaw.app.isWithinQuietHours
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
@@ -39,8 +38,8 @@ data class DeviceNotificationEntry(
val isClearable: Boolean,
)
internal fun DeviceNotificationEntry.toJsonObject(): JsonObject =
buildJsonObject {
internal fun DeviceNotificationEntry.toJsonObject(): JsonObject {
return buildJsonObject {
put("key", JsonPrimitive(key))
put("packageName", JsonPrimitive(packageName))
put("postTimeMs", JsonPrimitive(postTimeMs))
@@ -52,6 +51,7 @@ internal fun DeviceNotificationEntry.toJsonObject(): JsonObject =
category?.let { put("category", JsonPrimitive(it)) }
channelId?.let { put("channelId", JsonPrimitive(it)) }
}
}
data class DeviceNotificationSnapshot(
val enabled: Boolean,
@@ -77,7 +77,9 @@ data class NotificationActionResult(
val message: String? = null,
)
internal fun actionRequiresClearableNotification(kind: NotificationActionKind): Boolean = kind == NotificationActionKind.Dismiss
internal fun actionRequiresClearableNotification(kind: NotificationActionKind): Boolean {
return kind == NotificationActionKind.Dismiss
}
private object DeviceNotificationStore {
private val lock = Any()
@@ -191,8 +193,8 @@ class DeviceNotificationListenerService : NotificationListenerService() {
emitNotificationsChanged(payload)
}
private fun notificationChangedPayload(entry: DeviceNotificationEntry): String? =
notificationChangedPayload(
private fun notificationChangedPayload(entry: DeviceNotificationEntry): String? {
return notificationChangedPayload(
entry = entry,
change = "posted",
key = entry.key,
@@ -201,6 +203,7 @@ class DeviceNotificationListenerService : NotificationListenerService() {
isOngoing = entry.isOngoing,
isClearable = entry.isClearable,
)
}
private fun notificationChangedPayload(
entry: DeviceNotificationEntry?,
@@ -281,30 +284,28 @@ class DeviceNotificationListenerService : NotificationListenerService() {
private const val recentPackagesPref = "notifications.forwarding.recentPackages"
private const val legacyRecentPackagesPref = "notifications.recentPackages"
private const val recentPackagesLimit = 64
@Volatile private var activeService: DeviceNotificationListenerService? = null
@Volatile private var nodeEventSink: ((event: String, payloadJson: String?) -> Unit)? = null
private fun serviceComponent(context: Context): ComponentName = ComponentName(context, DeviceNotificationListenerService::class.java)
private fun serviceComponent(context: Context): ComponentName {
return ComponentName(context, DeviceNotificationListenerService::class.java)
}
fun setNodeEventSink(sink: ((event: String, payloadJson: String?) -> Unit)?) {
nodeEventSink = sink
}
private fun recentPackagesPrefs(context: Context) = context.applicationContext.getSharedPreferences("openclaw.secure", Context.MODE_PRIVATE)
private fun recentPackagesPrefs(context: Context) =
context.applicationContext.getSharedPreferences("openclaw.secure", Context.MODE_PRIVATE)
private fun migrateLegacyRecentPackagesIfNeeded(context: Context) {
val prefs = recentPackagesPrefs(context)
val hasNew = prefs.contains(recentPackagesPref)
val legacy = prefs.getString(legacyRecentPackagesPref, null)?.trim().orEmpty()
if (!hasNew && legacy.isNotEmpty()) {
prefs.edit {
putString(recentPackagesPref, legacy)
remove(legacyRecentPackagesPref)
}
prefs.edit().putString(recentPackagesPref, legacy).remove(legacyRecentPackagesPref).apply()
} else if (hasNew && prefs.contains(legacyRecentPackagesPref)) {
prefs.edit { remove(legacyRecentPackagesPref) }
prefs.edit().remove(legacyRecentPackagesPref).apply()
}
}
@@ -324,10 +325,9 @@ class DeviceNotificationListenerService : NotificationListenerService() {
return manager.isNotificationListenerAccessGranted(serviceComponent(context))
}
fun snapshot(
context: Context,
enabled: Boolean = isAccessEnabled(context),
): DeviceNotificationSnapshot = DeviceNotificationStore.snapshot(enabled = enabled)
fun snapshot(context: Context, enabled: Boolean = isAccessEnabled(context)): DeviceNotificationSnapshot {
return DeviceNotificationStore.snapshot(enabled = enabled)
}
fun requestServiceRebind(context: Context) {
runCatching {
@@ -335,10 +335,7 @@ class DeviceNotificationListenerService : NotificationListenerService() {
}
}
fun executeAction(
context: Context,
request: NotificationActionRequest,
): NotificationActionResult {
fun executeAction(context: Context, request: NotificationActionRequest): NotificationActionResult {
if (!isAccessEnabled(context)) {
return NotificationActionResult(
ok = false,
@@ -346,13 +343,12 @@ class DeviceNotificationListenerService : NotificationListenerService() {
message = "NOTIFICATIONS_DISABLED: enable notification access in system Settings",
)
}
val service =
activeService
?: return NotificationActionResult(
ok = false,
code = "NOTIFICATIONS_UNAVAILABLE",
message = "NOTIFICATIONS_UNAVAILABLE: notification listener not connected",
)
val service = activeService
?: return NotificationActionResult(
ok = false,
code = "NOTIFICATIONS_UNAVAILABLE",
message = "NOTIFICATIONS_UNAVAILABLE: notification listener not connected",
)
return service.executeActionInternal(request)
}
@@ -368,16 +364,13 @@ class DeviceNotificationListenerService : NotificationListenerService() {
if (normalized.isEmpty() || normalized == service.packageName) return
migrateLegacyRecentPackagesIfNeeded(service.applicationContext)
val prefs = recentPackagesPrefs(service.applicationContext)
val existing =
prefs
.getString(recentPackagesPref, null)
.orEmpty()
.split(',')
.map { it.trim() }
.filter { it.isNotEmpty() && it != normalized }
.take(recentPackagesLimit - 1)
val existing = prefs.getString(recentPackagesPref, null).orEmpty()
.split(',')
.map { it.trim() }
.filter { it.isNotEmpty() && it != normalized }
.take(recentPackagesLimit - 1)
val updated = listOf(normalized) + existing
prefs.edit { putString(recentPackagesPref, updated.joinToString(",")) }
prefs.edit().putString(recentPackagesPref, updated.joinToString(",")).apply()
}
}
@@ -400,13 +393,12 @@ class DeviceNotificationListenerService : NotificationListenerService() {
return when (request.kind) {
NotificationActionKind.Open -> {
val pendingIntent =
sbn.notification.contentIntent
?: return NotificationActionResult(
ok = false,
code = "ACTION_UNAVAILABLE",
message = "ACTION_UNAVAILABLE: notification has no open action",
)
val pendingIntent = sbn.notification.contentIntent
?: return NotificationActionResult(
ok = false,
code = "ACTION_UNAVAILABLE",
message = "ACTION_UNAVAILABLE: notification has no open action",
)
runCatching {
pendingIntent.send()
}.fold(

View File

@@ -1,11 +1,11 @@
package ai.openclaw.app.node
import ai.openclaw.app.protocol.OpenClawCalendarCommand
import ai.openclaw.app.protocol.OpenClawCallLogCommand
import ai.openclaw.app.protocol.OpenClawCameraCommand
import ai.openclaw.app.protocol.OpenClawCanvasA2UICommand
import ai.openclaw.app.protocol.OpenClawCanvasCommand
import ai.openclaw.app.protocol.OpenClawCameraCommand
import ai.openclaw.app.protocol.OpenClawCapability
import ai.openclaw.app.protocol.OpenClawCallLogCommand
import ai.openclaw.app.protocol.OpenClawContactsCommand
import ai.openclaw.app.protocol.OpenClawDeviceCommand
import ai.openclaw.app.protocol.OpenClawLocationCommand
@@ -221,8 +221,8 @@ object InvokeCommandRegistry {
fun find(command: String): InvokeCommandSpec? = byNameInternal[command]
fun advertisedCapabilities(flags: NodeRuntimeFlags): List<String> =
capabilityManifest
fun advertisedCapabilities(flags: NodeRuntimeFlags): List<String> {
return capabilityManifest
.filter { spec ->
when (spec.availability) {
NodeCapabilityAvailability.Always -> true
@@ -233,10 +233,12 @@ object InvokeCommandRegistry {
NodeCapabilityAvailability.VoiceWakeEnabled -> flags.voiceWakeEnabled
NodeCapabilityAvailability.MotionAvailable -> flags.motionActivityAvailable || flags.motionPedometerAvailable
}
}.map { it.name }
}
.map { it.name }
}
fun advertisedCommands(flags: NodeRuntimeFlags): List<String> =
all
fun advertisedCommands(flags: NodeRuntimeFlags): List<String> {
return all
.filter { spec ->
when (spec.availability) {
InvokeCommandAvailability.Always -> true
@@ -250,5 +252,7 @@ object InvokeCommandRegistry {
InvokeCommandAvailability.MotionPedometerAvailable -> flags.motionPedometerAvailable
InvokeCommandAvailability.DebugBuild -> flags.debugBuild
}
}.map { it.name }
}
.map { it.name }
}
}

View File

@@ -2,10 +2,10 @@ package ai.openclaw.app.node
import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.protocol.OpenClawCalendarCommand
import ai.openclaw.app.protocol.OpenClawCallLogCommand
import ai.openclaw.app.protocol.OpenClawCameraCommand
import ai.openclaw.app.protocol.OpenClawCanvasA2UICommand
import ai.openclaw.app.protocol.OpenClawCanvasCommand
import ai.openclaw.app.protocol.OpenClawCameraCommand
import ai.openclaw.app.protocol.OpenClawCallLogCommand
import ai.openclaw.app.protocol.OpenClawContactsCommand
import ai.openclaw.app.protocol.OpenClawDeviceCommand
import ai.openclaw.app.protocol.OpenClawLocationCommand
@@ -34,8 +34,8 @@ internal fun smsSearchAvailabilityError(
readSmsAvailable: Boolean,
smsFeatureEnabled: Boolean,
smsTelephonyAvailable: Boolean,
): GatewaySession.InvokeResult? =
when (
): GatewaySession.InvokeResult? {
return when (
classifySmsSearchAvailability(
readSmsAvailable = readSmsAvailable,
smsFeatureEnabled = smsFeatureEnabled,
@@ -43,14 +43,14 @@ internal fun smsSearchAvailabilityError(
)
) {
SmsSearchAvailabilityReason.Available,
SmsSearchAvailabilityReason.PermissionRequired,
-> null
SmsSearchAvailabilityReason.PermissionRequired -> null
SmsSearchAvailabilityReason.Unavailable ->
GatewaySession.InvokeResult.error(
code = "SMS_UNAVAILABLE",
message = "SMS_UNAVAILABLE: SMS not available on this device",
)
}
}
class InvokeDispatcher(
private val canvas: CanvasController,
@@ -82,10 +82,7 @@ class InvokeDispatcher(
private val motionActivityAvailable: () -> Boolean,
private val motionPedometerAvailable: () -> Boolean,
) {
suspend fun handleInvoke(
command: String,
paramsJson: String?,
): GatewaySession.InvokeResult {
suspend fun handleInvoke(command: String, paramsJson: String?): GatewaySession.InvokeResult {
val spec =
InvokeCommandRegistry.find(command)
?: return GatewaySession.InvokeResult.error(
@@ -154,7 +151,7 @@ class InvokeDispatcher(
} catch (err: Throwable) {
return GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = err.message ?: "invalid A2UI payload",
message = err.message ?: "invalid A2UI payload"
)
}
withReadyA2ui {
@@ -189,10 +186,9 @@ class InvokeDispatcher(
OpenClawSystemCommand.Notify.rawValue -> systemHandler.handleSystemNotify(paramsJson)
// Photos command
ai.openclaw.app.protocol.OpenClawPhotosCommand.Latest.rawValue ->
photosHandler.handlePhotosLatest(
paramsJson,
)
ai.openclaw.app.protocol.OpenClawPhotosCommand.Latest.rawValue -> photosHandler.handlePhotosLatest(
paramsJson,
)
// Contacts command
OpenClawContactsCommand.Search.rawValue -> contactsHandler.handleContactsSearch(paramsJson)
@@ -220,13 +216,14 @@ class InvokeDispatcher(
}
}
private suspend fun withReadyA2ui(block: suspend () -> GatewaySession.InvokeResult): GatewaySession.InvokeResult {
var a2uiUrl =
a2uiHandler.resolveA2uiHostUrl()
?: return GatewaySession.InvokeResult.error(
code = "A2UI_HOST_NOT_CONFIGURED",
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
)
private suspend fun withReadyA2ui(
block: suspend () -> GatewaySession.InvokeResult,
): GatewaySession.InvokeResult {
var a2uiUrl = a2uiHandler.resolveA2uiHostUrl()
?: return GatewaySession.InvokeResult.error(
code = "A2UI_HOST_NOT_CONFIGURED",
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
)
val readyOnFirstCheck = a2uiHandler.ensureA2uiReady(a2uiUrl)
if (!readyOnFirstCheck) {
if (!refreshNodeCanvasCapability()) {
@@ -250,8 +247,10 @@ class InvokeDispatcher(
return block()
}
private suspend fun withCanvasAvailable(block: suspend () -> GatewaySession.InvokeResult): GatewaySession.InvokeResult =
try {
private suspend fun withCanvasAvailable(
block: suspend () -> GatewaySession.InvokeResult,
): GatewaySession.InvokeResult {
return try {
block()
} catch (_: Throwable) {
GatewaySession.InvokeResult.error(
@@ -259,9 +258,10 @@ class InvokeDispatcher(
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
)
}
}
private fun availabilityError(availability: InvokeCommandAvailability): GatewaySession.InvokeResult? =
when (availability) {
private fun availabilityError(availability: InvokeCommandAvailability): GatewaySession.InvokeResult? {
return when (availability) {
InvokeCommandAvailability.Always -> null
InvokeCommandAvailability.CameraEnabled ->
if (cameraEnabled()) {
@@ -309,8 +309,7 @@ class InvokeDispatcher(
)
}
InvokeCommandAvailability.ReadSmsAvailable,
InvokeCommandAvailability.RequestableSmsSearchAvailable,
->
InvokeCommandAvailability.RequestableSmsSearchAvailable ->
smsSearchAvailabilityError(
readSmsAvailable = readSmsAvailable(),
smsFeatureEnabled = smsFeatureEnabled(),
@@ -335,4 +334,5 @@ class InvokeDispatcher(
)
}
}
}
}

View File

@@ -30,13 +30,7 @@ internal object JpegSizeLimiter {
var width = initialWidth
var height = initialHeight
val clampedStartQuality = startQuality.coerceIn(minQuality, 100)
var best =
JpegSizeLimiterResult(
bytes = encode(width, height, clampedStartQuality),
width = width,
height = height,
quality = clampedStartQuality,
)
var best = JpegSizeLimiterResult(bytes = encode(width, height, clampedStartQuality), width = width, height = height, quality = clampedStartQuality)
if (best.bytes.size <= maxBytes) return best
repeat(maxScaleAttempts) {

View File

@@ -7,19 +7,15 @@ import android.location.Location
import android.location.LocationManager
import android.os.CancellationSignal
import androidx.core.content.ContextCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import java.time.Instant
import java.time.format.DateTimeFormatter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.suspendCancellableCoroutine
class LocationCaptureManager(
private val context: Context,
) {
data class Payload(
val payloadJson: String,
)
class LocationCaptureManager(private val context: Context) {
data class Payload(val payloadJson: String)
suspend fun getLocation(
desiredProviders: List<String>,
@@ -102,16 +98,15 @@ class LocationCaptureManager(
val resolved =
providers.firstOrNull { manager.isProviderEnabled(it) }
?: throw IllegalStateException("LOCATION_UNAVAILABLE: no providers available")
val location =
withTimeout(timeoutMs.coerceAtLeast(1)) {
suspendCancellableCoroutine<Location?> { cont ->
val signal = CancellationSignal()
cont.invokeOnCancellation { signal.cancel() }
manager.getCurrentLocation(resolved, signal, context.mainExecutor) { location ->
cont.resume(location) { _, _, _ -> }
}
val location = withTimeout(timeoutMs.coerceAtLeast(1)) {
suspendCancellableCoroutine<Location?> { cont ->
val signal = CancellationSignal()
cont.invokeOnCancellation { signal.cancel() }
manager.getCurrentLocation(resolved, signal, context.mainExecutor) { location ->
cont.resume(location) { _, _, _ -> }
}
}
}
return location ?: throw IllegalStateException("LOCATION_UNAVAILABLE: no fix")
}
}

View File

@@ -1,11 +1,11 @@
package ai.openclaw.app.node
import ai.openclaw.app.gateway.GatewaySession
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.location.LocationManager
import androidx.core.content.ContextCompat
import ai.openclaw.app.gateway.GatewaySession
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive

View File

@@ -1,6 +1,5 @@
package ai.openclaw.app.node
import ai.openclaw.app.gateway.GatewaySession
import android.Manifest
import android.content.Context
import android.hardware.Sensor
@@ -9,15 +8,17 @@ import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.os.SystemClock
import androidx.core.content.ContextCompat
import ai.openclaw.app.gateway.GatewaySession
import java.time.Instant
import kotlinx.coroutines.InternalCoroutinesApi
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.time.Instant
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.sqrt
@@ -66,15 +67,9 @@ internal interface MotionDataSource {
fun hasPermission(context: Context): Boolean
suspend fun activity(
context: Context,
request: MotionActivityRequest,
): MotionActivityRecord
suspend fun activity(context: Context, request: MotionActivityRequest): MotionActivityRecord
suspend fun pedometer(
context: Context,
request: MotionPedometerRequest,
): PedometerRecord
suspend fun pedometer(context: Context, request: MotionPedometerRequest): PedometerRecord
}
private object SystemMotionDataSource : MotionDataSource {
@@ -88,27 +83,22 @@ private object SystemMotionDataSource : MotionDataSource {
return sensorManager?.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) != null
}
override fun hasPermission(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) ==
override fun hasPermission(context: Context): Boolean {
return ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
}
override suspend fun activity(
context: Context,
request: MotionActivityRequest,
): MotionActivityRecord {
override suspend fun activity(context: Context, request: MotionActivityRequest): MotionActivityRecord {
if (!request.startISO.isNullOrBlank() || !request.endISO.isNullOrBlank()) {
throw IllegalArgumentException("MOTION_RANGE_UNAVAILABLE: historical activity range not supported on Android")
}
val sensorManager =
context.getSystemService(SensorManager::class.java)
?: throw IllegalStateException("MOTION_UNAVAILABLE: sensor manager unavailable")
val accelerometer =
sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
?: throw IllegalStateException("MOTION_UNAVAILABLE: accelerometer not available")
val sensorManager = context.getSystemService(SensorManager::class.java)
?: throw IllegalStateException("MOTION_UNAVAILABLE: sensor manager unavailable")
val accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
?: throw IllegalStateException("MOTION_UNAVAILABLE: accelerometer not available")
val sample =
readAccelerometerSample(sensorManager, accelerometer)
?: throw IllegalStateException("MOTION_UNAVAILABLE: no accelerometer sample")
val sample = readAccelerometerSample(sensorManager, accelerometer)
?: throw IllegalStateException("MOTION_UNAVAILABLE: no accelerometer sample")
val end = Instant.now()
val start = end.minusSeconds(2)
val classification = classifyActivity(sample.averageDelta)
@@ -125,23 +115,17 @@ private object SystemMotionDataSource : MotionDataSource {
)
}
override suspend fun pedometer(
context: Context,
request: MotionPedometerRequest,
): PedometerRecord {
override suspend fun pedometer(context: Context, request: MotionPedometerRequest): PedometerRecord {
if (!request.startISO.isNullOrBlank() || !request.endISO.isNullOrBlank()) {
throw IllegalArgumentException("PEDOMETER_RANGE_UNAVAILABLE: historical pedometer range not supported on Android")
}
val sensorManager =
context.getSystemService(SensorManager::class.java)
?: throw IllegalStateException("PEDOMETER_UNAVAILABLE: sensor manager unavailable")
val stepCounter =
sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER)
?: throw IllegalStateException("PEDOMETER_UNAVAILABLE: step counting not supported")
val sensorManager = context.getSystemService(SensorManager::class.java)
?: throw IllegalStateException("PEDOMETER_UNAVAILABLE: sensor manager unavailable")
val stepCounter = sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER)
?: throw IllegalStateException("PEDOMETER_UNAVAILABLE: step counting not supported")
val steps =
readStepCounter(sensorManager, stepCounter)
?: throw IllegalStateException("PEDOMETER_UNAVAILABLE: no step counter sample")
val steps = readStepCounter(sensorManager, stepCounter)
?: throw IllegalStateException("PEDOMETER_UNAVAILABLE: no step counter sample")
val bootMs = System.currentTimeMillis() - SystemClock.elapsedRealtime()
return PedometerRecord(
startISO = Instant.ofEpochMilli(max(0L, bootMs)).toString(),
@@ -159,10 +143,7 @@ private object SystemMotionDataSource : MotionDataSource {
)
@OptIn(InternalCoroutinesApi::class)
private suspend fun readStepCounter(
sensorManager: SensorManager,
sensor: Sensor,
): Int? {
private suspend fun readStepCounter(sensorManager: SensorManager, sensor: Sensor): Int? {
val sample =
withTimeoutOrNull(1200L) {
suspendCancellableCoroutine<Float?> { cont ->
@@ -175,10 +156,7 @@ private object SystemMotionDataSource : MotionDataSource {
sensorManager.unregisterListener(this)
}
override fun onAccuracyChanged(
sensor: Sensor?,
accuracy: Int,
) = Unit
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
}
val registered = sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL)
if (!registered) {
@@ -216,21 +194,17 @@ private object SystemMotionDataSource : MotionDataSource {
sumDelta += abs(magnitude - SensorManager.GRAVITY_EARTH.toDouble())
count += 1
if (count >= ACCELEROMETER_SAMPLE_TARGET) {
val result =
AccelerometerSample(
samples = count,
averageDelta = sumDelta / count,
)
val result = AccelerometerSample(
samples = count,
averageDelta = sumDelta / count,
)
val token = cont.tryResume(result) ?: return
cont.completeResume(token)
sensorManager.unregisterListener(this)
}
}
override fun onAccuracyChanged(
sensor: Sensor?,
accuracy: Int,
) = Unit
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
}
val registered = sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL)
if (!registered) {
@@ -243,17 +217,15 @@ private object SystemMotionDataSource : MotionDataSource {
return sample
}
private fun classifyActivity(averageDelta: Double): String =
when {
private fun classifyActivity(averageDelta: Double): String {
return when {
averageDelta <= 0.55 -> "stationary"
averageDelta <= 1.80 -> "walking"
else -> "running"
}
}
private fun classifyConfidence(
samples: Int,
averageDelta: Double,
): String {
private fun classifyConfidence(samples: Int, averageDelta: Double): String {
if (samples < 6) return "low"
if (samples >= 14 && averageDelta > 0.4) return "high"
return "medium"

View File

@@ -1,98 +0,0 @@
package ai.openclaw.app.node
import android.os.Build
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
internal object NodePresenceAliveBeacon {
const val EVENT_NAME: String = "node.presence.alive"
const val MIN_SUCCESS_INTERVAL_MS: Long = 10 * 60 * 1000
private const val MAX_RESPONSE_JSON_CHARS: Int = 16 * 1024
enum class Trigger(
val rawValue: String,
) {
Background("background"),
SilentPush("silent_push"),
BackgroundAppRefresh("bg_app_refresh"),
SignificantLocation("significant_location"),
Manual("manual"),
Connect("connect"),
}
data class ResponsePayload(
val ok: Boolean?,
val event: String?,
val handled: Boolean?,
val reason: String?,
)
private val json = Json { ignoreUnknownKeys = true }
fun shouldSkipRecentSuccess(
nowMs: Long,
lastSuccessAtMs: Long?,
minIntervalMs: Long = MIN_SUCCESS_INTERVAL_MS,
): Boolean {
val last = lastSuccessAtMs ?: return false
if (last <= 0) return false
val elapsed = nowMs - last
return elapsed >= 0 && elapsed < minIntervalMs
}
fun androidPlatformLabel(): String {
val release =
Build.VERSION.RELEASE
?.trim()
.orEmpty()
.ifEmpty { "unknown" }
return "Android $release (SDK ${Build.VERSION.SDK_INT})"
}
fun makePayloadJson(
trigger: Trigger,
sentAtMs: Long,
displayName: String,
version: String,
platform: String,
deviceFamily: String?,
modelIdentifier: String?,
pushTransport: String? = null,
): String =
buildJsonObject {
put("trigger", JsonPrimitive(trigger.rawValue))
put("sentAtMs", JsonPrimitive(sentAtMs))
put("displayName", JsonPrimitive(displayName))
put("version", JsonPrimitive(version))
put("platform", JsonPrimitive(platform))
deviceFamily?.trim()?.takeIf { it.isNotEmpty() }?.let { put("deviceFamily", JsonPrimitive(it)) }
modelIdentifier?.trim()?.takeIf { it.isNotEmpty() }?.let { put("modelIdentifier", JsonPrimitive(it)) }
pushTransport?.trim()?.takeIf { it.isNotEmpty() }?.let { put("pushTransport", JsonPrimitive(it)) }
}.toString()
fun decodeResponse(payloadJson: String?): ResponsePayload? {
val raw = payloadJson?.trim()?.takeIf { it.isNotEmpty() } ?: return null
if (raw.length > MAX_RESPONSE_JSON_CHARS) return null
val obj =
try {
json.parseToJsonElement(raw).asObjectOrNull()
} catch (_: Throwable) {
null
} ?: return null
return ResponsePayload(
ok = parseJsonBooleanFlag(obj, "ok"),
event = parseJsonString(obj, "event"),
handled = parseJsonBooleanFlag(obj, "handled"),
reason = parseJsonString(obj, "reason"),
)
}
fun sanitizeReasonForLog(raw: String?): String {
val value = raw?.trim()?.takeIf { it.isNotEmpty() } ?: "unsupported"
return value
.map { ch -> if (ch.isISOControl()) ' ' else ch }
.joinToString("")
.take(200)
}
}

View File

@@ -10,17 +10,11 @@ import kotlinx.serialization.json.contentOrNull
const val DEFAULT_SEAM_COLOR_ARGB: Long = 0xFF4F7A9A
data class Quad<A, B, C, D>(
val first: A,
val second: B,
val third: C,
val fourth: D,
)
data class Quad<A, B, C, D>(val first: A, val second: B, val third: C, val fourth: D)
fun String.toJsonString(): String {
val escaped =
this
.replace("\\", "\\\\")
this.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
@@ -38,30 +32,18 @@ fun parseJsonParamsObject(paramsJson: String?): JsonObject? {
}
}
fun readJsonPrimitive(
params: JsonObject?,
key: String,
): JsonPrimitive? = params?.get(key) as? JsonPrimitive
fun readJsonPrimitive(params: JsonObject?, key: String): JsonPrimitive? = params?.get(key) as? JsonPrimitive
fun parseJsonInt(
params: JsonObject?,
key: String,
): Int? = readJsonPrimitive(params, key)?.contentOrNull?.toIntOrNull()
fun parseJsonInt(params: JsonObject?, key: String): Int? =
readJsonPrimitive(params, key)?.contentOrNull?.toIntOrNull()
fun parseJsonDouble(
params: JsonObject?,
key: String,
): Double? = readJsonPrimitive(params, key)?.contentOrNull?.toDoubleOrNull()
fun parseJsonDouble(params: JsonObject?, key: String): Double? =
readJsonPrimitive(params, key)?.contentOrNull?.toDoubleOrNull()
fun parseJsonString(
params: JsonObject?,
key: String,
): String? = readJsonPrimitive(params, key)?.contentOrNull
fun parseJsonString(params: JsonObject?, key: String): String? =
readJsonPrimitive(params, key)?.contentOrNull
fun parseJsonBooleanFlag(
params: JsonObject?,
key: String,
): Boolean? {
fun parseJsonBooleanFlag(params: JsonObject?, key: String): Boolean? {
val value = readJsonPrimitive(params, key)?.contentOrNull?.trim()?.lowercase() ?: return null
return when (value) {
"true" -> true
@@ -97,4 +79,6 @@ fun normalizeMainKey(raw: String?): String? {
return if (trimmed.isEmpty()) null else trimmed
}
fun isCanonicalMainSessionKey(key: String): Boolean = key == "main"
fun isCanonicalMainSessionKey(key: String): Boolean {
return key == "main"
}

View File

@@ -1,7 +1,7 @@
package ai.openclaw.app.node
import ai.openclaw.app.gateway.GatewaySession
import android.content.Context
import ai.openclaw.app.gateway.GatewaySession
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
@@ -15,10 +15,7 @@ internal interface NotificationsStateProvider {
fun requestServiceRebind(context: Context)
fun executeAction(
context: Context,
request: NotificationActionRequest,
): NotificationActionResult
fun executeAction(context: Context, request: NotificationActionRequest): NotificationActionResult
}
private object SystemNotificationsStateProvider : NotificationsStateProvider {
@@ -38,10 +35,9 @@ private object SystemNotificationsStateProvider : NotificationsStateProvider {
DeviceNotificationListenerService.requestServiceRebind(context)
}
override fun executeAction(
context: Context,
request: NotificationActionRequest,
): NotificationActionResult = DeviceNotificationListenerService.executeAction(context, request)
override fun executeAction(context: Context, request: NotificationActionRequest): NotificationActionResult {
return DeviceNotificationListenerService.executeAction(context, request)
}
}
class NotificationsHandler private constructor(
@@ -58,12 +54,11 @@ class NotificationsHandler private constructor(
suspend fun handleNotificationsActions(paramsJson: String?): GatewaySession.InvokeResult {
readSnapshotWithRebind()
val params =
parseParamsObject(paramsJson)
?: return GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: expected JSON object",
)
val params = parseParamsObject(paramsJson)
?: return GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: expected JSON object",
)
val key =
readString(params, "key")
?: return GatewaySession.InvokeResult.error(
@@ -128,8 +123,8 @@ class NotificationsHandler private constructor(
return snapshot
}
private fun snapshotPayloadJson(snapshot: DeviceNotificationSnapshot): String =
buildJsonObject {
private fun snapshotPayloadJson(snapshot: DeviceNotificationSnapshot): String {
return buildJsonObject {
put("enabled", JsonPrimitive(snapshot.enabled))
put("connected", JsonPrimitive(snapshot.connected))
put("count", JsonPrimitive(snapshot.notifications.size))
@@ -140,6 +135,7 @@ class NotificationsHandler private constructor(
),
)
}.toString()
}
private fun parseParamsObject(paramsJson: String?): JsonObject? {
if (paramsJson.isNullOrBlank()) return null
@@ -150,10 +146,7 @@ class NotificationsHandler private constructor(
}
}
private fun readString(
params: JsonObject,
key: String,
): String? =
private fun readString(params: JsonObject, key: String): String? =
(params[key] as? JsonPrimitive)
?.contentOrNull
?.trim()

View File

@@ -1,6 +1,5 @@
package ai.openclaw.app.node
import ai.openclaw.app.gateway.GatewaySession
import android.Manifest
import android.content.ContentResolver
import android.content.ContentUris
@@ -13,15 +12,17 @@ import android.os.Bundle
import android.provider.MediaStore
import androidx.core.content.ContextCompat
import androidx.core.graphics.scale
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import ai.openclaw.app.gateway.GatewaySession
import java.io.ByteArrayOutputStream
import java.time.Instant
import kotlin.math.max
import kotlin.math.roundToInt
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
private const val DEFAULT_PHOTOS_LIMIT = 1
private const val DEFAULT_PHOTOS_MAX_WIDTH = 1600
@@ -46,10 +47,7 @@ internal data class EncodedPhotoPayload(
internal interface PhotosDataSource {
fun hasPermission(context: Context): Boolean
fun latest(
context: Context,
request: PhotosLatestRequest,
): List<EncodedPhotoPayload>
fun latest(context: Context, request: PhotosLatestRequest): List<EncodedPhotoPayload>
}
private object SystemPhotosDataSource : PhotosDataSource {
@@ -63,10 +61,7 @@ private object SystemPhotosDataSource : PhotosDataSource {
return ContextCompat.checkSelfPermission(context, permission) == android.content.pm.PackageManager.PERMISSION_GRANTED
}
override fun latest(
context: Context,
request: PhotosLatestRequest,
): List<EncodedPhotoPayload> {
override fun latest(context: Context, request: PhotosLatestRequest): List<EncodedPhotoPayload> {
val resolver = context.contentResolver
val rows = queryLatestRows(resolver, request.limit)
if (rows.isEmpty()) return emptyList()
@@ -107,10 +102,7 @@ private object SystemPhotosDataSource : PhotosDataSource {
val height: Int,
)
private fun queryLatestRows(
resolver: ContentResolver,
limit: Int,
): List<PhotoRow> {
private fun queryLatestRows(resolver: ContentResolver, limit: Int): List<PhotoRow> {
val projection =
arrayOf(
MediaStore.Images.Media._ID,
@@ -125,30 +117,29 @@ private object SystemPhotosDataSource : PhotosDataSource {
putInt(ContentResolver.QUERY_ARG_LIMIT, limit)
}
resolver
.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
projection,
args,
null,
).use { cursor ->
if (cursor == null) return emptyList()
val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
val takenIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_TAKEN)
val addedIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_ADDED)
val rows = mutableListOf<PhotoRow>()
while (cursor.moveToNext()) {
val id = cursor.getLong(idIndex)
val takenMs = cursor.getLong(takenIndex).takeIf { it > 0L }
val addedMs = cursor.getLong(addedIndex).takeIf { it > 0L }?.times(1000L)
rows +=
PhotoRow(
uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id),
createdAtMs = takenMs ?: addedMs,
)
}
return rows
resolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
projection,
args,
null,
).use { cursor ->
if (cursor == null) return emptyList()
val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
val takenIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_TAKEN)
val addedIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_ADDED)
val rows = mutableListOf<PhotoRow>()
while (cursor.moveToNext()) {
val id = cursor.getLong(idIndex)
val takenMs = cursor.getLong(takenIndex).takeIf { it > 0L }
val addedMs = cursor.getLong(addedIndex).takeIf { it > 0L }?.times(1000L)
rows +=
PhotoRow(
uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id),
createdAtMs = takenMs ?: addedMs,
)
}
return rows
}
}
private fun decodeScaledBitmap(
@@ -180,10 +171,7 @@ private object SystemPhotosDataSource : PhotosDataSource {
}
}
private fun computeInSampleSize(
width: Int,
maxWidth: Int,
): Int {
private fun computeInSampleSize(width: Int, maxWidth: Int): Int {
var sample = 1
var candidate = width
while (candidate > maxWidth && sample < 64) {

View File

@@ -21,10 +21,7 @@ class SmsHandler(
return errorResult(res.error, defaultCode = "SMS_SEARCH_FAILED")
}
private fun errorResult(
error: String?,
defaultCode: String,
): GatewaySession.InvokeResult {
private fun errorResult(error: String?, defaultCode: String): GatewaySession.InvokeResult {
val rawMessage = error ?: defaultCode
val idx = rawMessage.indexOf(':')
val code = if (idx > 0) rawMessage.substring(0, idx).trim() else defaultCode

View File

@@ -1,6 +1,5 @@
package ai.openclaw.app.node
import ai.openclaw.app.gateway.GatewaySession
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
@@ -10,6 +9,7 @@ import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import ai.openclaw.app.gateway.GatewaySession
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
@@ -47,8 +47,7 @@ private class AndroidSystemNotificationPoster(
val channelId = ensureChannel(request.priority)
val silent = isSilentSound(request.sound)
val notification =
NotificationCompat
.Builder(appContext, channelId)
NotificationCompat.Builder(appContext, channelId)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(request.title)
.setContentText(request.body)
@@ -84,12 +83,13 @@ private class AndroidSystemNotificationPoster(
return channelId
}
private fun compatPriority(priority: String?): Int =
when (priority.orEmpty().trim().lowercase()) {
private fun compatPriority(priority: String?): Int {
return when (priority.orEmpty().trim().lowercase()) {
"passive" -> NotificationCompat.PRIORITY_LOW
"timesensitive" -> NotificationCompat.PRIORITY_HIGH
else -> NotificationCompat.PRIORITY_DEFAULT
}
}
private fun isSilentSound(sound: String?): Boolean {
val normalized = sound?.trim()?.lowercase() ?: return false

View File

@@ -57,16 +57,13 @@ object OpenClawCanvasA2UIAction {
).joinToString(separator = " ")
}
fun jsDispatchA2UIActionStatus(
actionId: String,
ok: Boolean,
error: String?,
): String {
fun jsDispatchA2UIActionStatus(actionId: String, ok: Boolean, error: String?): String {
val err = jsonStringLiteral(error ?: "")
val okLiteral = if (ok) "true" else "false"
val idLiteral = jsonStringLiteral(actionId)
return "window.dispatchEvent(new CustomEvent('openclaw:a2ui-action-status', { detail: { id: $idLiteral, ok: $okLiteral, error: $err } }));"
return "window.dispatchEvent(new CustomEvent('openclaw:a2ui-action-status', { detail: { id: ${idLiteral}, ok: ${okLiteral}, error: ${err} } }));"
}
private fun jsonStringLiteral(raw: String): String = JsonPrimitive(raw).toString().replace("\u2028", "\\u2028").replace("\u2029", "\\u2029")
private fun jsonStringLiteral(raw: String): String =
JsonPrimitive(raw).toString().replace("\u2028", "\\u2028").replace("\u2029", "\\u2029")
}

View File

@@ -1,8 +1,6 @@
package ai.openclaw.app.protocol
enum class OpenClawCapability(
val rawValue: String,
) {
enum class OpenClawCapability(val rawValue: String) {
Canvas("canvas"),
Camera("camera"),
Sms("sms"),
@@ -18,9 +16,7 @@ enum class OpenClawCapability(
CallLog("callLog"),
}
enum class OpenClawCanvasCommand(
val rawValue: String,
) {
enum class OpenClawCanvasCommand(val rawValue: String) {
Present("canvas.present"),
Hide("canvas.hide"),
Navigate("canvas.navigate"),
@@ -33,9 +29,7 @@ enum class OpenClawCanvasCommand(
}
}
enum class OpenClawCanvasA2UICommand(
val rawValue: String,
) {
enum class OpenClawCanvasA2UICommand(val rawValue: String) {
Push("canvas.a2ui.push"),
PushJSONL("canvas.a2ui.pushJSONL"),
Reset("canvas.a2ui.reset"),
@@ -46,9 +40,7 @@ enum class OpenClawCanvasA2UICommand(
}
}
enum class OpenClawCameraCommand(
val rawValue: String,
) {
enum class OpenClawCameraCommand(val rawValue: String) {
List("camera.list"),
Snap("camera.snap"),
Clip("camera.clip"),
@@ -59,9 +51,7 @@ enum class OpenClawCameraCommand(
}
}
enum class OpenClawSmsCommand(
val rawValue: String,
) {
enum class OpenClawSmsCommand(val rawValue: String) {
Send("sms.send"),
Search("sms.search"),
;
@@ -71,9 +61,7 @@ enum class OpenClawSmsCommand(
}
}
enum class OpenClawLocationCommand(
val rawValue: String,
) {
enum class OpenClawLocationCommand(val rawValue: String) {
Get("location.get"),
;
@@ -82,9 +70,7 @@ enum class OpenClawLocationCommand(
}
}
enum class OpenClawDeviceCommand(
val rawValue: String,
) {
enum class OpenClawDeviceCommand(val rawValue: String) {
Status("device.status"),
Info("device.info"),
Permissions("device.permissions"),
@@ -96,9 +82,7 @@ enum class OpenClawDeviceCommand(
}
}
enum class OpenClawNotificationsCommand(
val rawValue: String,
) {
enum class OpenClawNotificationsCommand(val rawValue: String) {
List("notifications.list"),
Actions("notifications.actions"),
;
@@ -108,9 +92,7 @@ enum class OpenClawNotificationsCommand(
}
}
enum class OpenClawSystemCommand(
val rawValue: String,
) {
enum class OpenClawSystemCommand(val rawValue: String) {
Notify("system.notify"),
;
@@ -119,9 +101,7 @@ enum class OpenClawSystemCommand(
}
}
enum class OpenClawPhotosCommand(
val rawValue: String,
) {
enum class OpenClawPhotosCommand(val rawValue: String) {
Latest("photos.latest"),
;
@@ -130,9 +110,7 @@ enum class OpenClawPhotosCommand(
}
}
enum class OpenClawContactsCommand(
val rawValue: String,
) {
enum class OpenClawContactsCommand(val rawValue: String) {
Search("contacts.search"),
Add("contacts.add"),
;
@@ -142,9 +120,7 @@ enum class OpenClawContactsCommand(
}
}
enum class OpenClawCalendarCommand(
val rawValue: String,
) {
enum class OpenClawCalendarCommand(val rawValue: String) {
Events("calendar.events"),
Add("calendar.add"),
;
@@ -154,9 +130,7 @@ enum class OpenClawCalendarCommand(
}
}
enum class OpenClawMotionCommand(
val rawValue: String,
) {
enum class OpenClawMotionCommand(val rawValue: String) {
Activity("motion.activity"),
Pedometer("motion.pedometer"),
;
@@ -166,9 +140,7 @@ enum class OpenClawMotionCommand(
}
}
enum class OpenClawCallLogCommand(
val rawValue: String,
) {
enum class OpenClawCallLogCommand(val rawValue: String) {
Search("callLog.search"),
;

View File

@@ -48,14 +48,13 @@ data class ToolDisplaySummary(
}
val summaryLine: String
get() = if (detailLine != null) "$emoji $label: $detailLine" else "$emoji $label"
get() = if (detailLine != null) "${emoji} ${label}: ${detailLine}" else "${emoji} ${label}"
}
object ToolDisplayRegistry {
private const val CONFIG_ASSET = "tool-display.json"
private val json = Json { ignoreUnknownKeys = true }
@Volatile private var cachedConfig: ToolDisplayConfig? = null
fun resolve(
@@ -113,11 +112,7 @@ object ToolDisplayRegistry {
val existing = cachedConfig
if (existing != null) return existing
return try {
val jsonString =
context.assets
.open(CONFIG_ASSET)
.bufferedReader()
.use { it.readText() }
val jsonString = context.assets.open(CONFIG_ASSET).bufferedReader().use { it.readText() }
val decoded = json.decodeFromString(ToolDisplayConfig.serializer(), jsonString)
cachedConfig = decoded
decoded
@@ -135,11 +130,8 @@ object ToolDisplayRegistry {
.split(Regex("\\s+"))
.joinToString(" ") { part ->
val upper = part.uppercase()
if (part.length <= 2 && part == upper) {
part
} else {
upper.firstOrNull()?.toString().orEmpty() + part.lowercase().drop(1)
}
if (part.length <= 2 && part == upper) part
else upper.firstOrNull()?.toString().orEmpty() + part.lowercase().drop(1)
}
}
@@ -155,18 +147,17 @@ object ToolDisplayRegistry {
val limit = args["limit"].asNumberOrNull()
return if (offset != null && limit != null) {
val end = offset + limit
"$path:${offset.toInt()}-${end.toInt()}"
"${path}:${offset.toInt()}-${end.toInt()}"
} else {
path
}
}
private fun pathDetail(args: JsonObject?): String? = args?.get("path")?.asStringOrNull()
private fun pathDetail(args: JsonObject?): String? {
return args?.get("path")?.asStringOrNull()
}
private fun firstValue(
args: JsonObject?,
keys: List<String>,
): String? {
private fun firstValue(args: JsonObject?, keys: List<String>): String? {
for (key in keys) {
val value = valueForPath(args, key)
val rendered = renderValue(value)
@@ -175,10 +166,7 @@ object ToolDisplayRegistry {
return null
}
private fun valueForPath(
args: JsonObject?,
path: String,
): JsonElement? {
private fun valueForPath(args: JsonObject?, path: String): JsonElement? {
var current: JsonElement? = args
for (segment in path.split(".")) {
if (segment.isBlank()) return null
@@ -194,12 +182,7 @@ object ToolDisplayRegistry {
if (value.isString) {
val trimmed = value.contentOrNull?.trim().orEmpty()
if (trimmed.isEmpty()) return null
val firstLine =
trimmed
.lineSequence()
.firstOrNull()
?.trim()
.orEmpty()
val firstLine = trimmed.lineSequence().firstOrNull()?.trim().orEmpty()
if (firstLine.isEmpty()) return null
return if (firstLine.length > 160) "${firstLine.take(157)}" else firstLine
}
@@ -212,18 +195,16 @@ object ToolDisplayRegistry {
val items = value.mapNotNull { renderValue(it) }
if (items.isEmpty()) return null
val preview = items.take(3).joinToString(", ")
return if (items.size > 3) "$preview" else preview
return if (items.size > 3) "${preview}" else preview
}
return null
}
private fun shortenHomeInString(value: String): String {
val home =
System.getProperty("user.home")?.takeIf { it.isNotBlank() }
?: System.getenv("HOME")?.takeIf { it.isNotBlank() }
val home = System.getProperty("user.home")?.takeIf { it.isNotBlank() }
?: System.getenv("HOME")?.takeIf { it.isNotBlank() }
if (home.isNullOrEmpty()) return value
return value
.replace(home, "~")
return value.replace(home, "~")
.replace(Regex("/Users/[^/]+"), "~")
.replace(Regex("/home/[^/]+"), "~")
}

View File

@@ -1,11 +1,10 @@
package ai.openclaw.app.ui
import ai.openclaw.app.MainViewModel
import android.annotation.SuppressLint
import android.net.Uri
import android.util.Log
import android.view.View
import android.webkit.ConsoleMessage
import android.webkit.JavascriptInterface
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
@@ -15,161 +14,135 @@ import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.webkit.JavaScriptReplyProxy
import androidx.webkit.WebMessageCompat
import androidx.webkit.WebSettingsCompat
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import ai.openclaw.app.MainViewModel
import java.util.concurrent.atomic.AtomicReference
@SuppressLint("SetJavaScriptEnabled")
@Suppress("DEPRECATION")
@Composable
fun CanvasScreen(
viewModel: MainViewModel,
visible: Boolean,
modifier: Modifier = Modifier,
) {
fun CanvasScreen(viewModel: MainViewModel, visible: Boolean, modifier: Modifier = Modifier) {
val context = LocalContext.current
val isDebuggable = (context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0
val webViewRef = remember { arrayOfNulls<WebView>(1) }
val webViewRef = remember { mutableStateOf<WebView?>(null) }
val currentPageUrlRef = remember { AtomicReference<String?>(null) }
DisposableEffect(viewModel) {
onDispose {
val webView = webViewRef[0] ?: return@onDispose
val webView = webViewRef.value ?: return@onDispose
viewModel.canvas.detach(webView)
if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
WebViewCompat.removeWebMessageListener(webView, CanvasA2UIActionBridge.interfaceName)
}
webView.removeJavascriptInterface(CanvasA2UIActionBridge.interfaceName)
webView.stopLoading()
webView.destroy()
webViewRef[0] = null
webViewRef.value = null
}
}
AndroidView(
modifier = modifier,
factory = {
val webView = WebView(context)
val webSettings = webView.settings
webSettings.setAllowContentAccess(false)
webSettings.setAllowFileAccess(false)
webSettings.setAllowFileAccessFromFileURLs(false)
webSettings.setAllowUniversalAccessFromFileURLs(false)
webSettings.setSafeBrowsingEnabled(true)
webSettings.javaScriptEnabled = true
webSettings.domStorageEnabled = true
webSettings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
webSettings.useWideViewPort = false
webSettings.loadWithOverviewMode = false
webSettings.builtInZoomControls = false
webSettings.displayZoomControls = false
webSettings.setSupportZoom(false)
webView.visibility = if (visible) View.VISIBLE else View.INVISIBLE
// targetSdk 33+ ignores Force Dark APIs, so only opt out through the supported
// algorithmic darkening flag when this WebView implementation exposes it.
if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) {
WebSettingsCompat.setAlgorithmicDarkeningAllowed(webSettings, false)
}
if (isDebuggable) {
Log.d("OpenClawWebView", "userAgent: ${webSettings.userAgentString}")
}
webView.isScrollContainer = true
webView.overScrollMode = View.OVER_SCROLL_IF_CONTENT_SCROLLS
webView.isVerticalScrollBarEnabled = true
webView.isHorizontalScrollBarEnabled = true
webView.webViewClient =
object : WebViewClient() {
override fun onPageStarted(
view: WebView,
url: String?,
favicon: android.graphics.Bitmap?,
) {
currentPageUrlRef.set(url)
}
override fun onReceivedError(
view: WebView,
request: WebResourceRequest,
error: WebResourceError,
) {
if (!isDebuggable || !request.isForMainFrame) return
Log.e("OpenClawWebView", "onReceivedError: ${error.errorCode} ${error.description} ${request.url}")
}
override fun onReceivedHttpError(
view: WebView,
request: WebResourceRequest,
errorResponse: WebResourceResponse,
) {
if (!isDebuggable || !request.isForMainFrame) return
Log.e(
"OpenClawWebView",
"onReceivedHttpError: ${errorResponse.statusCode} ${errorResponse.reasonPhrase} ${request.url}",
)
}
override fun onPageFinished(
view: WebView,
url: String?,
) {
currentPageUrlRef.set(url)
if (isDebuggable) {
Log.d("OpenClawWebView", "onPageFinished: $url")
WebView(context).apply {
visibility = if (visible) View.VISIBLE else View.INVISIBLE
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
settings.useWideViewPort = false
settings.loadWithOverviewMode = false
settings.builtInZoomControls = false
settings.displayZoomControls = false
settings.setSupportZoom(false)
// targetSdk 33+ ignores Force Dark APIs, so only opt out through the supported
// algorithmic darkening flag when this WebView implementation exposes it.
if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) {
WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, false)
}
if (isDebuggable) {
Log.d("OpenClawWebView", "userAgent: ${settings.userAgentString}")
}
isScrollContainer = true
overScrollMode = View.OVER_SCROLL_IF_CONTENT_SCROLLS
isVerticalScrollBarEnabled = true
isHorizontalScrollBarEnabled = true
webViewClient =
object : WebViewClient() {
override fun onPageStarted(
view: WebView,
url: String?,
favicon: android.graphics.Bitmap?,
) {
currentPageUrlRef.set(url)
}
viewModel.canvas.onPageFinished()
}
override fun onRenderProcessGone(
view: WebView,
detail: android.webkit.RenderProcessGoneDetail,
): Boolean {
if (isDebuggable) {
override fun onReceivedError(
view: WebView,
request: WebResourceRequest,
error: WebResourceError,
) {
if (!isDebuggable || !request.isForMainFrame) return
Log.e("OpenClawWebView", "onReceivedError: ${error.errorCode} ${error.description} ${request.url}")
}
override fun onReceivedHttpError(
view: WebView,
request: WebResourceRequest,
errorResponse: WebResourceResponse,
) {
if (!isDebuggable || !request.isForMainFrame) return
Log.e(
"OpenClawWebView",
"onRenderProcessGone didCrash=${detail.didCrash()} priorityAtExit=${detail.rendererPriorityAtExit()}",
"onReceivedHttpError: ${errorResponse.statusCode} ${errorResponse.reasonPhrase} ${request.url}",
)
}
return true
}
}
webView.webChromeClient =
object : WebChromeClient() {
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
if (!isDebuggable) return false
val msg = consoleMessage ?: return false
Log.d(
"OpenClawWebView",
"console ${msg.messageLevel()} @ ${msg.sourceId()}:${msg.lineNumber()} ${msg.message()}",
)
return false
}
}
val bridge =
CanvasA2UIActionBridge(
isTrustedPage = { viewModel.isTrustedCanvasActionUrl(currentPageUrlRef.get()) },
) { payload ->
viewModel.handleCanvasA2UIActionFromWebView(payload)
}
if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
WebViewCompat.addWebMessageListener(
webView,
CanvasA2UIActionBridge.interfaceName,
CanvasA2UIActionBridge.allowedOriginRules,
bridge,
)
} else if (isDebuggable) {
Log.w("OpenClawWebView", "WebMessageListener unsupported; canvas actions disabled")
override fun onPageFinished(view: WebView, url: String?) {
currentPageUrlRef.set(url)
if (isDebuggable) {
Log.d("OpenClawWebView", "onPageFinished: $url")
}
viewModel.canvas.onPageFinished()
}
override fun onRenderProcessGone(
view: WebView,
detail: android.webkit.RenderProcessGoneDetail,
): Boolean {
if (isDebuggable) {
Log.e(
"OpenClawWebView",
"onRenderProcessGone didCrash=${detail.didCrash()} priorityAtExit=${detail.rendererPriorityAtExit()}",
)
}
return true
}
}
webChromeClient =
object : WebChromeClient() {
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
if (!isDebuggable) return false
val msg = consoleMessage ?: return false
Log.d(
"OpenClawWebView",
"console ${msg.messageLevel()} @ ${msg.sourceId()}:${msg.lineNumber()} ${msg.message()}",
)
return false
}
}
val bridge =
CanvasA2UIActionBridge(
isTrustedPage = { viewModel.isTrustedCanvasActionUrl(currentPageUrlRef.get()) },
) { payload ->
viewModel.handleCanvasA2UIActionFromWebView(payload)
}
addJavascriptInterface(bridge, CanvasA2UIActionBridge.interfaceName)
viewModel.canvas.attach(this)
webViewRef.value = this
}
viewModel.canvas.attach(webView)
webViewRef[0] = webView
webView
},
update = { webView ->
webView.visibility = if (visible) View.VISIBLE else View.INVISIBLE
@@ -187,18 +160,8 @@ fun CanvasScreen(
internal class CanvasA2UIActionBridge(
private val isTrustedPage: () -> Boolean,
private val onMessage: (String) -> Unit,
) : WebViewCompat.WebMessageListener {
override fun onPostMessage(
view: WebView,
message: WebMessageCompat,
sourceOrigin: Uri,
isMainFrame: Boolean,
replyProxy: JavaScriptReplyProxy,
) {
if (!isMainFrame) return
postMessage(message.data)
}
) {
@JavascriptInterface
fun postMessage(payload: String?) {
val msg = payload?.trim().orEmpty()
if (msg.isEmpty()) return
@@ -208,6 +171,5 @@ internal class CanvasA2UIActionBridge(
companion object {
const val interfaceName: String = "openclawCanvasA2UIAction"
val allowedOriginRules: Set<String> = setOf("*")
}
}

View File

@@ -1,8 +1,8 @@
package ai.openclaw.app.ui
import androidx.compose.runtime.Composable
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.ui.chat.ChatSheetContent
import androidx.compose.runtime.Composable
@Composable
fun ChatSheet(viewModel: MainViewModel) {

View File

@@ -1,10 +1,7 @@
package ai.openclaw.app.ui
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.ui.mobileCardSurface
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.BorderStroke
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -41,6 +38,7 @@ import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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
@@ -50,11 +48,14 @@ 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.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.ui.mobileCardSurface
private enum class ConnectInputMode {
SetupCode,
@@ -126,10 +127,9 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
}
val setupResolvedEndpoint = remember(setupCode) { decodeGatewaySetupCode(setupCode)?.url?.let { parseGatewayEndpoint(it)?.displayUrl } }
val manualResolvedEndpoint =
remember(manualHostInput, manualPortInput, manualTlsInput) {
composeGatewayManualUrl(manualHostInput, manualPortInput, manualTlsInput)?.let { parseGatewayEndpoint(it)?.displayUrl }
}
val manualResolvedEndpoint = remember(manualHostInput, manualPortInput, manualTlsInput) {
composeGatewayManualUrl(manualHostInput, manualPortInput, manualTlsInput)?.let { parseGatewayEndpoint(it)?.displayUrl }
}
val activeEndpoint =
remember(isConnected, remoteAddress, setupResolvedEndpoint, manualResolvedEndpoint, inputMode) {
@@ -544,11 +544,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
colors = outlinedColors(),
)
Text(
"Password (optional)",
style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold),
color = mobileTextSecondary,
)
Text("Password (optional)", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
OutlinedTextField(
value = passwordInput,
onValueChange = { passwordInput = it },
@@ -582,11 +578,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
}
@Composable
private fun MethodChip(
label: String,
active: Boolean,
onClick: () -> Unit,
) {
private fun MethodChip(label: String, active: Boolean, onClick: () -> Unit) {
Button(
onClick = onClick,
modifier = Modifier.height(40.dp),
@@ -604,10 +596,7 @@ private fun MethodChip(
}
@Composable
private fun QuickFillChip(
label: String,
onClick: () -> Unit,
) {
private fun QuickFillChip(label: String, onClick: () -> Unit) {
Button(
onClick = onClick,
shape = RoundedCornerShape(999.dp),

View File

@@ -1,14 +1,14 @@
package ai.openclaw.app.ui
import ai.openclaw.app.gateway.isLoopbackGatewayHost
import java.util.Base64
import java.util.Locale
import java.net.URI
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import java.net.URI
import java.util.Base64
import java.util.Locale
internal data class GatewayEndpointConfig(
val host: String,
@@ -121,9 +121,11 @@ internal fun resolveGatewayConnectConfig(
)
}
internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? = parseGatewayEndpointResult(rawInput).config
internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? {
return parseGatewayEndpointResult(rawInput).config
}
internal fun parseGatewayEndpointResult(rawInput: String): GatewayEndpointParseResult {
internal fun parseGatewayEndpointResult(rawInput: String): GatewayEndpointParseResult {
val raw = rawInput.trim()
if (raw.isEmpty()) return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL)
@@ -131,18 +133,10 @@ internal fun parseGatewayEndpointResult(rawInput: String): GatewayEndpointParseR
val uri =
runCatching { URI(normalized) }.getOrNull()
?: return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL)
val host =
uri.host
?.trim()
?.trim('[', ']')
.orEmpty()
val host = uri.host?.trim()?.trim('[', ']').orEmpty()
if (host.isEmpty()) return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL)
val scheme =
uri.scheme
?.trim()
?.lowercase(Locale.US)
.orEmpty()
val scheme = uri.scheme?.trim()?.lowercase(Locale.US).orEmpty()
val tls =
when (scheme) {
"ws", "http" -> false
@@ -205,7 +199,9 @@ internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? {
}
}
internal fun resolveScannedSetupCode(rawInput: String): String? = resolveScannedSetupCodeResult(rawInput).setupCode
internal fun resolveScannedSetupCode(rawInput: String): String? {
return resolveScannedSetupCodeResult(rawInput).setupCode
}
internal fun resolveScannedSetupCodeResult(rawInput: String): GatewayScannedSetupCodeResult {
val setupCode =
@@ -224,8 +220,8 @@ internal fun resolveScannedSetupCodeResult(rawInput: String): GatewayScannedSetu
internal fun gatewayEndpointValidationMessage(
error: GatewayEndpointValidationError,
source: GatewayEndpointInputSource,
): String =
when (error) {
): String {
return when (error) {
GatewayEndpointValidationError.INSECURE_REMOTE_URL ->
when (source) {
GatewayEndpointInputSource.SETUP_CODE ->
@@ -242,27 +238,25 @@ internal fun gatewayEndpointValidationMessage(
GatewayEndpointInputSource.MANUAL -> "Enter a valid manual endpoint to connect."
}
}
}
internal fun composeGatewayManualUrl(
hostInput: String,
portInput: String,
tls: Boolean,
): String? {
internal fun composeGatewayManualUrl(hostInput: String, portInput: String, tls: Boolean): String? {
val host = hostInput.trim()
if (host.isEmpty()) return null
val portTrimmed = portInput.trim()
val port =
if (portTrimmed.isEmpty()) {
if (tls) 443 else return null
} else {
portTrimmed.toIntOrNull() ?: return null
}
val port = if (portTrimmed.isEmpty()) {
if (tls) 443 else return null
} else {
portTrimmed.toIntOrNull() ?: return null
}
if (port !in 1..65535) return null
val scheme = if (tls) "https" else "http"
return "$scheme://$host:$port"
}
private fun parseJsonObject(input: String): JsonObject? = runCatching { gatewaySetupJson.parseToJsonElement(input).jsonObject }.getOrNull()
private fun parseJsonObject(input: String): JsonObject? {
return runCatching { gatewaySetupJson.parseToJsonElement(input).jsonObject }.getOrNull()
}
private fun resolveSetupCodeCandidate(rawInput: String): String? {
val trimmed = rawInput.trim()
@@ -271,10 +265,7 @@ private fun resolveSetupCodeCandidate(rawInput: String): String? {
return qrSetupCode ?: trimmed
}
private fun jsonField(
obj: JsonObject,
key: String,
): String? {
private fun jsonField(obj: JsonObject, key: String): String? {
val value = (obj[key] as? JsonPrimitive)?.contentOrNull?.trim().orEmpty()
return value.ifEmpty { null }
}

View File

@@ -1,11 +1,11 @@
package ai.openclaw.app.ui
import ai.openclaw.app.BuildConfig
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Build
import android.widget.Toast
import ai.openclaw.app.BuildConfig
internal fun openClawAndroidVersionLabel(): String {
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
@@ -16,7 +16,9 @@ internal fun openClawAndroidVersionLabel(): String {
}
}
internal fun gatewayStatusForDisplay(statusText: String): String = statusText.trim().ifEmpty { "Offline" }
internal fun gatewayStatusForDisplay(statusText: String): String {
return statusText.trim().ifEmpty { "Offline" }
}
internal fun gatewayStatusHasDiagnostics(statusText: String): Boolean {
val lower = gatewayStatusForDisplay(statusText).lowercase()
@@ -38,11 +40,7 @@ internal fun buildGatewayDiagnosticsReport(
.joinToString(" ")
.trim()
.ifEmpty { "Android" }
val androidVersion =
Build.VERSION.RELEASE
?.trim()
.orEmpty()
.ifEmpty { Build.VERSION.SDK_INT.toString() }
val androidVersion = Build.VERSION.RELEASE?.trim().orEmpty().ifEmpty { Build.VERSION.SDK_INT.toString() }
val endpoint = gatewayAddress.trim().ifEmpty { "unknown" }
val status = gatewayStatusForDisplay(statusText)
return """
@@ -64,7 +62,7 @@ internal fun buildGatewayDiagnosticsReport(
- android: $androidVersion (SDK ${Build.VERSION.SDK_INT})
- gateway address: $endpoint
- status/error: $status
""".trimIndent()
""".trimIndent()
}
internal fun copyGatewayDiagnosticsReport(

View File

@@ -15,10 +15,7 @@ import kotlinx.coroutines.delay
internal const val PAIRING_AUTO_RETRY_MS = 6_000L
@Composable
internal fun PairingAutoRetryEffect(
enabled: Boolean,
onRetry: () -> Unit,
) {
internal fun PairingAutoRetryEffect(enabled: Boolean, onRetry: () -> Unit) {
val lifecycleOwner = LocalLifecycleOwner.current
var lifecycleStarted by
remember(lifecycleOwner) {

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