Merge remote-tracking branch 'origin/main' into jesse/non-clawhub-untrusted-installs

# Conflicts:
#	src/agents/tools/crestodian-tool.ts
#	src/cli/hooks-cli.test.ts
#	src/plugins/bundled-sources.ts
This commit is contained in:
Jesse Merhi
2026-07-14 16:24:52 +10:00
7999 changed files with 1049291 additions and 356897 deletions
+39 -4
View File
@@ -36,10 +36,10 @@ Use when:
- Tools are useful in review mode. Codex receives the validated bundle in an empty workspace so ignored files and linked-worktree metadata remain unreadable; web search stays available for dependency contracts and upstream docs.
- Security perspective is always included, but it should not cripple legitimate functionality. Report security findings only when the change creates a concrete, actionable risk or removes an important safety check.
- Reviewer subprocesses preserve engine authentication and non-credentialed proxy variables needed by headless or restricted-network environments while stripping process-injection, Git override, and credentialed proxy values.
- Review bundles fail closed before engine invocation when tracked or untracked paths look sensitive, patch text looks secret-like, or a Git diff exceeds the bundle limit. Redact/split the change; never accept a truncated patch as complete review proof.
- Review bundles fail closed before engine invocation when tracked or untracked paths look sensitive or patch text looks secret-like. Safe large diffs are scanned in full, sent as one pass while they fit the aggregate prompt limit, then partitioned into complete bounded passes without truncation.
- For regression provenance, keep roles separate: blamed code author, blamed PR author, PR merger/committer, current PR author, and PR/date. If no blamed PR is traceable, use the blamed commit as the provenance: commit SHA, date, and author username. Do not guess a merger or frame missing PR metadata as a separate finding.
- If the blamed PR was merged by `clawsweeper[bot]` or another automation, identify the human trigger when practical. Check timeline/comments first; if rate-limited, use gitcrawl/cache or public PR HTML. Look for maintainer commands such as `@clawsweeper automerge`, `/landpr`, or labels/status comments that armed automerge. Report `automerge triggered by @login`; if not found, say trigger unknown.
- Do not invoke built-in `codex review`, nested reviewers, or reviewer panels from inside the review. The helper builds one bundle, calls one selected engine, validates one structured result, and stops.
- Do not invoke built-in `codex review`, nested reviewers, or reviewer panels from inside the review. The helper builds one validated bundle, calls the selected engine once for normal inputs or once per complete bounded chunk for oversized inputs, validates the structured results, and stops.
- Stop as soon as the helper exits 0 with no accepted/actionable findings. Do not run an extra review just to get a nicer "clean" line, a second opinion, or clearer closeout wording.
- Treat the helper's successful exit plus absence of actionable findings as the clean review result, even if the underlying Codex CLI output is terse.
- Multi-reviewer panels are opt-in only. Use them when explicitly requested or when risk justifies the extra spend; the main agent still verifies every accepted finding before fixing.
@@ -189,6 +189,29 @@ clean `main` against `origin/main` is usually an empty diff after push. For a
small stack, review each commit explicitly or review the branch before merging
with `--base`.
## Oversized Bundles
The helper scans the full patch before partitioning it. A safe bundle that fits
the aggregate prompt limit remains one integrated review pass. Larger bundles
are split at bundle sections and file boundaries where possible; an oversized
single-file block is split at line boundaries with repeated file/hunk context
and an absolute new- or old-file line offset. Untracked snapshots use
injection-safe source-line records so continuation passes retain reportable
locations. A single physical diff line split across passes also retains its
original addition, deletion, or context marker.
Every original bundle byte appears exactly once across the pass sequence, and
all validated reports are merged before required-finding and exit-status checks.
The helper caps one run at eight bounded passes so an unexpectedly huge branch
cannot create unbounded model calls; split still-larger work into coherent review
targets.
Chunking makes large-diff review usable, but it cannot give one model call every
cross-file implementation detail. For architecture-heavy changes, still prefer
a coherent branch or PR shape whose semantic decision surface fits one pass.
Removing verified non-authoritative generated noise remains useful, but never
drop lockfiles, generated clients, policies, manifests, schemas, or other
independently semantic artifacts merely to shrink the review.
## Parallel Closeout
Format first if formatting can change line locations. Then it is OK to run tests and review in parallel:
@@ -204,8 +227,19 @@ Parallel tests inherit only a small allowlist of ordinary OS, CI, and toolchain
variables. Put additional non-secret project controls directly in the test command.
Home and standard config directories point to a temporary isolated root that is
removed after the command exits. Do not put secrets in the command because it is
printed before execution. Run secret-bearing or credentialed tests separately in an
appropriately isolated remote runner.
printed before execution. Set `OPENCLAW_TESTBOX=1` on the autoreview process, not
inside the test command, because the environment snapshot and credential staging
happen before the test shell starts:
```bash
OPENCLAW_TESTBOX=1 "$AUTOREVIEW" --parallel-tests "pnpm check:changed"
```
This is the narrow trusted-maintainer-code exception: it stages only the Blacksmith
credential file into the temporary home so the command can delegate remotely. Never
use this credential-hydrated path for untrusted contributor or fork code. Run other
secret-bearing or credentialed tests separately in an appropriately isolated remote
runner.
Tradeoff: tests may force code changes that stale the review. If tests or review lead to code edits, rerun the affected tests and rerun review until no accepted/actionable findings remain. Once that rerun exits cleanly, stop; do not spend another long review cycle on redundant confirmation.
@@ -375,6 +409,7 @@ The helper:
- recognizes `--engine droid`, `copilot`, `cursor`, and `opencode` only to fail closed with isolation errors; runnable engines are `codex`, `claude`, and `pi`; default is `AUTOREVIEW_ENGINE` or `codex`
- resolves bare `git`, `gh`, reviewer, and PowerShell shell commands from absolute `PATH` entries only, never from the reviewed checkout; explicit `--*-bin` paths are interpreted from the reviewed repository root when relative and accepted only when both the supplied path and resolved target stay outside the reviewed repository
- use `--mode commit --commit <ref>` for already-committed work, especially clean `main` after landing
- scans safe Git patches in full, reviews them in one pass up to the aggregate prompt limit, and automatically uses complete bounded passes above it
- should be left in `--mode auto` or forced to `--mode branch` for PR/branch work; do not force `--mode local` after committing
- writes only to stdout unless `--output`, `--json-output`, or live streamed engine stderr is set
- supports `--dry-run`, `--parallel-tests`, `--parallel-tests-shell`, `--prompt`, repo-relative `--prompt-file`, repo-relative `--dataset`, `--no-tools`, `--no-web-search`, repeatable Codex-only safe model/response tuning with `--codex-config key=value`, Codex-only `--codex-speed fast|flex|default`, and commit refs
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -6,9 +6,10 @@ description: Regenerate OpenClaw release changelog sections from git history bef
# OpenClaw Changelog Update
Use this for release changelog rewrites and GitHub release-note source text.
This is mandatory before every beta, beta rerun, stable release, or stable
rerun. Use it with `release-openclaw-maintainer`; this skill owns changelog
content, ordering, grouping, and attribution discipline.
Run it once after the final Code SHA has green Full Release Validation. Do not
rerun it for same-candidate tooling retries, resumed publication, or promotion.
Use it with `release-openclaw-maintainer`; this skill owns changelog content,
ordering, grouping, and attribution discipline.
## Goal
@@ -25,7 +26,8 @@ every human `Thanks @...` attribution.
the target; a newer but divergent tag is not a valid history boundary. Use
an explicit shipped/main-closeout SHA only when it is also reachable from the
target.
- Target ref: exact branch/SHA being released.
- Target ref: the exact green Code SHA. The changelog commit created from this
input becomes the Release SHA.
- Canonical main ref: current `origin/main`, fetched before verification. Release
notes cite the original merged main PR when the same work is carried by a
backport. A release-branch PR is used only while no forward-port exists on
@@ -33,10 +35,12 @@ every human `Thanks @...` attribution.
## Workflow
1. Start on `main` before branching when possible:
1. Confirm the release branch is at the fully validated Code SHA:
- `git fetch --tags origin`
- `git pull --ff-only`
- confirm clean `git status -sb`
- record `git rev-parse HEAD` as the Code SHA
- record the successful Full Release Validation run id and attempt
- stop if any product/version/backport change is still pending
2. Audit history, including direct commits:
- `git log --first-parent --date=iso-strict --pretty=format:'%h%x09%ad%x09%s' <base-tag>..<target-ref>`
- `git log --first-parent --grep='(#' --date=short --pretty=format:'%h%x09%ad%x09%s' <base-tag>..<target-ref>`
@@ -241,7 +245,13 @@ every human `Thanks @...` attribution.
- `git diff --check`
- for docs/changelog-only changes, no broad tests are required
- commit with `scripts/committer "docs(changelog): refresh YYYY.M.PATCH notes" CHANGELOG.md`
- push, pull/rebase if needed, then branch/rebase release from latest `main`
- record the new commit as the Release SHA and require
`git diff --name-only <code-sha>..<release-sha>` to print only
`CHANGELOG.md`
- push the release branch without rebasing it onto moving `main`
- dispatch SHA-pinned Full Release Validation for the Release SHA with evidence
reuse enabled. It must select `changelog-only-release-v1`; any other changed
path returns the release to the Code SHA validation loop
## Quota / API Outage Rule
@@ -1,7 +1,15 @@
#!/usr/bin/env node
import { execFileSync, spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import {
@@ -805,6 +813,69 @@ function backportPullRequestOrigins(message) {
].map((match) => Number(match[1]));
}
export function releaseProvenanceMarkers(message) {
const markers = [];
for (const line of message.split("\n")) {
if (!/^Release provenance:/i.test(line)) {
continue;
}
const match = line.match(/^Release provenance: ([0-9a-f]{40}) -> (#\d+(?:,\s*#\d+)*)\.?\s*$/i);
if (!match) {
fail(`invalid release provenance marker: ${line}`);
}
markers.push({
commit: match[1].toLowerCase(),
pullRequests: [...match[2].matchAll(/#(\d+)/g)].map((reference) => Number(reference[1])),
});
}
return markers;
}
export function collectReleaseProvenanceOverrides(activeCommits) {
const activeCommitHashes = new Set(activeCommits.map((commit) => commit.hash));
const overrides = new Map();
for (const commit of activeCommits) {
for (const marker of releaseProvenanceMarkers(commit.body)) {
if (!activeCommitHashes.has(marker.commit)) {
fail(`release provenance marker targets commit outside the active range: ${marker.commit}`);
}
const existing = overrides.get(marker.commit);
if (existing && existing.join(",") !== marker.pullRequests.join(",")) {
fail(`conflicting release provenance markers for ${marker.commit}`);
}
overrides.set(marker.commit, marker.pullRequests);
}
}
return overrides;
}
export function resolvedReleasePullRequests(
currentPullRequests,
mainPullRequests,
hasCanonicalMainCommit,
provenanceOverride,
) {
return (
provenanceOverride ??
canonicalPullRequests(currentPullRequests, mainPullRequests, hasCanonicalMainCommit)
);
}
export function releasePullRequestReferencesToSuppress(
currentPullRequests,
subject,
associatedPullRequests,
hasProvenanceOverride,
) {
const candidates = new Set(currentPullRequests);
if (hasProvenanceOverride) {
for (const match of subject.matchAll(/\(#(\d+)\)\s*$/g)) {
candidates.add(Number(match[1]));
}
}
return [...candidates].filter((number) => !associatedPullRequests.includes(number));
}
function changedPathsForCommit(hash) {
return new Set(
git(["diff-tree", "--root", "--no-commit-id", "--name-only", "-r", hash, "--"])
@@ -849,19 +920,14 @@ export function canonicalMainCommitMatches(commit, candidates) {
}
const pullRequestOrigins = new Set(backportPullRequestOrigins(commit.body));
const pullRequestMatches = candidates.filter(
(candidate) =>
referencesIn(`${candidate.subject}\n${candidate.body ?? ""}`).some((number) =>
pullRequestOrigins.has(number),
) &&
authorsMatch(commit, candidate) &&
pathsOverlap(commit.changedPaths, candidate.changedPaths),
);
if (pullRequestMatches.length === 1) {
return [pullRequestMatches[0].hash];
}
if (pullRequestMatches.length > 1) {
return [];
if (pullRequestOrigins.size > 0) {
const pullRequestMatches = candidates.filter(
(candidate) =>
(candidate.pullRequests ?? []).some((number) => pullRequestOrigins.has(number)) &&
authorsMatch(commit, candidate) &&
pathsOverlap(commit.changedPaths, candidate.changedPaths),
);
return pullRequestMatches.length === 1 ? [pullRequestMatches[0].hash] : [];
}
const subject = normalizedCommitSubject(commit.subject);
@@ -1085,23 +1151,22 @@ function sourceCommits(base, target, mainRef) {
activeCommits.map((commit) => commit.hash),
targetTimestamp,
);
const provenanceOverrides = collectReleaseProvenanceOverrides(activeCommits);
const mainCommits = canonicalMainCommits(base, mainRef);
const mainCommit = provenanceOverrides.size > 0 ? gitCommit(mainRef, true) : undefined;
const mainCommitsByHash = new Map(mainCommits.map((commit) => [commit.hash, commit]));
const mainCommitsBySubject = new Map();
const mainCommitsByPullRequest = new Map();
for (const commit of mainCommits) {
const subject = normalizedCommitSubject(commit.subject);
const matches = mainCommitsBySubject.get(subject) ?? [];
matches.push(commit);
mainCommitsBySubject.set(subject, matches);
for (const number of referencesIn(`${commit.subject}\n${commit.body}`)) {
const pullRequestMatches = mainCommitsByPullRequest.get(number) ?? [];
pullRequestMatches.push(commit);
mainCommitsByPullRequest.set(number, pullRequestMatches);
}
}
const canonicalMainCommitsByReleaseCommit = new Map();
const namedMainPullRequestsByReleaseCommit = new Map();
const canonicalMainHashes = new Set();
const mainAssociationCandidateHashes = new Set();
const pendingCanonicalMatches = [];
const changedPathsByCommit = new Map();
const withChangedPaths = (commit) => {
if (!changedPathsByCommit.has(commit.hash)) {
@@ -1118,22 +1183,59 @@ function sourceCommits(base, target, mainRef) {
const explicit = cherryPickOrigins(commit.body)
.map((origin) => mainCommitsByHash.get(origin))
.filter(Boolean);
if (explicit.length > 0) {
const matches = [...new Set(explicit.map((candidate) => candidate.hash))];
canonicalMainCommitsByReleaseCommit.set(commit.hash, matches);
for (const hash of matches) {
canonicalMainHashes.add(hash);
}
continue;
}
const releaseCommit = withChangedPaths(commit);
const pullRequestOrigins = backportPullRequestOrigins(commit.body);
const candidates = new Map(
[
...(mainCommitsBySubject.get(normalizedCommitSubject(commit.subject)) ?? []),
...backportPullRequestOrigins(commit.body).flatMap(
(number) => mainCommitsByPullRequest.get(number) ?? [],
),
].map((candidate) => [candidate.hash, candidate]),
(mainCommitsBySubject.get(normalizedCommitSubject(commit.subject)) ?? []).map((candidate) => [
candidate.hash,
candidate,
]),
);
if (pullRequestOrigins.length > 0) {
for (const candidate of mainCommits) {
if (!authorsMatch(releaseCommit, candidate)) {
continue;
}
const mainCandidate = withChangedPaths(candidate);
if (!pathsOverlap(releaseCommit.changedPaths, mainCandidate.changedPaths)) {
continue;
}
candidates.set(candidate.hash, candidate);
mainAssociationCandidateHashes.add(candidate.hash);
}
}
pendingCanonicalMatches.push({ candidates, commit: releaseCommit, pullRequestOrigins });
}
const candidateMainPullRequests = resolveAssociatedPullRequests(
[...mainAssociationCandidateHashes],
Number.POSITIVE_INFINITY,
);
for (const { candidates, commit, pullRequestOrigins } of pendingCanonicalMatches) {
const matches = canonicalMainCommitMatches(
commit,
[...candidates.values()].map((candidate) => ({
...withChangedPaths(candidate),
pullRequests: candidateMainPullRequests.get(candidate.hash) ?? [],
})),
);
const matches =
explicit.length > 0
? [...new Set(explicit.map((candidate) => candidate.hash))]
: canonicalMainCommitMatches(
withChangedPaths(commit),
[...candidates.values()].map(withChangedPaths),
);
canonicalMainCommitsByReleaseCommit.set(commit.hash, matches);
if (pullRequestOrigins.length > 0 && matches.length === 1) {
const associatedPullRequests = candidateMainPullRequests.get(matches[0]) ?? [];
const namedPullRequests = pullRequestOrigins.filter((number) =>
associatedPullRequests.includes(number),
);
if (namedPullRequests.length > 0) {
namedMainPullRequestsByReleaseCommit.set(commit.hash, namedPullRequests);
}
}
for (const hash of matches) {
canonicalMainHashes.add(hash);
}
@@ -1147,18 +1249,28 @@ function sourceCommits(base, target, mainRef) {
const nonRevertPullRequests = new Set();
for (const commit of activeCommits) {
const currentPullRequests = activePullRequests.get(commit.hash) ?? [];
const mainPullRequests = (canonicalMainCommitsByReleaseCommit.get(commit.hash) ?? []).flatMap(
(hash) => canonicalMainPullRequests.get(hash) ?? [],
);
const namedMainPullRequests = namedMainPullRequestsByReleaseCommit.get(commit.hash);
const mainPullRequests =
namedMainPullRequests ??
(canonicalMainCommitsByReleaseCommit.get(commit.hash) ?? []).flatMap(
(hash) => canonicalMainPullRequests.get(hash) ?? [],
);
const matchedMainCommits = canonicalMainCommitsByReleaseCommit.get(commit.hash) ?? [];
const associatedPullRequests = canonicalPullRequests(
const provenanceOverride = provenanceOverrides.get(commit.hash);
const associatedPullRequests = resolvedReleasePullRequests(
currentPullRequests,
mainPullRequests,
matchedMainCommits.length > 0,
provenanceOverride,
);
commit.pullRequests = associatedPullRequests;
const suppressedBackportPullRequests = new Set(
currentPullRequests.filter((number) => !associatedPullRequests.includes(number)),
releasePullRequestReferencesToSuppress(
currentPullRequests,
commit.subject,
associatedPullRequests,
provenanceOverride !== undefined,
),
);
commit.references = commit.references.filter(
(number) => !suppressedBackportPullRequests.has(number),
@@ -1217,8 +1329,10 @@ function sourceCommits(base, target, mainRef) {
return {
activeCommits,
coauthorsByReference,
mainCommit,
mergeBase,
pullRequests,
provenanceOverrides,
references,
revertedReferences,
target: targetCommit,
@@ -1424,7 +1538,9 @@ function resolveReferences(numbers) {
... on PullRequest {
number
title
baseRefName
mergedAt
mergeCommit { oid }
author { __typename login }
closingIssuesReferences(first: 100) {
nodes { number }
@@ -1446,6 +1562,29 @@ function resolveReferences(numbers) {
return resolveIssueRelationshipPages(nodes);
}
export function validateReleaseProvenanceOverrides(
provenanceOverrides,
nodes,
mainCommit,
isMainAncestor = gitIsAncestor,
) {
for (const [commit, pullRequests] of provenanceOverrides) {
for (const number of pullRequests) {
const node = nodes.get(number);
// Markers may name current-main forward-ports, but never release-only or unmerged PRs.
if (
node?.__typename !== "PullRequest" ||
node.baseRefName !== "main" ||
!node.mergedAt ||
!node.mergeCommit?.oid ||
!isMainAncestor(node.mergeCommit.oid, mainCommit)
) {
fail(`release provenance marker for ${commit} references non-main PR #${number}`);
}
}
}
}
function resolveGitHubHandles(handles) {
const resolved = new Map();
const uniqueHandles = [...new Set(handles)];
@@ -2117,6 +2256,19 @@ function releaseChecks(changelog, version, releaseTags) {
return checks;
}
function writeFileAtomic(filePath, contents) {
const directory = path.dirname(filePath);
mkdirSync(directory, { recursive: true });
const tempDirectory = mkdtempSync(path.join(directory, `.${path.basename(filePath)}.tmp-`));
const tempPath = path.join(tempDirectory, path.basename(filePath));
try {
writeFileSync(tempPath, contents);
renameSync(tempPath, filePath);
} finally {
rmSync(tempDirectory, { force: true, recursive: true });
}
}
function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
@@ -2124,8 +2276,8 @@ function main() {
return;
}
githubSnapshotState = initializeGithubSnapshot(options);
let changelog = readFileSync("CHANGELOG.md", "utf8");
let section = sectionFor(changelog, options.version);
const changelog = readFileSync("CHANGELOG.md", "utf8");
const section = sectionFor(changelog, options.version);
const source = sourceCommits(options.base, options.target, options.mainRef ?? "origin/main");
const shippedBaselineRecords = options.shippedRefs.map(shippedBaselineFor);
const shippedExclusions = subtractShippedPullRequests(source, shippedBaselineRecords);
@@ -2216,6 +2368,7 @@ function main() {
.join(", ")}`,
);
}
validateReleaseProvenanceOverrides(source.provenanceOverrides, nodes, source.mainCommit);
const provisionalEntries = references
.map((number) => nodes.get(number))
.filter((node) => node?.__typename === "PullRequest");
@@ -2285,32 +2438,32 @@ function main() {
ledger,
relationships.directCommits,
);
if (options.manifestPath) {
writeFileSync(options.manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
writeFileAtomic(options.manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
}
let candidateChangelog = changelog;
let candidateSection = section;
if (options.writeLedger) {
changelog = replaceLedger(
candidateChangelog = replaceLedger(
changelog,
section,
ledger.ledger,
ledger.pullRequests,
relationships.directCommits,
);
writeFileSync("CHANGELOG.md", changelog);
section = sectionFor(changelog, options.version);
candidateSection = sectionFor(candidateChangelog, options.version);
}
const errors = ledgerChecks(
section,
candidateSection,
ledger.pullRequests,
nodes,
relationships.directCommits,
source.shippedBaselines,
);
const github = options.checkGithub
? releaseChecks(changelog, options.version, options.releaseTags)
? releaseChecks(candidateChangelog, options.version, options.releaseTags)
: [];
for (const check of github) {
if (!check.matches) {
@@ -2319,6 +2472,11 @@ function main() {
);
}
}
if (errors.length === 0) {
if (options.writeLedger) {
writeFileAtomic("CHANGELOG.md", candidateChangelog);
}
}
const result = {
base: options.base,
@@ -134,6 +134,10 @@ These are intentionally guarded by `test/scripts/ci-workflow-guards.test.ts`:
- `preflight` and `security-fast` needing `runner-admission`.
- CI matrix caps: fast/check lanes at 12, Node test shards at 28, Windows and
Android at 2.
- Canonical PR Node tests use one precise changed-target job when possible;
broad, deleted, unknown, or planner-failed changes fall back to the 14-job
compact full-suite plan. Targeted plans retain the full built-artifact
boundary gate. `main`, manual, and release runs stay full.
- `build-artifacts` on `blacksmith-16vcpu-ubuntu-2404`.
- lower-weight Node/check shards on `blacksmith-4vcpu-ubuntu-2404`.
- heavy retained Linux/Android shards on `blacksmith-8vcpu-ubuntu-2404`.
@@ -30,8 +30,9 @@ Keep `/Users/steipete/openclaw` a read-only-to-the-agent deployment mirror: clea
- Every successful update sets `actions.gatewayBuild` and rebuilds exact new `main` before any restart.
- Missing, invalid, or stale build output also forces a build, even when Git did not move.
- A dependency-input change, absent `node_modules`, or missing/invalid build provenance requires `pnpm install --frozen-lockfile`. When a build is required, do not install before acquiring the maintenance suspension and stopping the managed Gateway.
- Before any dependency or build mutation, invoke the Gateway's existing built CLI to acquire `gateway.suspend.prepare`, binding both prepare and resume to this checkout's managed LaunchAgent loopback port and service auth even when normal CLI configuration points at a remote Gateway. The LaunchAgent may execute either this checkout's `dist/index.js` or a clean detached canonical snapshot under `~/.openclaw/runtime/gateway-<sha>` whose commit is an ancestor of the checkout; reject every other entrypoint. Never execute snapshot code: capture an exact source control build before the Git fast-forward for its prepare and failure-resume calls, preserve any validated generated service-environment wrapper, and stop the managed LaunchAgent with native launchd bootout. If that control build is missing, proceed only when native proof shows the snapshot job is already booted out with its port free, allowing a failed-build retry without executing unverified generated code. This atomically pauses cron scheduling, closes new work admission, and refuses while active work remains. A busy result defers the build to the next heartbeat without installing dependencies or stopping the Gateway; never replace this fence with `cron list` polling. Once ready, stop directly without a source launcher, install frozen dependencies when required, then build; source launchers can auto-build stale output before dispatching the stop. Resume the suspension if stop fails. If suspension RPC is unavailable on macOS, proceed only when native inspection proves this checkout's managed LaunchAgent is booted out and its configured port has no listener; never accept a loaded KeepAlive job's transient stopped state. On other platforms, require the existing CLI to prove the managed service is stopped with no PID, listener, or RPC. This preserves retry after a post-stop failure without weakening the live-work fence. Preserve `dist/OpenClaw.app` outside `dist` for the build and restore it even when the build fails, because the JS build cleans `dist` regardless of Mac impact classification. Never mutate the live `dist` tree while an old Gateway can dynamically import from it. `pnpm build` must leave both canonical stamp heads and `dist/build-info.json.commit` equal to post-update `afterSha`; any missing/mismatched stamp or required artifact blocks restart.
- Only after exact-SHA build proof may it restart the managed Gateway and require `gateway status --deep --require-rpc --json` plus `health --verbose --json`. A validated ancestor snapshot is suspension-only: prove the old launchd job is booted out with its port free, then atomically retarget only the owned LaunchAgent entrypoint to this checkout's exact `dist/index.js`, including on a retry where the build is already current. Preserve all other service arguments and environment unchanged. After restart, prove the loaded launchd PID owns the configured listener.
- Before mutating any entrypoint currently executed by the Gateway, invoke an exact trusted source CLI to acquire `gateway.suspend.prepare`, binding both prepare and resume to this checkout's managed LaunchAgent loopback port and service auth even when normal CLI configuration points at a remote Gateway. The LaunchAgent may execute either this checkout's `dist/index.js` or a clean detached canonical snapshot under `~/.openclaw/runtime/gateway-<sha>` whose commit is an ancestor of the checkout; reject every other entrypoint. Never execute snapshot code: capture an exact source control build before the Git fast-forward for its prepare and failure-resume calls, preserve any validated generated service-environment wrapper, and stop the managed LaunchAgent with native launchd bootout. If that control build is missing, first accept native proof that the snapshot job is already booted out with its port free; when the isolated snapshot is still running, build the verified clean source checkout to obtain an exact suspension client, then use only that source client for prepare and failure-resume. Never use this recovery build while launchd targets the source checkout. This atomically pauses cron scheduling, closes new work admission, and refuses while active work remains. A busy result defers further mutation to the next heartbeat; never replace this fence with `cron list` polling. Once ready, stop directly without a source launcher, install frozen dependencies when required, then build unless the exact recovery build already produced the deployment artifact; source launchers can auto-build stale output before dispatching the stop. Resume the suspension if stop fails. If suspension RPC is unavailable on macOS, proceed only when native inspection proves this checkout's managed LaunchAgent is booted out and its configured port has no listener; never accept a loaded KeepAlive job's transient stopped state. On other platforms, require the existing CLI to prove the managed service is stopped with no PID, listener, or RPC. This preserves retry after a post-stop failure without weakening the live-work fence. Preserve `dist/OpenClaw.app` outside `dist` for the build and restore it even when the build fails, because the JS build cleans `dist` regardless of Mac impact classification. Never mutate the live `dist` tree while an old Gateway can dynamically import from it. `pnpm build` must leave both canonical stamp heads and `dist/build-info.json.commit` equal to post-update `afterSha`; any missing/mismatched stamp or required artifact blocks restart.
- Snapshot ownership validation must never invoke Git inside the snapshot. Treat its worktree, local Git configuration, filters, hooks, attributes, and build artifacts as untrusted; prove only that the regular owned LaunchAgent entrypoint is under the canonical detached ancestor snapshot path. Snapshot validation authorizes native bootout and retargeting only. Every CLI path must reject snapshot execution and use the trusted source checkout build instead.
- Only after exact-SHA build proof may it restart the managed Gateway and require `gateway status --deep --require-rpc --json` plus `health --verbose --json`. A validated ancestor snapshot is suspension-only: prove the old launchd job is booted out with its port free, allowing bounded retries while launchd and the listener finish teardown, then atomically retarget only the owned LaunchAgent entrypoint to this checkout's exact `dist/index.js`, including on a retry where the build is already current. Replace the verified `ProgramArguments` array as one value; never use array-index plist mutation that can insert a duplicate argument. Preserve all other service arguments and environment unchanged. After restart, prove the loaded launchd PID owns the configured listener.
- After every managed restart, query Gateway logs through RPC, restrict the audit to entries emitted since that restart began, report warning summaries, and fail the pass on any error/fatal entry. If RPC verification or log retrieval fails, still inspect the local structured log for that restart window. Never accept supervisor or RPC health without this restart-window log audit.
Treat supervisor state alone as insufficient. If build or proof fails, leave the new mirror head intact and retry the stale/missing build on the next heartbeat; never run the old `dist` against new source.
@@ -17,6 +17,7 @@ import {
import { tmpdir } from "node:os";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
import { detectChangedScope } from "../../../../scripts/ci-changed-scope.mjs";
import { isDirectRunUrl } from "../../../../scripts/lib/direct-run.mjs";
import {
@@ -37,6 +38,8 @@ const DEFAULT_EXPECTED_ORIGIN = "openclaw/openclaw";
const FULL_SHA_RE = /^[0-9a-f]{40}$/u;
const GATEWAY_READINESS_ATTEMPTS = 3;
const GATEWAY_READINESS_RETRY_DELAY_MS = 5_000;
const GATEWAY_STOP_PROOF_ATTEMPTS = 100;
const GATEWAY_STOP_PROOF_RETRY_DELAY_MS = 100;
const GATEWAY_SUSPEND_TIMEOUT_MS = 10_000;
const GENERATED_LAUNCH_AGENT_ENV_WRAPPER = `#!/bin/sh
set -eu
@@ -123,6 +126,15 @@ function commitExists(checkout, sha) {
}
}
function isAncestorCommit(checkout, ancestor, descendant = "HEAD") {
try {
git(checkout, ["merge-base", "--is-ancestor", ancestor, descendant]);
return true;
} catch {
return false;
}
}
export function classifyActions(
changedPaths,
{ buildProvenanceKnown, buildRequired, nodeModulesPresent },
@@ -530,6 +542,54 @@ function defaultRunCommand(command, args, checkout) {
});
}
function readSnapshotMetadata(snapshotRoot) {
const gitDir = path.join(snapshotRoot, ".git");
const headPath = path.join(gitDir, "HEAD");
const configPath = path.join(gitDir, "config");
const gitDirStat = lstatSync(gitDir);
const headStat = lstatSync(headPath);
const configStat = lstatSync(configPath);
const owner = typeof process.getuid === "function" ? process.getuid() : null;
const isTrustedMetadataFile = (filePath, fileStat) =>
fileStat.isFile() &&
!fileStat.isSymbolicLink() &&
(owner === null || fileStat.uid === owner) &&
(fileStat.mode & 0o022) === 0 &&
realpathSync(filePath) === filePath;
if (
!gitDirStat.isDirectory() ||
gitDirStat.isSymbolicLink() ||
realpathSync(gitDir) !== gitDir ||
!isTrustedMetadataFile(headPath, headStat) ||
!isTrustedMetadataFile(configPath, configStat)
) {
return null;
}
const head = readFileSync(headPath, "utf8").trim().toLowerCase();
if (!FULL_SHA_RE.test(head)) {
return null;
}
let inOrigin = false;
let originUrl = null;
for (const rawLine of readFileSync(configPath, "utf8").split("\n")) {
const line = rawLine.trim();
if (line.startsWith("[") && line.endsWith("]")) {
inOrigin = /^\[remote\s+"origin"\]$/u.test(line);
continue;
}
if (!inOrigin) {
continue;
}
const urlMatch = line.match(/^url\s*=\s*(.+)$/u);
if (urlMatch) {
originUrl = urlMatch[1].trim().replace(/^"(.*)"$/u, "$1");
break;
}
}
return originUrl ? { head, originUrl } : null;
}
export function isOwnedGatewayEntrypoint(checkout, home, entrypoint) {
const sourceEntrypoint = path.join(checkout, "dist/index.js");
if (entrypoint === sourceEntrypoint) {
@@ -549,30 +609,30 @@ export function isOwnedGatewayEntrypoint(checkout, home, entrypoint) {
}
try {
const metadata = readSnapshotMetadata(snapshotRoot);
const entrypointStat = lstatSync(entrypoint);
const owner = typeof process.getuid === "function" ? process.getuid() : null;
if (
realpathSync(snapshotRoot) !== snapshotRoot ||
realpathSync(entrypoint) !== entrypoint ||
!originMatches(gitText(snapshotRoot, ["remote", "get-url", "origin"])) ||
gitText(snapshotRoot, ["status", "--porcelain"]) !== ""
!entrypointStat.isFile() ||
entrypointStat.isSymbolicLink() ||
(owner !== null && entrypointStat.uid !== owner) ||
(entrypointStat.mode & 0o022) !== 0 ||
!metadata ||
!originMatches(metadata.originUrl)
) {
return false;
}
try {
git(snapshotRoot, ["symbolic-ref", "-q", "HEAD"]);
return false;
} catch {
// Immutable runtime snapshots are detached from every mutable branch.
}
const snapshotHead = gitText(snapshotRoot, ["rev-parse", "HEAD"]);
const snapshotHead = metadata.head;
if (
!FULL_SHA_RE.test(snapshotHead) ||
snapshotName !== `gateway-${snapshotHead.slice(0, 7)}` ||
!commitExists(checkout, snapshotHead) ||
!inspectBuildState(snapshotRoot, snapshotHead).current
!isAncestorCommit(checkout, snapshotHead)
) {
return false;
}
git(checkout, ["merge-base", "--is-ancestor", snapshotHead, "HEAD"]);
return true;
} catch {
return false;
@@ -728,6 +788,8 @@ function readManagedGatewayLaunchAgent(checkout) {
const label = plist?.Label;
const programArguments = plist?.ProgramArguments;
const environmentVariables = plist?.EnvironmentVariables;
const workingDirectory =
typeof plist?.WorkingDirectory === "string" ? plist.WorkingDirectory : null;
const serviceEnvironment = Object.fromEntries(
Object.entries(environmentVariables ?? {}).filter((entry) => typeof entry[1] === "string"),
);
@@ -772,6 +834,7 @@ function readManagedGatewayLaunchAgent(checkout) {
runtime: gatewayCommand.runtime,
serviceEnvironment,
stateDir: gatewayCommand.stateDir,
workingDirectory,
wrapperPath: gatewayCommand.wrapperPath,
};
}
@@ -818,23 +881,43 @@ export function repointManagedGatewayDeployment(
};
}
export function replaceLaunchAgentProgramArgument(programArguments, index, expected, replacement) {
if (!Array.isArray(programArguments) || programArguments[index] !== expected) {
throw new UpdateInvariantError(
"gateway_repoint_failed",
"managed Gateway LaunchAgent changed before its entrypoint could be replaced",
);
}
return programArguments.with(index, replacement);
}
function replaceLaunchAgentEntrypoint(deployment, entrypoint) {
const original = readFileSync(deployment.plistPath);
const temporaryPath = `${deployment.plistPath}.openclaw-live-updater-${randomUUID()}`;
writeFileSync(temporaryPath, original, {
writeFileSync(temporaryPath, readFileSync(deployment.plistPath), {
flag: "wx",
mode: statSync(deployment.plistPath).mode,
});
try {
const plistResult = spawnSync(
"/usr/bin/plutil",
["-convert", "json", "-o", "-", temporaryPath],
{ encoding: "utf8" },
);
if (plistResult.status !== 0) {
throw new UpdateInvariantError(
"gateway_repoint_failed",
`could not read the managed Gateway LaunchAgent: ${String(plistResult.stderr).trim()}`,
);
}
const programArguments = replaceLaunchAgentProgramArgument(
JSON.parse(plistResult.stdout)?.ProgramArguments,
deployment.entrypointIndex,
deployment.entrypoint,
entrypoint,
);
execFileSync(
"/usr/bin/plutil",
[
"-replace",
`ProgramArguments.${deployment.entrypointIndex}`,
"-string",
entrypoint,
temporaryPath,
],
["-replace", "ProgramArguments", "-json", JSON.stringify(programArguments), temporaryPath],
{ stdio: ["ignore", "ignore", "pipe"] },
);
execFileSync("/usr/bin/plutil", ["-lint", temporaryPath], {
@@ -925,8 +1008,25 @@ export function parseLaunchctlArguments(output) {
: [];
}
function runBuiltGatewayCli(checkout, args, deployment) {
const managedDeployment = deployment ?? readManagedGatewayLaunchAgent(checkout);
function runBuiltGatewayCli(checkout, args, deployment, options = {}) {
const observedDeployment = deployment ?? readManagedGatewayLaunchAgent(checkout);
const sourceEntrypoint = path.join(checkout, "dist/index.js");
let managedDeployment = observedDeployment;
if (observedDeployment.entrypoint !== sourceEntrypoint) {
const currentHead = gitText(checkout, ["rev-parse", "HEAD"]);
managedDeployment = resolveGatewayControlDeployment(
checkout,
observedDeployment,
inspectBuildState(checkout, currentHead),
currentHead,
);
if (!managedDeployment) {
throw new UpdateInvariantError(
"gateway_snapshot_control_unavailable",
"refusing to execute a managed Gateway runtime snapshot without a trusted source control build",
);
}
}
const {
configPath,
entrypoint,
@@ -936,6 +1036,7 @@ function runBuiltGatewayCli(checkout, args, deployment) {
port,
runtime,
serviceEnvironment = {},
workingDirectory,
wrapperPath,
} = managedDeployment;
const baseEnv = { ...process.env };
@@ -996,10 +1097,10 @@ function runBuiltGatewayCli(checkout, args, deployment) {
]
: [...invocationPrefix, ...args];
return execFileSync(executable, callArgs, {
cwd: path.dirname(path.dirname(entrypoint)),
cwd: workingDirectory ?? path.dirname(path.dirname(entrypoint)),
encoding: "utf8",
env,
stdio: ["ignore", "pipe", "inherit"],
stdio: ["ignore", "pipe", options.stderr ?? "inherit"],
});
} finally {
rmSync(overlayPath, { force: true });
@@ -1069,6 +1170,33 @@ function stopManagedGateway(runCommand, checkout, deployment) {
);
}
function stopManagedGatewayAndProve(runCommand, checkout, deployment, proveGatewayStopped, sleep) {
let stopError;
try {
stopManagedGateway(runCommand, checkout, deployment);
} catch (error) {
stopError = error;
}
let proofError;
for (let attempt = 0; attempt < GATEWAY_STOP_PROOF_ATTEMPTS; attempt += 1) {
try {
return proveGatewayStopped(checkout);
} catch (error) {
proofError = error;
if (attempt + 1 < GATEWAY_STOP_PROOF_ATTEMPTS) {
sleep(GATEWAY_STOP_PROOF_RETRY_DELAY_MS);
}
}
}
if (!stopError) {
throw proofError;
}
throw new AggregateError(
[stopError, proofError],
"Gateway stop command failed and native stopped proof did not converge",
);
}
function isTrustedSourceControlBuild(checkout, buildState, currentHead) {
if (buildState.current) {
return true;
@@ -1326,6 +1454,15 @@ function restartGateway(
return startedAtMs;
}
function isManagedGatewayLoaded(deployment) {
const result = spawnSync(
"/bin/launchctl",
["print", `gui/${process.getuid()}/${deployment.label}`],
{ encoding: "utf8" },
);
return result.status === 0;
}
function verifyGateway(runCommand, checkout, expectedSha, deployment = null) {
assertExactBuild(checkout, expectedSha);
if (deployment) {
@@ -1384,13 +1521,85 @@ function summarizeGatewayLogEntry(entry) {
};
}
export function parseGatewayLogAudit(output, sinceMs) {
const entries = output
function canonicalizeExistingPath(filePath) {
try {
return realpathSync(filePath);
} catch {
return path.resolve(filePath);
}
}
function isPathWithinRoot(sourcePath, rootPath) {
const normalizedRoot = canonicalizeExistingPath(rootPath);
const normalizedSource = canonicalizeExistingPath(sourcePath);
return (
normalizedSource === normalizedRoot ||
normalizedSource.startsWith(`${normalizedRoot}${path.sep}`)
);
}
function isCurrentGatewayLogSource(source, sourceRoot, managedSourceRoots) {
if (managedSourceRoots === null) {
return true;
}
if (!sourceRoot) {
return true;
}
if (typeof source !== "string" || source.length === 0) {
return true;
}
let sourcePath;
try {
sourcePath = source.startsWith("file:") ? fileURLToPath(source) : source;
} catch {
return true;
}
const sourceFilePath = sourcePath.replace(/:\d+(?::\d+)?$/u, "");
if (sourceFilePath !== sourcePath && !existsSync(sourcePath) && existsSync(sourceFilePath)) {
sourcePath = sourceFilePath;
}
if (
isPathWithinRoot(sourcePath, sourceRoot) ||
managedSourceRoots.some((rootPath) => isPathWithinRoot(sourcePath, rootPath))
) {
return true;
}
const normalizedRoot = canonicalizeExistingPath(sourceRoot);
const normalizedSource = canonicalizeExistingPath(sourcePath);
const checkoutRoot = path.dirname(normalizedRoot);
let candidate = path.dirname(normalizedSource);
while (candidate !== path.dirname(candidate)) {
const packagePath = path.join(candidate, "package.json");
const gitPath = path.join(candidate, ".git");
if (existsSync(packagePath) && existsSync(gitPath)) {
try {
if (JSON.parse(readFileSync(packagePath, "utf8")).name === "openclaw") {
return candidate === checkoutRoot;
}
} catch {
return true;
}
}
candidate = path.dirname(candidate);
}
return true;
}
function parseGatewayLogEntries(output, sinceMs) {
return output
.split("\n")
.filter(Boolean)
.flatMap((line) => {
try {
const raw = JSON.parse(line);
let sourceRecord = raw;
if (raw.type === "log" && typeof raw.raw === "string") {
try {
sourceRecord = JSON.parse(raw.raw);
} catch {
sourceRecord = raw;
}
}
const rawLevel = raw.type === "log" ? raw.level : raw._meta?.logLevelName;
const level = String(rawLevel ?? "").toLowerCase();
const time = raw.time ?? raw._meta?.date;
@@ -1412,12 +1621,16 @@ export function parseGatewayLogAudit(output, sinceMs) {
level,
subsystem,
message: raw.message ?? raw["1"] ?? raw["0"] ?? "",
source: sourceRecord._meta?.path?.fullFilePath ?? null,
},
];
} catch {
return [];
}
});
}
function summarizeGatewayLogAudit(entries) {
const errors = entries
.filter((entry) => entry.level === "error" || entry.level === "fatal")
.map(summarizeGatewayLogEntry);
@@ -1431,6 +1644,13 @@ export function parseGatewayLogAudit(output, sinceMs) {
};
}
export function parseGatewayLogAudit(output, sinceMs, sourceRoot = null, managedSourceRoots = []) {
const entries = parseGatewayLogEntries(output, sinceMs).filter((entry) =>
isCurrentGatewayLogSource(entry.source, sourceRoot, managedSourceRoots),
);
return summarizeGatewayLogAudit(entries);
}
function localDateKey(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
@@ -1453,7 +1673,47 @@ function readFallbackGatewayLogs(sinceMs) {
return contents.join("\n");
}
function defaultAuditGatewayLogs(checkout, sinceMs) {
function readManagedPluginSourceRoots(checkout, deployment) {
let managedDeployment = deployment;
try {
managedDeployment ??= readManagedGatewayLaunchAgent(checkout);
} catch {
return null;
}
try {
const output = runBuiltGatewayCli(
checkout,
["plugins", "list", "--enabled", "--json"],
managedDeployment,
{ stderr: "pipe" },
);
return resolveManagedPluginSourceRoots(JSON.parse(output));
} catch {
return null;
}
}
export function resolveManagedPluginSourceRoots(report) {
if (!Array.isArray(report?.plugins)) {
return null;
}
const roots = [];
for (const plugin of report.plugins) {
if (typeof plugin?.rootDir !== "string" || plugin.rootDir.length === 0) {
return null;
}
roots.push(plugin.rootDir);
}
return roots;
}
export function resolveManagedGatewaySourceRoot(checkout, deployment) {
return typeof deployment?.entrypoint === "string" && deployment.entrypoint.length > 0
? path.dirname(path.resolve(deployment.entrypoint))
: path.join(realpathSync(checkout), "dist");
}
function defaultAuditGatewayLogs(checkout, sinceMs, deployment = null) {
let output;
try {
output = execFileSync(
@@ -1477,7 +1737,12 @@ function defaultAuditGatewayLogs(checkout, sinceMs) {
throw error;
}
}
const audit = parseGatewayLogAudit(output, sinceMs);
const audit = parseGatewayLogAudit(
output,
sinceMs,
resolveManagedGatewaySourceRoot(checkout, deployment),
readManagedPluginSourceRoots(checkout, deployment),
);
if (audit.errorCount > 0) {
throw new UpdateInvariantError(
"gateway_restart_log_errors",
@@ -1502,7 +1767,7 @@ function verifyAndAuditGateway({
} catch (error) {
verificationError = error;
}
const audit = auditGatewayLogs(checkout, sinceMs);
const audit = auditGatewayLogs(checkout, sinceMs, deployment);
if (verificationError) {
throw verificationError;
}
@@ -1555,6 +1820,9 @@ export function maintainMain(options, dependencies = {}) {
const replaceGatewayEntrypoint =
dependencies.replaceGatewayEntrypoint ?? replaceLaunchAgentEntrypoint;
const verifyGatewayRuntime = dependencies.verifyGatewayRuntime ?? verifyManagedGatewayRuntime;
const verifyGatewayProbe = dependencies.verifyGateway ?? verifyGateway;
const verifyGatewayAfterRestart = dependencies.verifyAndAuditGateway ?? verifyAndAuditGateway;
const isGatewayLoaded = dependencies.isGatewayLoaded ?? isManagedGatewayLoaded;
const prepareSuspension =
dependencies.prepareGatewaySuspension ??
((checkout, deployment) =>
@@ -1569,7 +1837,7 @@ export function maintainMain(options, dependencies = {}) {
verifiedBefore.checkout,
verifiedBefore.headSha,
);
const gatewayControlDeployment = resolveGatewayControlDeployment(
let gatewayControlDeployment = resolveGatewayControlDeployment(
verifiedBefore.checkout,
gatewayDeploymentBefore,
sourceBuildBeforeUpdate,
@@ -1620,6 +1888,8 @@ export function maintainMain(options, dependencies = {}) {
if (actions.gatewayBuild || actions.dependencyInstall || gatewayRuntimeRepointRequired) {
actions.gatewayRestart = true;
let controlBuildPrepared = false;
let controlDependenciesInstalled = false;
let gatewaySuspension;
const controlUnavailable =
gatewayDeploymentBefore !== null && gatewayControlDeployment === null;
@@ -1630,16 +1900,55 @@ export function maintainMain(options, dependencies = {}) {
proof: proveGatewayStopped(update.checkout),
};
} catch (proofError) {
throw new AggregateError(
[
new UpdateInvariantError(
try {
if (!gatewayRuntimeRepointRequired) {
throw new UpdateInvariantError(
"gateway_live_source_build_forbidden",
"refusing to rebuild the source entrypoint while its managed Gateway is still running",
);
}
// The running Gateway is isolated in its immutable snapshot, so a
// clean source build cannot mutate its code. Build only to obtain
// an exact trusted client for the suspension RPC.
if (actions.dependencyInstall) {
runCommand("pnpm", ["install", "--frozen-lockfile"], update.checkout);
controlDependenciesInstalled = true;
}
if (!actions.gatewayBuild) {
throw new UpdateInvariantError(
"gateway_snapshot_control_unavailable",
"managed Gateway uses a snapshot but the source checkout has no exact trusted control build",
),
proofError,
],
"Gateway control is unavailable and the managed Gateway could not be proven stopped",
);
"managed Gateway snapshot has no exact trusted source control build",
);
}
runBuildWithPreservedMacApp(runCommand, update.checkout, sleep);
assertExactBuild(update.checkout, update.afterSha);
controlBuildPrepared = true;
gatewayControlDeployment = resolveGatewayControlDeployment(
update.checkout,
gatewayDeploymentBefore,
inspectBuildState(update.checkout, update.afterSha),
update.afterSha,
);
if (!gatewayControlDeployment) {
throw new UpdateInvariantError(
"gateway_snapshot_control_unavailable",
"source build did not produce an exact trusted Gateway control client",
);
}
gatewaySuspension = prepareSuspension(update.checkout, gatewayControlDeployment);
} catch (controlError) {
throw new AggregateError(
[
new UpdateInvariantError(
"gateway_snapshot_control_unavailable",
"managed Gateway uses a snapshot but the source checkout has no exact trusted control build",
),
proofError,
controlError,
],
"Gateway control is unavailable and the managed Gateway could not be proven stopped",
);
}
}
} else {
try {
@@ -1675,10 +1984,15 @@ export function maintainMain(options, dependencies = {}) {
// Native bootout prevents launchd from retaining old ProgramArguments
// and avoids source launchers that can rebuild stale dist before stopping.
try {
stopManagedGateway(runCommand, update.checkout, gatewayDeploymentBefore);
// Retarget only after launchd has discarded the old ProgramArguments.
// Otherwise a later kickstart can revive its cached snapshot command.
proveGatewayStopped(update.checkout);
// launchctl can return before the job and listener have disappeared.
// Retarget only after bounded native proof prevents cached snapshot revival.
stopManagedGatewayAndProve(
runCommand,
update.checkout,
gatewayDeploymentBefore,
proveGatewayStopped,
sleep,
);
} catch (error) {
try {
resumeSuspension(
@@ -1695,10 +2009,10 @@ export function maintainMain(options, dependencies = {}) {
throw error;
}
}
if (actions.dependencyInstall) {
if (actions.dependencyInstall && !controlDependenciesInstalled) {
runCommand("pnpm", ["install", "--frozen-lockfile"], update.checkout);
}
if (actions.gatewayBuild) {
if (actions.gatewayBuild && !controlBuildPrepared) {
runBuildWithPreservedMacApp(runCommand, update.checkout, sleep);
}
assertExactBuild(update.checkout, update.afterSha);
@@ -1719,7 +2033,7 @@ export function maintainMain(options, dependencies = {}) {
gatewayDeployment,
gatewayDeployment !== null,
);
gatewayLogAudit = verifyAndAuditGateway({
gatewayLogAudit = verifyGatewayAfterRestart({
runCommand,
auditGatewayLogs,
checkout: update.checkout,
@@ -1731,19 +2045,22 @@ export function maintainMain(options, dependencies = {}) {
gatewayRuntime = verifyGatewayRuntime(update.checkout, update.afterSha);
} else {
try {
verifyGateway(runCommand, update.checkout, update.afterSha, gatewayControlDeployment);
verifyGatewayProbe(runCommand, update.checkout, update.afterSha, gatewayControlDeployment);
gatewayRuntime = verifyGatewayRuntime(update.checkout, update.afterSha);
} catch {
actions.gatewayRestart = true;
actions.gatewaySelfHeal = true;
const bootstrap =
gatewayControlDeployment !== null && !isGatewayLoaded(gatewayControlDeployment);
const restartStartedAt = restartGateway(
runCommand,
update.checkout,
update.afterSha,
Date.now(),
gatewayControlDeployment,
bootstrap,
);
gatewayLogAudit = verifyAndAuditGateway({
gatewayLogAudit = verifyGatewayAfterRestart({
runCommand,
auditGatewayLogs,
checkout: update.checkout,
@@ -1780,7 +2097,7 @@ export function maintainMain(options, dependencies = {}) {
update.checkout,
);
const macTarget = verifyMacTarget(update.checkout);
verifyGateway(
verifyGatewayProbe(
runCommand,
update.checkout,
update.afterSha,
@@ -80,6 +80,8 @@ gh api -X POST "repos/openclaw/openclaw/issues/<number>/assignees" -f 'assignees
In generic issue/PR triage, hot queues, landable shortlists, or "what is still open", exclude PRs authored by maintainers with broad repository access until 14 days after `created_at`. Prefer external contributors' PRs. An ordinary request for landing candidates does not override the age gate. Continue suppressing maintainer-authored issues by default.
Treat live repository permission as the source of truth. Before surfacing a finalist whose access is not already known, check `gh api repos/openclaw/openclaw/collaborators/<login>/permission`; suppress write, maintain, or admin access even when the login is absent from the fast-path list below. Read or triage access alone does not trigger suppression unless the login is explicitly listed.
Suppress by default when the opener/author is one of:
- `@vincentkoc`
@@ -89,6 +91,8 @@ Suppress by default when the opener/author is one of:
- `@shakkernerd`
- `@mbelinky`
- `@joshavant`
- `@pgondhi987`
- `@mmaps`
- `@ngutman`
- `@vignesh07`
- `@huntharo`
+24 -21
View File
@@ -288,7 +288,8 @@ rerun after a focused patch.
### Full Release Validation
`Full Release Validation` (`.github/workflows/full-release-validation.yml`) is
the manual "everything before release" umbrella. It resolves a target ref, then
the manual product-validation umbrella. Run the full child matrix on the
product-complete pre-changelog **Code SHA**. It resolves a target ref, then
dispatches:
- manual `CI` for the full normal CI graph, with Android enabled via
@@ -300,30 +301,23 @@ dispatches:
Telegram release lanes
- optional post-publish Telegram E2E when a package spec is supplied
Run it only when validating an actual release candidate, after broad shared CI
or release orchestration changes, or when explicitly asked:
Run the full matrix only when validating an actual Code SHA, after broad shared
CI or release orchestration changes, or when explicitly asked:
```bash
gh workflow run full-release-validation.yml \
--repo openclaw/openclaw \
--ref main \
-f ref=<branch-or-sha> \
-f provider=openai \
-f mode=both \
-f release_profile=stable
node scripts/full-release-validation-at-sha.mjs \
--sha <code-sha> \
--target-ref release/YYYY.M.PATCH
```
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 a child
overview plus slowest-job tables for child runs; rerun only that verifier after
a child rerun turns green.
The helper pins the trusted workflow revision on current `main` while targeting
the historical release SHA and recording the canonical release branch as
context. It infers `beta` for alpha/beta package versions and `stable` for
stable/correction versions. Pass `-f release_profile=full` only for the broad
advisory provider/media sweep. Do not make `full` faster by silently dropping
suites; optimize setup, artifact reuse, and sharding instead. The parent
verifier job appends a child overview plus slowest-job tables for child runs;
rerun only that verifier after a child rerun turns green.
Standalone manual `CI` dispatches do not run the plugin prerelease suite, the
extension batch sweep, or the release-only `agentic-plugins` Vitest shard. Those
@@ -341,6 +335,15 @@ 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.
Once the Code SHA is green, generate and commit only `CHANGELOG.md`. The new
**Release SHA** is eligible for product-evidence reuse only when GitHub proves
that it is a descendant of the Code SHA and the complete changed path set is
exactly `CHANGELOG.md`. Dispatch the same SHA-pinned helper for the Release SHA;
the resulting parent records `changelog-only-release-v1` and reuses the Code
SHA children. Package, install/update, and release-note proof still runs on the
Release SHA because its tarball bytes changed. Any non-changelog path
invalidates reuse and requires a new Code SHA full matrix.
For bounded recovery after a focused fix, pass `-f rerun_group=<group>`.
Supported umbrella groups are `all`, `ci`, `plugin-prerelease`,
`release-checks`, `install-smoke`, `cross-os`, `live-e2e`, `package`, `qa`,
+91 -24
View File
@@ -10,6 +10,16 @@ Use this with `$release-openclaw-maintainer` and `$openclaw-testing` when a rele
## Guardrails
- No version bump, tag, npm publish, GitHub release, or release promotion without explicit operator approval.
- Hold the release scope once a release branch or Code SHA exists. Validate and
ship that exact release; do not turn moving `main` into a second work queue.
- Use trusted `main` workflow revisions as immutable dispatch sources. Do not
adopt newer main code, repair unrelated main CI, wait for broad main health,
or expand a release fix because the workflow source lives on `main`.
- Touch `main` only for an operator-requested change or the smallest critical
main-owned blocker that prevents this release and cannot be handled from the
release branch. If the required main landing policy is blocked by unrelated
main failures, report that blocker and keep independent release work moving
instead of healing broader main.
- Validate provider secrets before dispatching expensive full release matrices.
- Do not set GitHub secrets from unvalidated 1Password candidates. If a candidate returns 401/403, leave the existing secret alone and report the exact missing provider.
- Use `$one-password` for secret reads/writes: one persistent tmux session, targeted items only, no secret output.
@@ -24,6 +34,10 @@ Use this with `$release-openclaw-maintainer` and `$openclaw-testing` when a rele
fails, the parent cancels the remaining child matrix and prints the failed
job summary. Inspect that first red job instead of waiting for unrelated
matrix tails.
- Treat the product-complete pre-changelog commit as the Code SHA. Full product
validation and performance evidence bind to that SHA. The later Release SHA
may reuse those results only when it is a descendant whose complete changed
path set is exactly `CHANGELOG.md`.
- In a sparse worktree or Testbox source sync, first confirm `package.json`,
`pnpm-lock.yaml`, and every source path the selected check reads. If any are
absent, that checkout cannot validate a release dependency or Docker lane:
@@ -48,6 +62,30 @@ Use this with `$release-openclaw-maintainer` and `$openclaw-testing` when a rele
on source sync to overlay committed branch changes onto the workflow's
default ref.
## Run identity and retry budget
Record the target SHA, target ref, parent run id, attempt, and effective
workflow SHA before watching or recovering Full Release Validation.
- One target SHA has one active canonical `rerun_group=all` parent run by
default.
- Use GitHub's failed-job rerun on the same parent when its original inputs
still select the correct work.
- A new parent for the same target SHA is allowed only when no usable parent
exists, the existing run cannot consume a required trusted-workflow fix, its
evidence identity is invalid, or the operator explicitly requests a fresh
run. The replacement must also use `rerun_group=all`; record which terminal
parent it supersedes and why.
- A narrow `rerun_group` dispatch is supplemental diagnostic proof only. It
never supersedes the canonical parent and cannot satisfy publish evidence.
Run it only after the canonical parent is terminal, then obtain green
`rerun_group=all` evidence before publish.
- Never keep two parents active for the same SHA. Cancel only the superseded
task-owned run after the replacement is identified.
- After two unchanged retries of the same failure, stop repeating it. Recheck
classification and report one precise blocker or the missing evidence needed
for a different action.
## Preflight
Before full release validation:
@@ -68,14 +106,14 @@ non-billable credentials fail before the expensive release matrix.
## Dispatch
Start product performance evidence as early as the release SHA exists, in
Start product performance evidence as early as the Code SHA exists, in
parallel with other release work:
```bash
gh workflow run openclaw-performance.yml \
--repo openclaw/openclaw \
--ref main \
-f target_ref=<release-sha> \
-f target_ref=<code-sha> \
-f profile=release \
-f repeat=3 \
-f deep_profile=false \
@@ -93,33 +131,41 @@ gh workflow run openclaw-performance.yml \
early standalone run is for overlap and faster regression discovery, but a
regression or missing child run blocks the parent validation.
Prefer the trusted workflow on `main`, target the exact release SHA:
Prefer an immutable trusted-main workflow revision, target the exact Code SHA:
- Keep trusted-workflow checks compatible with frozen release targets. If
`main` adds a target-owned guard script or package command after the release
branch cut, make the trusted workflow skip only when that target surface is
absent. Heal the trusted workflow before rerunning validation; do not port an
unrelated runtime refactor or mutate the release candidate just to satisfy a
newer `main`-only check.
absent. Repair the smallest trusted-workflow compatibility issue only when it
blocks the release, then rerun validation. Do not port an unrelated runtime
refactor, heal other main failures, or mutate the release candidate just to
satisfy a newer `main`-only check.
```bash
gh workflow run full-release-validation.yml \
--repo openclaw/openclaw \
--ref main \
-f ref=<release-sha> \
-f provider=openai \
-f mode=both \
-f release_profile=full \
-f rerun_group=all
node scripts/full-release-validation-at-sha.mjs \
--sha <code-sha> \
--target-ref release/YYYY.M.PATCH
```
For immutable workflow proof on a moving `main`, use
`pnpm ci:full-release --sha <release-sha>`. Its canonical `release-ci/*` ref
keeps exact-target evidence reuse enabled after proving the workflow commit is
still on trusted `main` lineage. Pass `-f reuse_evidence=false` only when the
operator intentionally needs a fresh full run.
`pnpm ci:full-release --sha <code-sha> --target-ref
release/YYYY.M.PATCH`. Its canonical `release-ci/*` ref keeps evidence reuse
enabled after proving the workflow commit is still on trusted `main` lineage.
Pass `-f reuse_evidence=false` only when the operator intentionally needs a
fresh full run.
Use `release_profile=stable` unless the operator explicitly asks for the broad advisory provider/media matrix. Stable and full profiles force the release soak; the beta profile may opt in with `run_release_soak=true`. Use narrow `rerun_group` after focused fixes.
After the Code SHA is green, commit only `CHANGELOG.md` and run the same helper
against the Release SHA. The parent must report
`policy=changelog-only-release-v1`, `evidenceSha=<code-sha>`, and
`changedPaths=["CHANGELOG.md"]`; it should reuse the product matrix instead of
dispatching child lanes. Npm preflight and package/install acceptance still run
against the exact Release SHA and its new tarball bytes.
The SHA-pinned helper infers `beta` for alpha/beta package versions and `stable`
for stable/correction versions. Pass `release_profile=full` only when the
operator explicitly asks for the broad advisory provider/media matrix. Stable
and full profiles force the release soak; the beta profile may opt in with
`run_release_soak=true`. Use narrow `rerun_group` after focused fixes.
Publish with `openclaw-release-publish.yml` using `release_profile=from-validation`
unless a maintainer intentionally wants to cross-check a specific profile; the
publish workflow reads the effective profile from the full-validation manifest.
@@ -154,12 +200,31 @@ Stop watchers before ending the turn or switching strategy.
them in a clean-home CLI probe, never as a substitute for a required
Anthropic API-key lane.
5. For live-cache failures, inspect whether it is missing/invalid key, empty text, provider refusal, timeout, or baseline miss. Do not weaken release gates without clear provider evidence.
6. Fix narrowly, run local/changed proof, commit, push, rerun the smallest matching group.
6. Classify before editing:
- product/code failure: fix the release branch, freeze a new Code SHA, run
focused proof, then obtain green full validation for that new SHA
- workflow/harness/infrastructure/credential failure: fix the smallest
owning surface and rerun failed jobs on the current parent when its inputs
still select the correct work; otherwise supersede the terminal parent once
with a new `rerun_group=all` parent on the required trusted-workflow
revision. Keep the same Code SHA; touching `main` still requires the active
release scope lock
- changelog/release-note failure: change only `CHANGELOG.md`, keep Code SHA
evidence, and repeat Release SHA proof
- publish child/registry selector failure: keep Release SHA and resume the
failed child; never rebuild an immutable version that already published
7. If a required PR CI run is capacity-stalled with queued jobs and no active
jobs, do not cancel unrelated work or accept a generic manual dispatch.
From the PR head branch, dispatch the explicit exact-SHA fallback:
First check the target-owned workflow with `git show
<full-pr-sha>:.github/workflows/ci.yml | rg -q '^ +pr_number:'`. When it
declares `pr_number`, dispatch the explicit exact-SHA fallback:
`gh workflow run ci.yml --repo openclaw/openclaw --ref <pr-head-branch> -f
target_ref=<full-pr-sha> -f include_android=true -f release_gate=true`.
target_ref=<full-pr-sha> -f pr_number=<pr-number> -f include_android=true -f
release_gate=true`.
The workflow authenticates that PR's head/base, validates GitHub's current
synthetic merge tree, and runs the LOC task from that tree. Older workflow
schemas cannot provide equivalent LOC evidence; update the head to contain the current `pr_number` workflow, then
restart exact-head proof on the new SHA instead of dispatching them.
It runs on GitHub-hosted runners and is accepted only when its run title is
`CI release gate <full-pr-sha>`. Record the stalled Blacksmith run and the
fallback run in release evidence.
@@ -173,8 +238,10 @@ target_ref=<full-pr-sha> -f include_android=true -f release_gate=true`.
Record:
- release SHA
- full parent run URL
- Code SHA and Release SHA
- evidence-reuse policy and complete changed-path set
- active full parent run URL, attempt, workflow SHA, and any superseded parent
with the exact replacement reason
- child run IDs and conclusions: CI, Release Checks, Plugin Prerelease, NPM Telegram, Product Performance
- performance comparison result versus earlier releases when available
- targeted local proof commands
@@ -1,4 +1,4 @@
interface:
display_name: "OpenClaw Release CI"
short_description: "Verify and debug OpenClaw release validation runs"
default_prompt: "Use $release-openclaw-ci to preflight provider secrets, watch full release validation, summarize child runs, and triage only failing release lanes."
default_prompt: "Use $release-openclaw-ci to validate one frozen release SHA with one active parent run, recover only the smallest failed lane, and stop on a precise external blocker."
@@ -6,6 +6,8 @@
- GitHub secret presence was confused with key validity.
- Repeated `gh run view` and log fetches exhausted REST quota.
- Parent run state was less useful than child run evidence.
- Replacement parent runs were dispatched while an existing parent was still
recoverable, multiplying polling, cancellation, and identity checks.
- Live-cache failures needed structured classification: invalid key, empty provider output, timeout, or real cache regression.
- Background watchers accumulated and made interruption recovery harder.
@@ -14,7 +16,16 @@
- Run provider-secret preflight first. Require real `/models` or equivalent endpoint checks for release-blocking providers.
- Keep one watcher open. Use child summaries every few minutes, not every few seconds.
- Fetch failed-job logs only after a job reaches a terminal failing state.
- Prefer narrow `rerun_group` recovery after a focused fix.
- Prefer same-parent failed-job reruns when the original inputs still select the
right work.
- Keep one active parent per target SHA. Create a replacement only when the
existing parent cannot consume a required workflow fix or its evidence
identity is invalid; record the superseded run and reason. The replacement
remains `rerun_group=all`.
- Treat narrow `rerun_group` dispatches as supplemental diagnostics only; they
do not satisfy publish evidence.
- Stop after two unchanged retries and report the exact blocker instead of
creating another verification loop.
- Leave bad secrets unset. A 401 candidate from 1Password should not overwrite GitHub.
- Make the final release evidence note durable: parent URL, child run URLs, SHA, command proof, and gaps.
@@ -64,6 +64,11 @@ Do not update these from mixed sources. All three ASC fields must come from the
## Dispatch
The public handoff workflow validates the tag, source, build, and package
metadata before publication. It does not require a GitHub release page because
it does not upload assets. Keep this validation before the real publish
workflow; the publisher owns draft creation and final undraft.
Public handoff validation:
```bash
@@ -11,6 +11,25 @@ approved backport set for an extended-stable maintenance release. Load
locators or private host topology. Keep ordinary development changes and
GHSA-specific advisory work outside this skill.
## Freeze the release state
Before validation or publication, write one compact state record and keep it
current:
- goal and terminal success criteria
- release version, tag, branch, cut SHA, Code SHA, and Release SHA
- active Full Release Validation parent run id and attempt
- npm preflight and publish parent run ids
- completed phases and immutable child artifacts
- approved backports or main changes
- current phase, next action, and one precise blocker if stopped
Use `references/release-handoff-template.md` when starting a release session,
recovering after compaction, or handing the release to another operator.
Completed phases stay complete. Reopen one only when a named event invalidates
its evidence, such as a Code SHA change, a non-changelog Release SHA change, or
a workflow fix that the existing parent run cannot consume.
## Respect release guardrails
- Do not change version numbers without explicit operator approval.
@@ -31,18 +50,51 @@ GHSA-specific advisory work outside this skill.
user-facing documentation contract changed.
- Normal release work happens on a branch cut from `main`, not directly on
`main`. Use `release/YYYY.M.PATCH` for the branch name.
- Hold release scope from cut-SHA selection through publish and verification.
The active release is the work queue; moving `main` is only a trusted
workflow and provenance source unless the operator explicitly requests main
work.
- Touch `main` during the active release only for an operator-requested change
or a critical main-owned blocker that prevents this release and cannot be
fixed or proven from the release branch. Examples include a live installer or
trusted workflow that is sourced only from `main`. A red unrelated `main`
check, baseline, refactor, cleanup, or later improvement is not a release
blocker.
- Do not broaden a release-critical main fix to make moving `main` green. Keep
the change to the exact blocker, run focused proof, follow the required main
landing policy, then return immediately to the release branch. If unrelated
main health blocks that landing, report the blocker and continue independent
release work instead of adopting the failure.
- Defer normal forward-ports and main closeout until after publication.
Forward-port before publish only when the operator requests it or `main`
itself owns the exact release-critical runtime or workflow surface.
- If the operator asks for a release without saying stable/full, default to
beta only. Continue from beta to stable only when the operator explicitly asks
for the full release or an automated beta-and-stable train.
- Before release branching, pull latest `main` and confirm current `main` CI is
green. Then branch from that commit so regular development can continue on
`main` while release validation runs.
- Before release branching, commit any dirty files in coherent groups, push,
pull/rebase, and create the release branch from the intended `main` head.
Finish version preparation, backports, release-only fixes, and their required
forward-ports before generating `CHANGELOG.md`. The changelog rewrite is the
final candidate source mutation; freeze the candidate SHA immediately after
it and do not restart completed evidence for metadata-only reruns.
- Resolve the intended cut SHA once. If the operator supplies a SHA, use it
exactly; do not pull, rebase, or advance it to newer `main`. Otherwise fetch
`origin/main` once and record the selected full SHA plus its CI state. A red
unrelated main check does not authorize healing main.
- Create a clean release worktree and `release/YYYY.M.PATCH` from that selected
SHA. Do not commit or absorb unrelated dirty files as release preparation.
Finish version preparation plus any operator-selected backports,
release-only fixes, and explicitly required pre-publish main changes.
Backports are optional.
Freeze this product-complete tree as the **Code SHA** without changing the
release changelog.
- Full product validation belongs to the Code SHA. If validation finds a code
defect, fix it, freeze a new Code SHA, and validate that SHA. If the failure
belongs to trusted workflow tooling, the harness, credentials, or
infrastructure, repair the smallest owning surface and rerun against the same
Code SHA. Touch `main` only under the active release scope lock above. Never
mutate the release candidate to satisfy newer tooling or heal unrelated main.
- Generate `CHANGELOG.md` only after the Code SHA is green. The resulting
**Release SHA** must be a descendant whose complete diff from the Code SHA is
exactly `CHANGELOG.md`. Release-note checks, npm preflight/package bytes,
install/update acceptance, tagging, and publication run against the Release
SHA. Full product validation is reused through the
`changelog-only-release-v1` evidence policy; any non-changelog source change
returns to the Code SHA loop.
- During release planning, inspect both `src/plugins/compat/registry.ts` and
`src/commands/doctor/shared/deprecation-compat.ts` before branching and again
before final publish. For every deprecated or removal-pending compatibility
@@ -66,23 +118,21 @@ GHSA-specific advisory work outside this skill.
the next beta number until the matching npm package has actually published.
If a published beta needs a fix, commit the fix on the release branch and
increment to the next `-beta.N`.
- For a beta release train, keep Full Release Validation as a pre-publish gate
unless the operator explicitly waives it. Run the fast local preflight, npm
preflight, full release validation, and performance in parallel where safe.
If anything fails before npm publish, fix it on the release branch,
forward-port the fix to `main`, move the unpublished beta tag/prerelease to
the fixed commit, and rerun the affected pre-publish gates. If anything fails
after npm publish, fix it, forward-port to `main`, increment beta number, and
repeat. After each beta publish, run the published-package roster focused on
install/update/Docker/Parallels/NPM Telegram. For later beta attempts, rerun
only lanes whose evidence changed unless the fix touches broad release,
install/update, plugin, Docker, Parallels, or live QA behavior. After each
beta is live, scan current `main` once for critical fixes that landed after
the release branch cut and backport only important low-risk fixes. Operators
may authorize up to 4 autonomous beta attempts; after 4 failed beta attempts,
stop and report.
- As soon as the release candidate SHA exists, dispatch `OpenClaw Performance`
with `target_ref=<release-sha>` in parallel with the other release work. Do
- For a beta release train, keep Full Release Validation as a pre-publish Code
SHA gate unless the operator explicitly waives it. Run independent validation
lanes in parallel where safe, but do not start changelog or package
finalization until the Code SHA is green. After the changelog-only Release SHA
exists, run npm preflight and the package/install/update acceptance roster
against its exact bytes. If a product defect appears, return to a new Code
SHA; if a release-tooling or publication child fails, repair/resume that child
without changing the candidate. After a published beta needs a code fix,
increment the beta number and repeat. Defer its forward-port until after
publication unless the operator requests it. Do not scan moving `main` for
extra fixes during an active release unless the operator explicitly asks for
that audit. Operators may authorize up to 4 autonomous beta attempts; after
4 failed beta attempts, stop and report.
- As soon as the Code SHA exists, dispatch `OpenClaw Performance`
with `target_ref=<code-sha>` in parallel with the other release work. Do
not wait for full release validation to start the performance signal.
- Before publish/closeout, compare available product performance metrics with
earlier releases: Kova agent-turn/resource metrics, gateway startup
@@ -90,11 +140,13 @@ GHSA-specific advisory work outside this skill.
or clawgrit reports. Report regressions explicitly. A major regression is a
release blocker unless the operator waives it or the data clearly proves
infrastructure noise.
- Heal CI before tagging or publishing. The exact candidate SHA must have green
`Full Release Validation`, including the root Dockerfile/install-smoke path.
- Heal release-owned CI before changelog, tagging, or publishing. The exact
Code SHA must have green `Full Release Validation`, including the root
Dockerfile/install-smoke path.
Treat a red Docker, package, or release workflow lane as a release-branch
defect until the smallest correct fix is landed and proven; do not waive it
because npm preflight or another sibling lane passed.
because npm preflight or another sibling lane passed. Unrelated moving-main
failures are not part of this gate.
- Keep the canonical `scripts/pr` runner authoritative for prepare and merge
artifacts. A release-gate policy change may use focused candidate tests and
exact-SHA hosted CI for proof, but never route `prepare-*` or `merge-*`
@@ -115,9 +167,16 @@ GHSA-specific advisory work outside this skill.
gates on the newly pushed SHA, then run `prepare-run` again.
- If an exact PR-head CI run has no active jobs because Blacksmith capacity is
stalled, a maintainer may dispatch the explicit GitHub-hosted fallback from
the PR head branch:
the PR head branch. Check the target-owned workflow with `git show
<full-pr-sha>:.github/workflows/ci.yml | rg -q '^ +pr_number:'`. When it
declares `pr_number`, run:
`gh workflow run ci.yml --repo openclaw/openclaw --ref <pr-head-branch> -f
target_ref=<full-pr-sha> -f include_android=true -f release_gate=true`.
target_ref=<full-pr-sha> -f pr_number=<pr-number> -f include_android=true -f
release_gate=true`.
The workflow authenticates that PR's head/base, validates GitHub's current
synthetic merge tree, and runs the LOC task from that tree. Older workflow
schemas cannot provide equivalent LOC evidence; update the head to contain the current `pr_number` workflow, then
restart exact-head proof on the new SHA instead of dispatching them.
Use it only for an observed provider queue stall, never for failed CI or as a
routine shortcut. The run must be named `CI release gate <full-pr-sha>` and
pass on that exact SHA; the native hosted-gate verifier rejects generic manual
@@ -128,8 +187,10 @@ target_ref=<full-pr-sha> -f include_android=true -f release_gate=true`.
use this coverage when the artifact workflow has started, failed, been
cancelled, or been skipped. Then rerun `OPENCLAW_TESTBOX=1 scripts/pr
prepare-run <PR>`.
- Generate the changelog before every beta, beta rerun, stable release, or
stable rerun, after version preparation and all source/backport work. Use
- Generate the changelog once after the final Code SHA is fully green. Do not
regenerate it for same-candidate tooling reruns, resumed publication, or
promotion. If code changes, validate the replacement Code SHA first and then
regenerate the release section once for that new history. Use
`$openclaw-changelog-update` for the rewrite. Do not continue release prep if
the target `CHANGELOG.md` section does not have `### Highlights`,
`### Changes`, and `### Fixes`, grouped by user-facing surface while
@@ -215,7 +276,9 @@ on pinned current `main` as the exact command and validation contract.
## Close stable releases on main
Stable publication is not complete until `main` carries the actual shipped release state.
This gate starts only after stable publication. It is a narrow shipped-state
closeout, not permission to heal broader `main`. Stable publication is not
complete until `main` carries the actual shipped release state.
1. Start from fresh latest `main`. Audit `release/YYYY.M.PATCH` against it and
forward-port real fixes that are absent from `main`. Do not blindly merge
@@ -330,19 +393,19 @@ HEAD/worktree-bound manifest under git metadata for cutover review.
- `CHANGELOG.md` is release-owned. Normal PRs and direct `main` fixes should
not edit it.
- After release preparation and all intended backports/fixes are complete,
rewrite the target `CHANGELOG.md` section from history, not existing notes.
Use the last reachable stable or beta release tag as the base, then inspect
every commit through the target release SHA. This rewrite is the final source
task before freezing the candidate SHA and starting or reusing evidence.
- After the product-complete Code SHA passes Full Release Validation, rewrite
the target `CHANGELOG.md` section from history, not existing notes. Use the
last reachable stable or beta release tag as the base, then inspect every
commit through the Code SHA. This is the one release-note mutation that
creates the Release SHA.
- Generate `$openclaw-changelog-update`'s full contribution manifest before
the editorial rewrite. It is the required source for `### Highlights`,
`### Changes`, and `### Fixes`; do not preserve old grouped prose without
comparing it to the manifest's PRs, contributors, direct commits, and
unlinked commits.
- The changelog rewrite is not optional for beta reruns: any `beta.N` after a
rebase or backport must refresh the same stable-base `## YYYY.M.PATCH` section
after the new version/backport work and before the candidate SHA freezes.
- A same-Code-SHA retry reuses the existing changelog. A new beta containing
code changes must first validate its replacement Code SHA, then refresh the
same stable-base `## YYYY.M.PATCH` section once.
- Always fetch and pass current `origin/main` as the canonical main ref.
Equivalent release/backport PRs are omitted in favor of the original merged
main PR. A release-branch PR remains only until that change is forward-ported.
@@ -547,16 +610,18 @@ pnpm test:install:smoke
package-local runtime, and pass the npm and ClawHub release metadata checks
before a tag or publish workflow can start. Do not defer README, entrypoint,
or packed-artifact failures to postpublish verification.
- Before tagging, require green CI for the exact release-candidate SHA, not an
earlier branch SHA. Heal every related red CI, release-check, packaging, or
root-Dockerfile lane on the release branch, forward-port the fix to `main`,
and rerun the affected exact-SHA gates. Never waive a red Docker lane because
npm preflight passed.
- Root Dockerfile proof is mandatory before every beta and stable tag. Run the
release `install-smoke` group or equivalent root Dockerfile build for the
exact candidate SHA and require it to pass. The tag-triggered Docker Release
workflow is post-tag publishing, not the first valid proof that the root
Dockerfile can build.
- Before generating the changelog, require green CI for the exact Code SHA, not
an earlier branch SHA. Heal every related red CI, release-check, packaging,
or root-Dockerfile lane on the release branch and rerun the affected exact-SHA
gates. Defer product-fix forward-ports until after publication unless the
active release scope lock requires an exact main-owned blocker fix. Never
waive a red Docker lane because npm preflight passed.
- Root Dockerfile proof is mandatory on the Code SHA before every beta and
stable tag. The changelog-only Release SHA reuses that product proof, while
exact Release SHA npm preflight and package/install acceptance prove the
changed package bytes. The tag-triggered Docker Release workflow is
post-tag publishing, not the first valid proof that the root Dockerfile can
build.
- Before tagging, diff publishable plugin package manifests against the last
reachable stable/beta release tag. For every newly publishable package
(`openclaw.release.publishToNpm: true` or `publishToClawHub: true`) whose
@@ -902,70 +967,77 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
publish, or complete the named release as continuing authorization through
publish and verification. Reconfirm only if the release identity, channel,
publish scope, or material risk changes.
2. Choose the exact target version and git tag.
3. Commit any dirty files in coherent groups, push, pull/rebase, and verify the
worktree is clean.
4. Pull latest `main` and confirm current `main` CI is green.
5. Create `release/YYYY.M.PATCH` from the intended `main` commit.
6. Make every repo version location match the beta tag and finish all intended
backports or release-only fixes. Forward-port fixes to `main` where required.
7. Run `/changelog` for the stable base target version on the release branch as
the final candidate source task, using current `origin/main` for canonical PR
provenance. Keep the heading as `## YYYY.M.PATCH`, not
`## YYYY.M.PATCH-beta.N`.
8. Commit the changelog rewrite, push the release branch, and freeze that exact
candidate SHA. Any later source change must finish first, then rerun only
this final changelog step before freezing the replacement candidate.
9. Immediately dispatch Actions > `OpenClaw Performance` from `main` with
`target_ref=<release-sha>`, `profile=release`, `repeat=3`, deep profiling
2. Choose the exact target version, git tag, and npm dist-tag (`beta` or
`latest`).
3. Start from a clean release worktree. Do not absorb unrelated dirty files.
4. Resolve the cut SHA once. Use an operator-supplied SHA exactly; otherwise
fetch `origin/main` once, record its full SHA and CI state, and do not chase a
newer moving head.
5. Create `release/YYYY.M.PATCH` from that selected commit.
6. Make every repo version location match the beta tag. Apply only explicitly
selected backports or release fixes. Make a pre-publish main change only
under the active release scope lock. Freeze the result as the Code SHA.
7. Immediately dispatch Actions > `OpenClaw Performance` from the pinned
trusted workflow source with `target_ref=<code-sha>`, `profile=release`,
`repeat=3`, deep profiling
off, live OpenAI off, and regression failure off. Let it run in parallel
with preflight and validation work.
10. Run the fast local beta preflight from the release branch before any npm
preflight or publish. Require exact-SHA CI and root Dockerfile install-smoke
to be green before tagging. Keep the remaining expensive Docker, Parallels,
and published-package install/update lanes for after the beta is live unless
the operator asks to run them before beta publication.
11. For beta releases, skip mac app build/sign/notarize unless beta scope or a
with Code SHA validation.
8. Run the deterministic source preflight, then Full Release Validation against
the exact Code SHA with
`node scripts/full-release-validation-at-sha.mjs --sha <code-sha> --target-ref release/YYYY.M.PATCH`.
Use one transition watcher. Product failures return to step 6 with a new
Code SHA; tooling/harness failures are fixed separately and rerun against the
same Code SHA.
9. After the Code SHA is green, run `/changelog` once for the stable-base target
version using current `origin/main` for canonical PR provenance. Keep the
heading as `## YYYY.M.PATCH`, not `## YYYY.M.PATCH-beta.N`.
10. Commit only `CHANGELOG.md` and freeze the Release SHA. Verify
`<code-sha>..<release-sha>` changes exactly `CHANGELOG.md`; any other path
returns to step 6.
11. Dispatch Full Release Validation for the Release SHA with evidence reuse
enabled. It must select `changelog-only-release-v1`, reuse the green Code SHA
product matrix, and run no product lanes again.
12. Run npm preflight and release-note/package/install/update acceptance against
the exact Release SHA and prepared tarball. A package or install failure that
exposes a product defect returns to step 6; a tooling failure keeps the
Release SHA unchanged.
13. For beta releases, skip mac app build/sign/notarize unless beta scope or a
release blocker specifically requires it. For stable releases, include the
mac app, signing, notarization, and appcast path.
12. Confirm the target npm version is not already published.
13. Create and push the git tag from the release branch.
14. Do not create or publish the matching GitHub release page yet. The real
14. Confirm the target npm version is not already published.
15. Create and push the git tag from the Release SHA.
16. Do not create or publish the matching GitHub release page yet. The real
publish workflow creates or undrafts it only after postpublish verification
and release evidence upload pass.
15. Dispatch Actions > `QA-Lab - All Lanes` against the release tag and wait
for the mock parity, live Matrix, and live Telegram credentialed-channel
lanes to pass.
16. Start `.github/workflows/openclaw-npm-release.yml` from the release branch
with `preflight_only=true`
and choose the intended `npm_dist_tag` (`beta` default; `latest` only for
an intentional direct stable publish). Wait for it to pass. Save that run id
because the real publish requires it to reuse the prepared npm tarball.
17. Before real publish, review the early performance run if it has completed.
17. Run `pnpm release:candidate -- --tag <tag> --full-release-run
<release-sha-validation-run-id> --npm-preflight-run <preflight-run-id>
--skip-dispatch` to consume the existing reused full evidence and exact
Release SHA preflight instead of dispatching either again. It completes
package/install proof and prints the publish command.
18. Start publication only after the candidate bundle is green. Reuse successful
immutable child runs/artifacts on retry; do not rebuild or republish versions
that already succeeded.
19. Before real publish, review the early performance run if it has completed.
Compare against earlier release evidence or clawgrit reports where
available. Call out minor regressions in the release proof; block on major
regressions unless waived or proven noisy.
18. For stable releases, start `.github/workflows/macos-release.yml` in
20. For stable releases, start `.github/workflows/macos-release.yml` in
`openclaw/openclaw` and wait for the public validation-only run to pass.
19. For stable releases, start
21. For stable releases, start
`openclaw/releases/.github/workflows/openclaw-macos-validate.yml` with the
same tag and wait for the release-ops mac validation lane to pass.
20. For stable releases, start
22. For stable releases, start
`openclaw/releases/.github/workflows/openclaw-macos-publish.yml` with
`preflight_only=true` and wait for it to pass. Save that run id because the
real publish requires it to reuse the notarized mac artifacts.
21. If any preflight or validation run fails, fix the issue on a new commit,
delete the tag and any accidental draft/incomplete GitHub release, recreate
the tag from the fixed commit, and rerun all relevant preflights from
scratch before continuing. Never reuse old preflight results after the
commit changes. Once the npm version exists, do not rerun the publish
workflow for that same version; finalize the existing draft/evidence state
manually or cut a correction tag. For pushed or published beta tags, do not
delete/recreate; increment to the next beta tag. For preflight-only failures
where npm did not publish the beta version, delete/recreate the same beta
tag and any accidental draft/incomplete prerelease at the fixed commit
instead of skipping a prerelease number.
22. Start `.github/workflows/openclaw-release-publish.yml` from trusted `main`
23. Classify every failure before changing git state. Product defects return to
step 6 and invalidate downstream Code/Release SHA evidence. Changelog or
release-note defects change only the Release SHA and reuse the green Code
SHA evidence after the exact delta is reverified. Tooling, credential,
approval, registry selector, or publication-child failures keep the
candidate unchanged and resume the smallest failed surface.
24. Start `.github/workflows/openclaw-release-publish.yml` from the exact pinned
trusted workflow source
with the same tag for the real beta or stable publish, choose `npm_dist_tag` (`beta` default,
`latest` only when you intentionally want direct stable publish), keep it
the same as the preflight run, and pass the successful npm
@@ -974,8 +1046,8 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
For stable publish, also pass the exact non-prerelease
`openclaw/openclaw-windows-node` tag as `windows_node_tag` and its
candidate-approved installer digest map as `windows_node_installer_digests`.
23. Wait for `npm-release` approval from `@openclaw/openclaw-release-managers`.
24. Wait for the real publish workflow to run postpublish verification,
25. Wait for `npm-release` approval from `@openclaw/openclaw-release-managers`.
26. Wait for the real publish workflow to run postpublish verification,
create or update the GitHub release as a draft, upload dependency evidence,
promote and verify the required Windows Hub assets for stable releases,
append release verification proof, and only then undraft/publish it. If a
@@ -989,10 +1061,10 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
with the original child run IDs and an evidence output path before manually
recreating the workflow's draft, dependency evidence asset, proof section,
and publish step.
25. Run the post-published beta verification roster. First scan current `main`
for critical fixes that landed after the release branch cut; backport only
important low-risk fixes before starting expensive lanes, or increment to
the next beta if the fix must change the already-published package. If any
27. Run the post-published beta verification roster. Do not scan current `main`
for extra fixes unless the operator explicitly requests a backport audit.
Apply only operator-selected backports, and increment to the next beta if a
selected fix must change the already-published package. If any
lane fails after the beta package is published, fix, commit/push/pull,
increment to the next beta tag, and rerun the affected beta evidence. Once
the beta is live, start remote/manual rosters where they
@@ -1003,10 +1075,10 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
If a pre-npm lane fails before any tag/package leaves the machine, fix and
rerun the same intended beta attempt. Repeat up to the operator's
authorized beta-attempt limit, normally 4.
26. Announce the beta/stable release on Discord best-effort using the configured secret workflow.
27. If the operator requested beta only, stop after beta verification and the
28. Announce the beta/stable release on Discord best-effort using the configured secret workflow.
29. If the operator requested beta only, stop after beta verification and the
announcement.
28. If the stable release was published to `beta`, use the light stable
30. If the stable release was published to `beta`, use the light stable
promotion roster when the matching beta already carried the full confidence
pass: published npm postpublish verify, Docker install/update smoke,
macOS-only Parallels install/update smoke, and required QA signal.
@@ -1014,25 +1086,25 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
`openclaw/releases/.github/workflows/openclaw-npm-dist-tags.yml` workflow
to promote that stable version from `beta` to `latest`, then verify
`latest` now points at that version.
29. If the stable release was published directly to `latest` and `beta` should
31. If the stable release was published directly to `latest` and `beta` should
follow it, start that same release-ops dist-tag workflow to point `beta` at
the stable version, then verify both `latest` and `beta` point at that
version.
30. For stable releases, start
32. For stable releases, start
`openclaw/releases/.github/workflows/openclaw-macos-publish.yml` for the
real publish with the successful release-ops mac `preflight_run_id` and wait
for success.
31. Verify the successful real release-ops mac run uploaded the `.zip`, `.dmg`,
33. Verify the successful real release-ops mac run uploaded the `.zip`, `.dmg`,
and `.dSYM.zip` artifacts to the existing GitHub release in
`openclaw/openclaw`.
32. For stable releases, download `macos-appcast-<tag>` from the successful
34. For stable releases, download `macos-appcast-<tag>` from the successful
release-ops mac run, update `appcast.xml` on `main`, verify the feed, then
complete the **Close stable releases on main** gate.
33. For beta releases, publish the mac assets only when intentionally requested;
35. For beta releases, publish the mac assets only when intentionally requested;
expect no shared production
`appcast.xml` artifact and do not update the shared production feed unless a
separate beta feed exists.
34. After stable main closeout, verify npm and the attached release artifacts.
36. After stable main closeout, verify npm and the attached release artifacts.
## GHSA advisory work
@@ -0,0 +1,52 @@
# Release Handoff
Use this compact record to start or resume one release session. Replace every
placeholder with current live state. Omit completed detail that is already
captured by a durable run or artifact URL.
## Goal
Ship `<version>` on `<channel>` and stop when `<terminal success criteria>`.
## Immutable state
- branch: `release/<YYYY.M.PATCH>`
- cut SHA: `<full sha>`
- Code SHA: `<full sha or not frozen>`
- Release SHA: `<full sha or not frozen>`
- tag: `v<version>`
- approved backports: `<none or exact PRs/commits>`
- approved main changes: `<none or exact blocker>`
## Active evidence
- Full Release Validation parent: `<run id / attempt / URL or none>`
- npm preflight: `<run id / URL or none>`
- publish parent: `<run id / URL or none>`
- immutable successful children: `<run ids / artifacts or none>`
## Phase
- completed: `<phases that stay complete>`
- current: `<one phase>`
- next action: `<one concrete action>`
## Failure policy
- product/code failure: fix the release branch, freeze a new Code SHA, and
invalidate downstream product evidence
- changelog-only failure: change only `CHANGELOG.md`, freeze a new Release SHA,
and reuse green Code SHA evidence after delta proof
- workflow/tooling/credential failure: keep the candidate frozen and recover
the smallest owning surface
- external approval or permission blocker: stop with the exact job, URL,
missing permission, and required operator action
Do not scan moving `main`, add optional backports, dispatch a replacement
validation parent, or repeat completed phases unless a named invalidating event
requires it.
## Stop conditions
- success: `<exact published and verified state>`
- blocked: `<one precise external action that only the operator can complete>`
@@ -10,7 +10,11 @@ openclaw_node_version_matches() {
*x)
[[ "${actual%%.*}" == "${requested%%.*}" ]] || return 1
if [[ "${requested%%.*}" == "22" ]]; then
openclaw_node_version_at_least "$actual" "22.19.0"
openclaw_node_version_at_least "$actual" "22.22.3"
elif [[ "${requested%%.*}" == "24" ]]; then
openclaw_node_version_at_least "$actual" "24.15.0"
elif [[ "${requested%%.*}" == "25" ]]; then
openclaw_node_version_at_least "$actual" "25.9.0"
fi
;;
*.*.*)
+1 -1
View File
@@ -4,7 +4,7 @@
## Tech Stack
- **Runtime**: Node 22+ (Bun also supported for dev/scripts)
- **Runtime**: Node 22.22.3+, 24.15+, or 25.9+ (Bun also supported for dev/scripts)
- **Language**: TypeScript (ESM, strict mode)
- **Package Manager**: pnpm (keep `pnpm-lock.yaml` in sync)
- **Lint/Format**: Oxlint, Oxfmt (`pnpm check`)
+20
View File
@@ -25,6 +25,11 @@
- any-glob-to-any-file:
- "extensions/irc/**"
- "docs/channels/irc.md"
"channel: reef":
- changed-files:
- any-glob-to-any-file:
- "extensions/reef/**"
- "docs/channels/reef.md"
"channel: feishu":
- changed-files:
- any-glob-to-any-file:
@@ -68,6 +73,10 @@
- "extensions/migrate-claude/**"
- "docs/cli/migrate.md"
- "docs/install/migrating-claude.md"
"plugin: mxc":
- changed-files:
- any-glob-to-any-file:
- "extensions/mxc/**"
"plugin: bonjour":
- changed-files:
- any-glob-to-any-file:
@@ -207,6 +216,11 @@
- "apps/macos/**"
- "docs/platforms/macos.md"
- "docs/platforms/mac/**"
"app: linux":
- changed-files:
- any-glob-to-any-file:
- "apps/linux/**"
- "docs/platforms/linux.md"
"app: web-ui":
- changed-files:
- any-glob-to-any-file:
@@ -354,6 +368,12 @@
- "extensions/vault/**"
- "docs/plugins/vault.md"
- "docs/plugins/reference/vault.md"
"extensions: onepassword":
- changed-files:
- any-glob-to-any-file:
- "extensions/onepassword/**"
- "docs/plugins/onepassword.md"
- "docs/plugins/reference/onepassword.md"
"extensions: webhooks":
- changed-files:
- any-glob-to-any-file:
+396 -67
View File
@@ -8,6 +8,16 @@ on:
required: false
default: ""
type: string
pr_number:
description: Pull request number required by the exact-SHA release gate
required: false
default: ""
type: string
loc_base_ref:
description: Optional exact LOC comparison-base SHA for standalone manual runs
required: false
default: ""
type: string
include_android:
description: Run Android lanes for this manual CI dispatch.
required: false
@@ -81,12 +91,15 @@ jobs:
preflight:
permissions:
contents: read
pull-requests: read
needs: [runner-admission]
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
timeout-minutes: 20
outputs:
checkout_revision: ${{ steps.checkout_ref.outputs.sha }}
loc_base_sha: ${{ steps.release_gate_loc_tree.outputs.base_sha || inputs.loc_base_ref || '' }}
loc_head_sha: ${{ steps.release_gate_loc_tree.outputs.head_sha || '' }}
docs_only: ${{ steps.manifest.outputs.docs_only }}
docs_changed: ${{ steps.manifest.outputs.docs_changed }}
run_node: ${{ steps.manifest.outputs.run_node }}
@@ -130,6 +143,7 @@ jobs:
- name: Validate release-gate dispatch
if: github.event_name == 'workflow_dispatch' && inputs.release_gate
env:
PR_NUMBER: ${{ inputs.pr_number }}
TARGET_REF: ${{ inputs.target_ref }}
run: |
set -euo pipefail
@@ -139,11 +153,27 @@ jobs:
exit 1
fi
if [[ ! "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]]; then
echo "release_gate requires pr_number to identify an open pull request" >&2
exit 1
fi
if [[ "$GITHUB_SHA" != "$TARGET_REF" ]]; then
echo "release_gate must run from the branch at target_ref" >&2
exit 1
fi
- name: Validate manual LOC base input
if: github.event_name == 'workflow_dispatch' && inputs.loc_base_ref != ''
env:
LOC_BASE_REF: ${{ inputs.loc_base_ref }}
run: |
set -euo pipefail
if [[ ! "$LOC_BASE_REF" =~ ^[0-9a-f]{40}$ ]]; then
echo "loc_base_ref must be a full commit SHA" >&2
exit 1
fi
- name: Checkout
env:
CHECKOUT_REPO: ${{ github.repository }}
@@ -177,8 +207,8 @@ jobs:
}
# Manual release-gate runs commonly validate the workflow ref's exact SHA.
# Fetch the branch/tag ref first so GitHub does not have to negotiate an
# arbitrary SHA fetch, then verify the resolved commit stayed pinned.
# Fetch the branch/tag ref first, then refetch the SHA if that ref moves
# while the manual run waits for a runner.
checkout_ref="$CHECKOUT_REF"
requested_sha=""
if [[ "$CHECKOUT_REF" =~ ^[0-9a-f]{40}$ ]]; then
@@ -209,6 +239,12 @@ jobs:
if [ -n "$requested_sha" ]; then
resolved_sha="$(git -C "$GITHUB_WORKSPACE" rev-parse refs/remotes/origin/checkout)"
if [ "$resolved_sha" != "$requested_sha" ] && [ "$checkout_ref" != "$requested_sha" ]; then
echo "::notice::checkout ref '$checkout_ref' moved to '$resolved_sha'; fetching requested SHA '$requested_sha'"
checkout_ref="$requested_sha"
fetch_checkout_ref "$checkout_ref"
resolved_sha="$(git -C "$GITHUB_WORKSPACE" rev-parse refs/remotes/origin/checkout)"
fi
if [ "$resolved_sha" != "$requested_sha" ]; then
echo "::error::checkout ref '$checkout_ref' resolved to '$resolved_sha'," \
"expected '$requested_sha'" >&2
@@ -221,6 +257,67 @@ jobs:
id: checkout_ref
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Validate release-gate PR merge tree
id: release_gate_loc_tree
if: github.event_name == 'workflow_dispatch' && inputs.release_gate
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ inputs.pr_number }}
TARGET_REF: ${{ inputs.target_ref }}
run: |
set -euo pipefail
merge_sha=""
pr_base_sha=""
for attempt in {1..12}; do
pr_json="$(gh api --method GET "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}")"
pr_state="$(jq -r '.state // ""' <<<"$pr_json")"
pr_head_sha="$(jq -r '.head.sha // ""' <<<"$pr_json")"
pr_base_sha="$(jq -r '.base.sha // ""' <<<"$pr_json")"
pr_base_repo="$(jq -r '.base.repo.full_name // ""' <<<"$pr_json")"
if [[ "$pr_state" != "open" || "$pr_base_repo" != "$GITHUB_REPOSITORY" ]]; then
echo "release_gate pr_number must identify an open pull request in ${GITHUB_REPOSITORY}" >&2
exit 1
fi
if [[ "$pr_head_sha" != "$TARGET_REF" ]]; then
echo "release_gate target_ref must match pull request ${PR_NUMBER} head ${pr_head_sha}" >&2
exit 1
fi
if [[ ! "$pr_base_sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "Could not resolve pull request ${PR_NUMBER} base SHA." >&2
exit 1
fi
mergeable="$(jq -r 'if .mergeable == null then "pending" else (.mergeable | tostring) end' <<<"$pr_json")"
if [[ "$mergeable" == "true" ]]; then
merge_sha="$(jq -r '.merge_commit_sha // ""' <<<"$pr_json")"
if [[ ! "$merge_sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "Could not resolve pull request ${PR_NUMBER} test merge commit." >&2
exit 1
fi
break
fi
if [[ "$mergeable" == "false" ]]; then
echo "Pull request ${PR_NUMBER} is not mergeable with its current base." >&2
exit 1
fi
if [[ "$attempt" == "12" ]]; then
echo "Timed out waiting for pull request ${PR_NUMBER} mergeability." >&2
exit 1
fi
sleep 2
done
merge_ref="refs/remotes/origin/release-gate-merge"
git fetch --no-tags --depth=2 origin \
"+refs/pull/${PR_NUMBER}/merge:${merge_ref}"
resolved_merge_sha="$(git rev-parse "$merge_ref")"
merge_base_parent="$(git rev-parse "${merge_ref}^1")"
merge_head_parent="$(git rev-parse "${merge_ref}^2")"
if [[ "$resolved_merge_sha" != "$merge_sha" || "$merge_base_parent" != "$pr_base_sha" || "$merge_head_parent" != "$TARGET_REF" ]]; then
echo "release_gate pull request merge ref does not match the current base and head" >&2
exit 1
fi
echo "base_sha=${pr_base_sha}" >> "$GITHUB_OUTPUT"
echo "head_sha=${merge_sha}" >> "$GITHUB_OUTPUT"
- name: Validate historical release target
id: historical_target
if: inputs.historical_target_tag != ''
@@ -309,6 +406,8 @@ jobs:
OPENCLAW_CI_RUN_CONTROL_UI_I18N: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_control_ui_i18n || 'false' }}
OPENCLAW_CI_RUN_UI_TESTS: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_ui_tests || 'false' }}
OPENCLAW_CI_RUN_NATIVE_I18N: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_native_i18n || 'false' }}
OPENCLAW_CI_RUN_TS_LOC: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_ts_loc || 'false' }}
OPENCLAW_CI_CHANGED_PATHS_JSON: ${{ steps.changed_scope.outputs.changed_paths_json || 'null' }}
OPENCLAW_CI_CHECKOUT_REVISION: ${{ steps.checkout_ref.outputs.sha }}
OPENCLAW_CI_HISTORICAL_TARGET: ${{ steps.historical_target.outputs.eligible || 'false' }}
OPENCLAW_CI_RELEASE_CANDIDATE_TARGET: ${{ steps.release_candidate_target.outputs.eligible || 'false' }}
@@ -346,6 +445,15 @@ jobs:
throw new Error("CI target does not export a supported Node test shard planner");
}
let changedNodeTestPlan = {};
if (existsSync("./scripts/lib/ci-changed-node-test-plan.mjs")) {
try {
changedNodeTestPlan = await import("./scripts/lib/ci-changed-node-test-plan.mjs");
} catch (error) {
console.warn(`Changed Node test planner import failed; using compact full suite: ${error}`);
}
}
const importTargetPlan = async (path) => {
if (existsSync(path)) {
return import(path);
@@ -390,6 +498,16 @@ jobs:
const packageScripts = JSON.parse(readFileSync("package.json", "utf8")).scripts ?? {};
const hasPackageScript = (name) => typeof packageScripts[name] === "string";
const isCanonicalRepository = process.env.OPENCLAW_CI_REPOSITORY === "openclaw/openclaw";
const changedPaths = (() => {
try {
const value = JSON.parse(process.env.OPENCLAW_CI_CHANGED_PATHS_JSON ?? "null");
return Array.isArray(value) && value.every((path) => typeof path === "string")
? value
: null;
} catch {
return null;
}
})();
const docsOnly = parseBoolean(process.env.OPENCLAW_CI_DOCS_ONLY);
const docsChanged = parseBoolean(process.env.OPENCLAW_CI_DOCS_CHANGED);
const runNode = parseBoolean(process.env.OPENCLAW_CI_RUN_NODE) && !docsOnly;
@@ -435,13 +553,18 @@ jobs:
parseBoolean(process.env.OPENCLAW_CI_RUN_NATIVE_I18N) &&
!docsOnly &&
(!frozenTarget || supportsNativeI18n);
const runTsLoc = parseBoolean(process.env.OPENCLAW_CI_RUN_TS_LOC) && !docsOnly;
const targetWorkflow = existsSync(".github/workflows/ci.yml")
? readFileSync(".github/workflows/ci.yml", "utf8")
: "";
const supportsCurrentAndroidCi = targetWorkflow.includes("android-ci-contract-v2");
const useCompatibleAndroidCi = compatibilityTarget && !supportsCurrentAndroidCi;
const supportsFormatCheck =
targetWorkflow.split("pnpm format:check").length - 1 >= 2;
const runFormatCheck = !frozenTarget || supportsFormatCheck;
const checksFastCoreTasks = [];
const checksFastCoreTasks = runTsLoc
? [{ check_name: "checks-fast-loc-ratchet", runtime: "node", task: "loc-ratchet" }]
: [];
if (runNodeFull) {
checksFastCoreTasks.push(
{ check_name: "checks-fast-bundled-protocol", runtime: "node", task: "bundled-protocol" },
@@ -458,14 +581,29 @@ jobs:
}
const compactPullRequest = isCanonicalRepository && eventName === "pull_request";
let changedNodeTestShards = null;
if (
compactPullRequest &&
changedPaths &&
typeof changedNodeTestPlan.createChangedNodeTestShards === "function"
) {
try {
changedNodeTestShards = changedNodeTestPlan.createChangedNodeTestShards(changedPaths);
} catch (error) {
console.warn(`Changed Node test planning failed; using compact full suite: ${error}`);
}
}
const runQaSmokeCi =
runNodeFull &&
(!compatibilityTarget || existsSync("extensions/qa-lab/src/ci-smoke-plan.ts"));
(!frozenTarget || existsSync("extensions/qa-lab/src/ci-smoke-plan.ts"));
const nodeTestShards = runNodeFull
? createNodeTestPlan({
includeReleaseOnlyPluginShards: false,
compact: compactPullRequest,
}).map((shard) => ({
? (changedNodeTestShards
? changedNodeTestShards
: createNodeTestPlan({
includeReleaseOnlyPluginShards: false,
compact: compactPullRequest,
})
).map((shard) => ({
check_name: shard.checkName,
runtime: "node",
task: "test-shard",
@@ -477,6 +615,7 @@ jobs:
requires_dist: shard.requiresDist,
runner: shard.runner,
timeout_minutes: shard.timeoutMinutes,
targets: shard.targets,
requires_go:
shard.shardName === "core-tooling" ||
shard.groups?.some((group) => group.shard_name.startsWith("core-tooling")),
@@ -484,6 +623,9 @@ jobs:
: [];
const nodeTestNonDistShards = nodeTestShards.filter((shard) => !shard.requires_dist);
const nodeTestDistShards = nodeTestShards.filter((shard) => shard.requires_dist);
// Targeted jobs cannot discover repository-scanning boundary tests
// through imports, so preserve the existing full boundary gate.
const runNodeCoreDist = changedNodeTestShards !== null || nodeTestDistShards.length > 0;
const channelContractShards = runNodeFull ? createChannelContractTestShards() : [];
const protocolCoverageRequested = runNode || runIosBuild || runAndroid;
const runProtocolEventCoverage =
@@ -514,7 +656,7 @@ jobs:
run_checks: runNodeFull,
run_checks_node_core_nondist: nodeTestNonDistShards.length > 0,
checks_node_core_nondist_matrix: createMatrix(nodeTestNonDistShards),
run_checks_node_core_dist: nodeTestDistShards.length > 0,
run_checks_node_core_dist: runNodeCoreDist,
run_check: runNodeFull,
run_check_additional: runNodeFull,
run_check_docs: docsChanged && eventName !== "push",
@@ -548,10 +690,16 @@ jobs:
android_matrix: createMatrix(
runAndroid
? [
// android-ci-contract-v2: both app variants, Android lint, benchmark, and ktlint.
{ check_name: "android-test-play", task: "test-play" },
{ check_name: "android-test-third-party", task: "test-third-party" },
{ check_name: "android-build-play", task: "build-play" },
{ check_name: "android-ktlint", task: "ktlint" },
{
check_name: "android-build-play",
task: useCompatibleAndroidCi ? "build-play-compat" : "build-play",
},
...(!useCompatibleAndroidCi
? [{ check_name: "android-ktlint", task: "ktlint" }]
: []),
]
: [],
),
@@ -570,14 +718,14 @@ jobs:
if: steps.manifest.outputs.run_protocol_event_coverage == 'true'
run: node scripts/check-protocol-event-coverage.mjs
# Run dependency-free security checks in parallel with scope detection so the
# main Node jobs do not have to wait for Python/pre-commit setup.
# Run dependency-free security checks on a hosted runner in parallel with
# scope detection. No downstream job waits for Python/pre-commit setup.
security-fast:
permissions:
contents: read
needs: [runner-admission]
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
runs-on: ubuntu-24.04
timeout-minutes: 20
env:
PRE_COMMIT_HOME: .cache/pre-commit-security-fast
@@ -827,10 +975,6 @@ jobs:
NODE_OPTIONS: --max-old-space-size=8192
run: pnpm build:ci-artifacts
- name: Check Control UI i18n
if: needs.preflight.outputs.run_control_ui_i18n == 'true'
run: pnpm ui:i18n:check
- name: Pack built runtime artifacts
run: tar --posix -cf dist-runtime-build.tar.zst --use-compress-program zstdmt dist dist-runtime packages/*/dist
@@ -865,7 +1009,7 @@ jobs:
env:
# GitHub-hosted Linux reports a higher RSS baseline than Blacksmith for
# the same built CLI. Keep the tighter Blacksmith regression ceiling.
OPENCLAW_STARTUP_MEMORY_PLUGINS_LIST_MB: ${{ runner.environment == 'github-hosted' && '425' || '350' }}
OPENCLAW_STARTUP_MEMORY_PLUGINS_LIST_MB: ${{ runner.environment == 'github-hosted' && '425' || '400' }}
run: |
set +e
pnpm test:startup:memory
@@ -1042,7 +1186,14 @@ jobs:
install-bun: "false"
- name: Install Playwright Chromium
run: node scripts/ensure-playwright-chromium.mjs
run: |
if [[ "$COMPATIBILITY_TARGET" == "true" ]]; then
# Legacy Vitest configs cannot pass a discovered system browser to Playwright.
# Install the managed browser revision pinned by the selected target instead.
pnpm --dir ui exec playwright install chromium
else
node scripts/ensure-playwright-chromium.mjs
fi
- name: Lint Control UI window.open usage
run: pnpm lint:ui:no-raw-window-open
@@ -1052,12 +1203,35 @@ jobs:
if [[ "$COMPATIBILITY_TARGET" == "true" ]]; then
# Frozen targets can contain timing-sensitive tests fixed on current main.
# Give legacy browser fixtures enough headroom on shared hosted runners.
# Isolate files because older suites can still leak module mocks between tests.
# Do not retry whole files: several rely on one-shot mocked browser globals.
pnpm --dir ui test --testTimeout=30000
pnpm --dir ui test --testTimeout=30000 --isolate
else
pnpm --dir ui test
fi
control-ui-i18n:
permissions:
contents: read
name: control-ui-i18n
needs: [preflight]
if: needs.preflight.outputs.run_control_ui_i18n == 'true'
# Locale refresh runs separately on main and nightly. Keep drift visible on
# automatic runs without blocking unrelated work; release CI stays strict.
continue-on-error: ${{ github.event_name != 'workflow_dispatch' }}
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
timeout-minutes: 10
steps:
- *linux_node_checkout_step
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: "24.x"
install-bun: "false"
- name: Check Control UI locale parity
run: pnpm ui:i18n:check
checks-fast-core:
permissions:
contents: read
@@ -1072,6 +1246,26 @@ jobs:
matrix: ${{ fromJson(needs.preflight.outputs.checks_fast_core_matrix) }}
steps:
- *linux_node_checkout_step
- name: Checkout verified release-gate LOC merge tree
if: matrix.task == 'loc-ratchet' && needs.preflight.outputs.loc_head_sha != ''
env:
LOC_HEAD_SHA: ${{ needs.preflight.outputs.loc_head_sha }}
LOC_PR_NUMBER: ${{ inputs.pr_number }}
shell: bash
run: |
set -euo pipefail
if [[ ! "$LOC_PR_NUMBER" =~ ^[1-9][0-9]*$ ]]; then
echo "A release-gate LOC head requires a pull request number." >&2
exit 1
fi
git fetch --no-tags --depth=2 origin \
"+refs/pull/${LOC_PR_NUMBER}/merge:refs/remotes/origin/ci-head"
resolved_loc_head="$(git rev-parse refs/remotes/origin/ci-head)"
if [[ "$resolved_loc_head" != "$LOC_HEAD_SHA" ]]; then
echo "Pull request ${LOC_PR_NUMBER} merge ref moved before the LOC check." >&2
exit 1
fi
git checkout --detach refs/remotes/origin/ci-head
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
@@ -1079,11 +1273,20 @@ jobs:
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
env:
HISTORICAL_TARGET: ${{ needs.preflight.outputs.compatibility_target }}
LOC_BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event_name == 'push' && github.event.before || needs.preflight.outputs.loc_base_sha || '' }}
LOC_EXPECTED_PR_HEAD: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || needs.preflight.outputs.loc_head_sha != '' && inputs.target_ref || '' }}
OPENCLAW_TEST_PROJECTS_PARALLEL: 3
TASK: ${{ matrix.task }}
shell: bash
run: |
set -euo pipefail
has_package_script() {
node -e '
const scripts = require("./package.json").scripts ?? {};
process.exit(Object.hasOwn(scripts, process.argv[1]) ? 0 : 1);
' "$1"
}
case "$TASK" in
bundled-protocol)
pnpm test:bundled
@@ -1091,10 +1294,39 @@ jobs:
;;
contracts-plugins-ci-routing)
pnpm test:contracts:plugins
pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/changed-lanes.test.ts test/scripts/ci-workflow-guards.test.ts test/scripts/run-vitest.test.ts test/scripts/test-projects.test.ts
pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/changed-lanes.test.ts test/scripts/ci-changed-node-test-plan.test.ts test/scripts/ci-workflow-guards.test.ts test/scripts/run-vitest.test.ts test/scripts/test-projects.test.ts
;;
ci-routing)
pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/changed-lanes.test.ts test/scripts/ci-workflow-guards.test.ts test/scripts/run-vitest.test.ts test/scripts/test-projects.test.ts
pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/changed-lanes.test.ts test/scripts/ci-changed-node-test-plan.test.ts test/scripts/ci-workflow-guards.test.ts test/scripts/run-vitest.test.ts test/scripts/test-projects.test.ts
;;
loc-ratchet)
# Frozen compatibility targets were governed by their original CI. Comparing
# them with moving main would turn unrelated branch drift into release failures.
if [[ "$HISTORICAL_TARGET" != "true" ]] && [ -n "$LOC_BASE_SHA" ] && [[ ! "$LOC_BASE_SHA" =~ ^0+$ ]]; then
loc_base_ref="refs/remotes/origin/ci-base"
if [ -n "$LOC_EXPECTED_PR_HEAD" ]; then
# Pull-request merge refs can advance past the event base as main moves.
# Compare with the base parent of the exact merge tree under test.
if ! git rev-parse --verify HEAD^1 >/dev/null 2>&1 || ! git rev-parse --verify HEAD^2 >/dev/null 2>&1; then
loc_merge_sha="$(git rev-parse HEAD)"
git fetch --no-tags --depth=2 origin "+${loc_merge_sha}:refs/remotes/origin/ci-loc-merge"
fi
merge_head="$(git rev-parse HEAD^2)"
if [[ "$merge_head" != "$LOC_EXPECTED_PR_HEAD" ]]; then
echo "LOC merge tree head ${merge_head} does not match expected pull-request head ${LOC_EXPECTED_PR_HEAD}." >&2
exit 1
fi
loc_base_ref="$(git rev-parse HEAD^1)"
else
git fetch --no-tags --depth=1 origin "+${LOC_BASE_SHA}:${loc_base_ref}"
fi
if has_package_script "check:loc"; then
pnpm check:loc --base "$loc_base_ref" --head HEAD
else
echo "Current CI targets must provide the check:loc package script." >&2
exit 1
fi
fi
;;
bun-launcher)
OPENCLAW_TEST_BUN_LAUNCHER=1 pnpm test test/openclaw-launcher.e2e.test.ts
@@ -1172,13 +1404,54 @@ jobs:
if (typeof smokePlan.createQaSmokeCiPart === "function") {
runs = smokePlan.createQaSmokeCiPart(partId).runs;
} else if (typeof smokePlan.createQaSmokeCiMatrix === "function") {
// Legacy planners select the entire profile and can mix long-lived
// execution kinds. Reuse the current bounded smoke contract and
// isolate each scenario so one invocation cannot pin a profile part.
const compatibilityScenarioIds = new Set([
"control-ui-chat-flow-playwright",
"crestodian-ring-zero-setup",
"dreaming-shadow-trial-report",
"gateway-smoke",
"group-visible-reply-tool",
"long-running-release-audit",
"luna-thinking-visibility-switch",
"matrix-restart-resume",
"personal-task-followthrough-status",
"plugin-lifecycle-hot-reload",
"subagent-completion-direct-fallback",
"telegram-commands-command",
]);
const partIndex = partId === "profile-1" ? 0 : partId === "profile-2" ? 1 : -1;
if (partIndex < 0) {
throw new Error(`unknown QA smoke CI profile part: ${partId}`);
}
runs = smokePlan
const scenarioCatalog = await import("./extensions/qa-lab/src/scenario-catalog.ts");
const scenarioKindById = new Map(
scenarioCatalog
.readQaScenarioPack()
.scenarios.map((scenario) => [scenario.id, scenario.execution.kind]),
);
const legacyRuns = smokePlan
.createQaSmokeCiMatrix()
.include.filter((_, index) => index % 2 === partIndex);
runs = legacyRuns.flatMap((run) =>
run.scenario_ids.flatMap((scenarioId) => {
if (!compatibilityScenarioIds.has(scenarioId)) {
return [];
}
const kind = scenarioKindById.get(scenarioId);
if (!kind) {
throw new Error(`legacy QA smoke scenario not found: ${scenarioId}`);
}
return [
{
...run,
slug: `${run.slug}-${kind}-${scenarioId}`,
scenario_ids: [scenarioId],
},
];
}),
);
} else {
throw new Error("QA smoke plan does not expose a supported CI planner.");
}
@@ -1313,7 +1586,7 @@ jobs:
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: "22.19.0"
node-version: "22.22.3"
install-bun: "false"
- name: Configure Node test resources
@@ -1353,9 +1626,11 @@ jobs:
- name: Setup Go for docs i18n
if: matrix.requires_go == true
# The current workflow validates frozen targets whose go.mod may predate this patch pin.
# Keep the runner toolchain owned by the workflow while using the target only for cache keys.
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: scripts/docs-i18n/go.mod
go-version: "1.25.12"
cache-dependency-path: scripts/docs-i18n/go.sum
- name: Verify docs i18n Go toolchain
@@ -1372,6 +1647,7 @@ jobs:
OPENCLAW_NODE_TEST_CONFIGS_JSON: ${{ toJson(matrix.configs) }}
OPENCLAW_NODE_TEST_ENV_JSON: ${{ toJson(matrix.env) }}
OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON: ${{ toJson(matrix.includePatterns) }}
OPENCLAW_NODE_TEST_TARGETS_JSON: ${{ toJson(matrix.targets) }}
OPENCLAW_VITEST_SHARD_NAME: ${{ matrix.shard_name }}
OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS: "300000"
OPENCLAW_VITEST_NO_OUTPUT_RETRY: "1"
@@ -1384,6 +1660,23 @@ jobs:
import { writeFileSync } from "node:fs";
import { join } from "node:path";
const targets = JSON.parse(process.env.OPENCLAW_NODE_TEST_TARGETS_JSON ?? "null");
if (Array.isArray(targets) && targets.length > 0) {
// One file per process preserves the isolation boundaries encoded by
// full-suite include-pattern shards while keeping one runner job.
for (const target of targets) {
const result = spawnSync(
"pnpm",
["exec", "node", "scripts/test-projects.mjs", target],
{ env: process.env, stdio: "inherit" },
);
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
process.exit(0);
}
const groups = JSON.parse(process.env.OPENCLAW_NODE_TEST_GROUPS_JSON ?? "null");
const plans = Array.isArray(groups) && groups.length > 0
? groups
@@ -1488,6 +1781,12 @@ jobs:
shell: bash
run: |
set -euo pipefail
has_package_script() {
node -e '
const scripts = require("./package.json").scripts ?? {};
process.exit(Object.hasOwn(scripts, process.argv[1]) ? 0 : 1);
' "$1"
}
case "$TASK" in
guards)
pnpm check:no-conflict-markers
@@ -1495,8 +1794,8 @@ jobs:
pnpm check:host-env-policy:swift
pnpm dup:check:coverage
if [ -n "$PR_BASE_SHA" ]; then
git fetch --no-tags --depth=1 origin "+${PR_BASE_SHA}:refs/remotes/origin/pr-base"
node scripts/report-test-temp-creations.mjs --base refs/remotes/origin/pr-base --head HEAD --no-merge-base
git fetch --no-tags --depth=1 origin "+${PR_BASE_SHA}:refs/remotes/origin/ci-base"
node scripts/report-test-temp-creations.mjs --base refs/remotes/origin/ci-base --head HEAD --no-merge-base
fi
pnpm deps:patches:check
pnpm lint:webhook:no-low-level-body-read
@@ -1520,12 +1819,21 @@ jobs:
fi
;;
dependencies)
if pnpm run --silent 2>/dev/null | grep -q '^ deadcode:dependencies$'; then
if has_package_script "deadcode:dependencies" &&
has_package_script "deadcode:unused-files"; then
pnpm deadcode:dependencies
pnpm deadcode:unused-files
pnpm deadcode:report:ci:ts-unused
else
if has_package_script "deadcode:exports"; then
pnpm deadcode:exports
elif [[ "$HISTORICAL_TARGET" != "true" ]]; then
echo "Current CI targets must provide the deadcode:exports package script." >&2
exit 1
fi
elif [[ "$HISTORICAL_TARGET" == "true" ]] && has_package_script "deadcode:ci"; then
pnpm deadcode:ci
else
echo "Target does not provide a supported deadcode check." >&2
exit 1
fi
;;
test-types)
@@ -1536,12 +1844,6 @@ jobs:
echo "Current CI targets must provide the tsgo:scripts package script." >&2
exit 1
fi
if pnpm run --silent 2>/dev/null | grep -q '^ tsgo:strict-ratchet$'; then
pnpm tsgo:strict-ratchet
elif [[ "$HISTORICAL_TARGET" != "true" ]]; then
echo "Current CI targets must provide the tsgo:strict-ratchet package script." >&2
exit 1
fi
if pnpm run --silent 2>/dev/null | grep -q '^ tsgo:test:root$'; then
pnpm tsgo:test:root
elif [[ "$HISTORICAL_TARGET" != "true" ]]; then
@@ -1555,14 +1857,6 @@ jobs:
;;
esac
- name: Upload deadcode reports
if: ${{ always() && matrix.task == 'dependencies' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: deadcode-reports
path: .artifacts/deadcode
if-no-files-found: ignore
check-additional-shard:
permissions:
contents: read
@@ -1596,12 +1890,6 @@ jobs:
- check_name: check-sqlite-session-flip-proof
group: sqlite-session-flip-proof
runner: blacksmith-8vcpu-ubuntu-2404
- check_name: check-additional-extension-channels
group: extension-channels
runner: blacksmith-8vcpu-ubuntu-2404
- check_name: check-additional-extension-bundled
group: extension-bundled
runner: blacksmith-8vcpu-ubuntu-2404
- check_name: check-additional-extension-package-boundary
group: extension-package-boundary
runner: blacksmith-8vcpu-ubuntu-2404
@@ -1676,7 +1964,6 @@ jobs:
env:
ADDITIONAL_CHECK_GROUP: ${{ matrix.group }}
OPENCLAW_ADDITIONAL_BOUNDARY_SHARD: ${{ matrix.boundary_shard || '' }}
RUN_CONTROL_UI_I18N: ${{ needs.preflight.outputs.run_control_ui_i18n }}
OPENCLAW_ADDITIONAL_BOUNDARY_CONCURRENCY: 4
OPENCLAW_EXTENSION_BOUNDARY_CONCURRENCY: 6
shell: bash
@@ -1742,12 +2029,6 @@ jobs:
run_check "sqlite sessions/transcripts flip proof" node scripts/run-vitest.mjs run --config test/vitest/vitest.e2e.config.ts test/scripts/sqlite-sessions-transcripts-flip-proof.e2e.test.ts
fi
;;
extension-channels)
run_check "lint:extensions:channels" pnpm run lint:extensions:channels
;;
extension-bundled)
run_check "lint:extensions:bundled" pnpm run lint:extensions:bundled
;;
extension-package-boundary)
run_check "test:extensions:package-boundary:compile" pnpm run test:extensions:package-boundary:compile
run_check "test:extensions:package-boundary:canary" pnpm run test:extensions:package-boundary:canary
@@ -2083,17 +2364,18 @@ jobs:
- name: Install XcodeGen / SwiftLint / SwiftFormat
run: |
brew update
if [[ -x ./scripts/install-swift-tools.sh ]]; then
brew install xcodegen
if [[ -x ./scripts/install-xcodegen.sh && -x ./scripts/install-swift-tools.sh ]]; then
swift_tools_dir="$RUNNER_TEMP/openclaw-swift-tools"
./scripts/install-xcodegen.sh "$swift_tools_dir"
./scripts/install-swift-tools.sh "$swift_tools_dir"
echo "$swift_tools_dir" >> "$GITHUB_PATH"
"$swift_tools_dir/xcodegen" --version
"$swift_tools_dir/swiftformat" --version
"$swift_tools_dir/swiftlint" version
elif [[ "$HISTORICAL_TARGET" == "true" ]]; then
# Frozen release targets before the pinned installer used one of these
# reviewed formatter contracts. Fail closed for any unknown minimum.
brew update
brew install xcodegen swiftlint
swiftformat_min_version="$(awk '$1 == "--min-version" { print $2; exit }' config/swiftformat)"
case "$swiftformat_min_version" in
@@ -2125,7 +2407,7 @@ jobs:
echo "$swift_tools_dir" >> "$GITHUB_PATH"
[[ "$("$swift_tools_dir/swiftformat" --version)" == "$swiftformat_version" ]]
else
echo "Current CI targets must provide scripts/install-swift-tools.sh." >&2
echo "Current CI targets must provide scripts/install-xcodegen.sh and scripts/install-swift-tools.sh." >&2
exit 1
fi
@@ -2258,6 +2540,8 @@ jobs:
if: needs.preflight.outputs.run_ios_build == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-26' || (github.repository == 'openclaw/openclaw' && 'blacksmith-12vcpu-macos-26' || 'macos-26') }}
timeout-minutes: 45
env:
HISTORICAL_TARGET: ${{ needs.preflight.outputs.compatibility_target }}
steps:
- *platform_checkout_step
@@ -2285,17 +2569,56 @@ jobs:
- name: Install iOS Swift tooling
run: |
brew update
if [[ -x ./scripts/install-swift-tools.sh ]]; then
brew install xcodegen
if [[ -x ./scripts/install-xcodegen.sh && -x ./scripts/install-swift-tools.sh ]]; then
swift_tools_dir="$RUNNER_TEMP/openclaw-swift-tools"
./scripts/install-xcodegen.sh "$swift_tools_dir"
./scripts/install-swift-tools.sh "$swift_tools_dir"
echo "$swift_tools_dir" >> "$GITHUB_PATH"
"$swift_tools_dir/xcodegen" --version
"$swift_tools_dir/swiftformat" --version
"$swift_tools_dir/swiftlint" version
elif [[ "$HISTORICAL_TARGET" == "true" ]]; then
# The generated Xcode project runs SwiftFormat during the build, so
# frozen targets must keep the formatter contract they were authored for.
brew update
brew install xcodegen swiftlint
swiftformat_min_version="$(awk '$1 == "--min-version" { print $2; exit }' config/swiftformat)"
case "$swiftformat_min_version" in
""|0.61.1)
swiftformat_version="0.61.1"
swiftformat_checksum="b990400779aceb7d7020796eb9ba814d4480543f671d38fc0ff48cb72f04c584"
;;
0.62.1)
swiftformat_version="0.62.1"
swiftformat_checksum="7cb1cb1fae04932047c7015441c543848e8e60e1572d808d080e0a1f1661114a"
;;
*)
echo "Unsupported frozen-target SwiftFormat minimum: $swiftformat_min_version" >&2
exit 1
;;
esac
swiftformat_archive="$RUNNER_TEMP/swiftformat-$swiftformat_version.zip"
swift_tools_dir="$RUNNER_TEMP/openclaw-legacy-swift-tools"
curl --fail --location --silent --show-error --retry 3 \
--output "$swiftformat_archive" \
"https://github.com/nicklockwood/SwiftFormat/releases/download/$swiftformat_version/swiftformat.zip"
if [[ "$(shasum -a 256 "$swiftformat_archive" | awk '{print $1}')" != "$swiftformat_checksum" ]]; then
echo "SwiftFormat $swiftformat_version archive checksum mismatch" >&2
exit 1
fi
mkdir -p "$swift_tools_dir"
unzip -q "$swiftformat_archive" -d "$swift_tools_dir"
chmod +x "$swift_tools_dir/swiftformat"
echo "$swift_tools_dir" >> "$GITHUB_PATH"
[[ "$("$swift_tools_dir/swiftformat" --version)" == "$swiftformat_version" ]]
# Legacy generated Xcode phases prepend Homebrew ahead of GITHUB_PATH.
# Point that lookup at the verified binary or the build can bypass the pin.
swiftformat_link="$(brew --prefix)/bin/swiftformat"
ln -sfn "$swift_tools_dir/swiftformat" "$swiftformat_link"
[[ "$("$swiftformat_link" --version)" == "$swiftformat_version" ]]
else
# Frozen release targets before the pinned installer used Homebrew directly.
brew install xcodegen swiftlint swiftformat
echo "Current CI targets must provide scripts/install-xcodegen.sh and scripts/install-swift-tools.sh." >&2
exit 1
fi
- name: Swift lint
@@ -2439,6 +2762,11 @@ jobs:
:app:lintThirdPartyDebug \
:benchmark:assembleDebug
;;
build-play-compat)
# Frozen targets keep their target-owned Android build contract. New lint rules
# must not retroactively reject a previously validated release branch.
./gradlew --no-daemon --build-cache :app:assemblePlayDebug
;;
ktlint)
# Mirrors `pnpm android:lint`; keeps formatting drift out of main (see PR #100304 sweep).
./gradlew --no-daemon --build-cache :app:ktlintCheck :benchmark:ktlintCheck
@@ -2460,6 +2788,7 @@ jobs:
- pnpm-store-warmup
- build-artifacts
- checks-ui
- control-ui-i18n
- checks-fast-core
- checks-fast-plugin-contracts-shard
- checks-fast-channel-contracts-shard
@@ -276,6 +276,9 @@ jobs:
with:
install-bun: "false"
- name: Finalize control UI generated artifacts
run: node --import tsx scripts/control-ui-i18n.ts sync --write
- name: Validate control UI locale refresh
run: node --import tsx scripts/control-ui-i18n.ts check
+38 -21
View File
@@ -65,7 +65,7 @@ on:
- npm-telegram
- performance
reuse_evidence:
description: Reuse the newest prior green full validation only for the exact same target SHA and inputs
description: Reuse matching green product validation for the same target or a changelog-only Release SHA
required: false
default: true
type: boolean
@@ -256,6 +256,7 @@ jobs:
evidence_root_run_id: ${{ steps.find.outputs.evidence_root_run_id }}
evidence_run_url: ${{ steps.find.outputs.evidence_run_url }}
evidence_sha: ${{ steps.find.outputs.evidence_sha }}
evidence_policy: ${{ steps.find.outputs.evidence_policy }}
evidence_manifest: ${{ steps.find.outputs.evidence_manifest }}
changed_paths: ${{ steps.find.outputs.changed_paths }}
steps:
@@ -342,7 +343,7 @@ jobs:
if [[ "$REUSE" == "true" ]]; then
echo "- Reusing evidence: ${EVIDENCE_RUN_URL}"
echo "- Evidence SHA: \`${EVIDENCE_SHA}\`"
echo "- Exact-target reuse changed paths: \`${changed_paths_summary}\`"
echo "- Reused validation changed paths: \`${changed_paths_summary}\`"
else
echo "- No reusable evidence: ${REUSE_REASON:-unknown}"
fi
@@ -379,7 +380,7 @@ jobs:
needs: [resolve_target, evidence_reuse]
if: ${{ always() && needs.resolve_target.result == 'success' && contains(fromJSON('["all","ci"]'), inputs.rerun_group) && needs.evidence_reuse.outputs.reuse != 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: ${{ inputs.release_profile != 'minimum' && 240 || 60 }}
timeout-minutes: ${{ inputs.release_profile != 'beta' && 240 || 60 }}
outputs:
run_id: ${{ steps.dispatch.outputs.run_id }}
url: ${{ steps.dispatch.outputs.url }}
@@ -524,14 +525,14 @@ jobs:
break
fi
poll_count=$((poll_count + 1))
if (( poll_count % 2 == 0 )); then
if (( poll_count % 5 == 0 )); then
fail_fast_failed_jobs
fi
if (( poll_count % 10 == 0 )); then
echo "Still waiting on ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
fetch_child_jobs | jq 'select(.status != "completed") | {name, status, url: .html_url}' || true
fi
sleep 30
sleep 60
done
trap - EXIT INT TERM
@@ -714,14 +715,14 @@ jobs:
break
fi
poll_count=$((poll_count + 1))
if (( poll_count % 2 == 0 )); then
if (( poll_count % 5 == 0 )); then
fail_fast_failed_jobs
fi
if (( poll_count % 10 == 0 )); then
echo "Still waiting on ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
fetch_child_jobs | jq 'select(.status != "completed") | {name, status, url: .html_url}' || true
fi
sleep 30
sleep 60
done
trap - EXIT INT TERM
@@ -752,7 +753,7 @@ jobs:
needs: [resolve_target, evidence_reuse]
if: ${{ always() && needs.resolve_target.result == 'success' && contains(fromJSON('["all","release-checks","install-smoke","cross-os","live-e2e","package","qa","qa-parity","qa-live"]'), inputs.rerun_group) && needs.evidence_reuse.outputs.reuse != 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: ${{ inputs.release_profile != 'minimum' && 240 || 60 }}
timeout-minutes: ${{ inputs.release_profile != 'beta' && 240 || 60 }}
outputs:
run_id: ${{ steps.dispatch.outputs.run_id }}
url: ${{ steps.dispatch.outputs.url }}
@@ -763,6 +764,7 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
TARGET_REF: ${{ inputs.ref }}
TARGET_CONTEXT_REF: ${{ inputs.target_context_ref }}
TARGET_SHA: ${{ needs.resolve_target.outputs.sha }}
CHILD_WORKFLOW_REF: ${{ github.ref_name }}
PARENT_WORKFLOW_SHA: ${{ github.sha }}
@@ -965,14 +967,14 @@ jobs:
break
fi
poll_count=$((poll_count + 1))
if (( poll_count % 2 == 0 )); then
if (( poll_count % 5 == 0 )); then
fail_fast_failed_jobs
fi
if (( poll_count % 10 == 0 )); then
echo "Still waiting on ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
fetch_child_jobs | jq 'select(.status != "completed") | {name, status, url: .html_url}' || true
fi
sleep 30
sleep 60
done
trap - EXIT INT TERM
@@ -1031,8 +1033,10 @@ jobs:
child_rerun_group=all
fi
release_checks_target_ref="${TARGET_CONTEXT_REF:-$TARGET_REF}"
args=(
-f ref="$TARGET_REF"
-f ref="$release_checks_target_ref"
-f expected_sha="$TARGET_SHA"
-f provider="$PROVIDER"
-f mode="$MODE"
@@ -1210,14 +1214,14 @@ jobs:
break
fi
poll_count=$((poll_count + 1))
if (( poll_count % 2 == 0 )); then
if (( poll_count % 5 == 0 )); then
fail_fast_failed_jobs
fi
if (( poll_count % 10 == 0 )); then
echo "Still waiting on npm-telegram-beta-e2e.yml: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
gh_with_retry run view "$run_id" --json jobs --jq '.jobs[] | select(.status != "completed") | {name, status, url}' || true
fi
sleep 30
sleep 60
done
trap - EXIT INT TERM
@@ -1380,7 +1384,7 @@ jobs:
echo "Still waiting on openclaw-performance.yml: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
gh_with_retry run view "$run_id" --json jobs --jq '.jobs[] | select(.status != "completed") | {name, status, url}' || true
fi
sleep 30
sleep 60
done
trap - EXIT INT TERM
@@ -1430,6 +1434,7 @@ jobs:
EVIDENCE_ROOT_RUN_ID: ${{ needs.evidence_reuse.outputs.evidence_root_run_id }}
EVIDENCE_RUN_URL: ${{ needs.evidence_reuse.outputs.evidence_run_url }}
EVIDENCE_SHA: ${{ needs.evidence_reuse.outputs.evidence_sha }}
EVIDENCE_POLICY: ${{ needs.evidence_reuse.outputs.evidence_policy }}
EVIDENCE_MANIFEST: ${{ needs.evidence_reuse.outputs.evidence_manifest }}
RERUN_GROUP: ${{ inputs.rerun_group }}
TARGET_SHA: ${{ needs.resolve_target.outputs.sha }}
@@ -1709,12 +1714,25 @@ jobs:
fi
done < <(jq -r '[.childRuns.normalCi // "", .childRuns.pluginPrerelease // "", .childRuns.releaseChecks // "", .childRuns.npmTelegram // "", (.childRuns.productPerformance.runId // "")] | map(select(. != "")) | .[]' <<< "$EVIDENCE_MANIFEST")
if [[ "$failed" == "0" ]]; then
emit_reused_child_dispatch() {
local workflow="$1"
local run_id="$2"
if [[ -n "${run_id// }" ]]; then
echo "Dispatched ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
fi
}
emit_reused_child_dispatch "ci.yml" "$(jq -r '.childRuns.normalCi // ""' <<< "$EVIDENCE_MANIFEST")"
emit_reused_child_dispatch "plugin-prerelease.yml" "$(jq -r '.childRuns.pluginPrerelease // ""' <<< "$EVIDENCE_MANIFEST")"
emit_reused_child_dispatch "openclaw-release-checks.yml" "$(jq -r '.childRuns.releaseChecks // ""' <<< "$EVIDENCE_MANIFEST")"
emit_reused_child_dispatch "npm-telegram-beta-e2e.yml" "$(jq -r '.childRuns.npmTelegram // ""' <<< "$EVIDENCE_MANIFEST")"
emit_reused_child_dispatch "openclaw-performance.yml" "$(jq -r '.childRuns.productPerformance.runId // ""' <<< "$EVIDENCE_MANIFEST")"
{
echo "### Reused validation evidence"
echo
echo "- Evidence run: ${EVIDENCE_RUN_URL}"
echo "- Evidence SHA: \`${EVIDENCE_SHA}\`"
echo "- Target SHA: \`${TARGET_SHA}\` (exact-target evidence reuse)"
echo "- Target SHA: \`${TARGET_SHA}\`"
echo "- Reuse policy: \`${EVIDENCE_POLICY}\`"
} >> "$GITHUB_STEP_SUMMARY"
fi
elif [[ "$RERUN_GROUP" == "all" && "$DOCKER_RUNTIME_ASSETS_PREFLIGHT_RESULT" != "success" ]]; then
@@ -1804,19 +1822,16 @@ jobs:
RELEASE_CHECKS_RESULT: ${{ needs.release_checks.result }}
EVIDENCE_REUSE: ${{ needs.evidence_reuse.outputs.reuse }}
EVIDENCE_ROOT_RUN_ID: ${{ needs.evidence_reuse.outputs.evidence_root_run_id }}
EVIDENCE_POLICY: ${{ needs.evidence_reuse.outputs.evidence_policy }}
run: |
set -euo pipefail
if [[ "$RELEASE_CHECKS_RESULT" == "skipped" && "$EVIDENCE_REUSE" != "true" ]]; then
echo "Release checks were skipped by rerun group; skipping automatic release evidence update."
exit 0
fi
# In reuse mode the child runs live on the chain-root validation run
# (the evidence consumer scrapes dispatch logs from the given run),
# so durable evidence must reference that run id, not this wrapper.
notes="Automatically requested by Full Release Validation ${GITHUB_RUN_ID_VALUE} after child workflows completed; the parent summary re-checks current child run conclusions."
if [[ "$EVIDENCE_REUSE" == "true" && -n "${EVIDENCE_ROOT_RUN_ID// }" ]]; then
notes="Automatically requested by Full Release Validation ${GITHUB_RUN_ID_VALUE}, which reused green evidence from chain-root run ${EVIDENCE_ROOT_RUN_ID} for the exact same target SHA and inputs."
GITHUB_RUN_ID_VALUE="$EVIDENCE_ROOT_RUN_ID"
notes="Automatically requested by Full Release Validation ${GITHUB_RUN_ID_VALUE}, which reused green product evidence from chain-root run ${EVIDENCE_ROOT_RUN_ID} under policy ${EVIDENCE_POLICY}."
fi
if [[ -z "${RELEASES_DISPATCH_TOKEN// }" ]]; then
echo "OPENCLAW_RELEASES_DISPATCH_TOKEN is not configured; skipping automatic release evidence update."
@@ -1898,6 +1913,7 @@ jobs:
EVIDENCE_RUN_ID: ${{ needs.evidence_reuse.outputs.evidence_run_id }}
EVIDENCE_ROOT_RUN_ID: ${{ needs.evidence_reuse.outputs.evidence_root_run_id }}
EVIDENCE_SHA: ${{ needs.evidence_reuse.outputs.evidence_sha }}
EVIDENCE_POLICY: ${{ needs.evidence_reuse.outputs.evidence_policy }}
EVIDENCE_CHANGED_PATHS: ${{ needs.evidence_reuse.outputs.changed_paths }}
EVIDENCE_MANIFEST: ${{ needs.evidence_reuse.outputs.evidence_manifest }}
PROVIDER: ${{ inputs.provider }}
@@ -1927,6 +1943,7 @@ jobs:
--arg evidenceRunId "$EVIDENCE_RUN_ID" \
--arg evidenceRootRunId "$EVIDENCE_ROOT_RUN_ID" \
--arg evidenceSha "$EVIDENCE_SHA" \
--arg evidencePolicy "$EVIDENCE_POLICY" \
--argjson evidenceChangedPaths "$EVIDENCE_CHANGED_PATHS" \
'. + {
version: 3,
@@ -1939,7 +1956,7 @@ jobs:
targetRef: $targetRef,
targetSha: $targetSha,
evidenceReuse: {
policy: "exact-target-full-validation-v1",
policy: $evidencePolicy,
runId: $evidenceRootRunId,
selectedRunId: $evidenceRunId,
evidenceSha: $evidenceSha,
+48 -20
View File
@@ -1,8 +1,8 @@
name: iOS Periphery Dead Code Comment
name: Periphery Dead Code Comment
on:
workflow_run: # zizmor: ignore[dangerous-triggers] trusted PR commenter; job gates repository, source event, workflow name, live open PR, and exact current head before reading artifacts or writing comments
workflows: ["iOS Periphery Dead Code"]
workflows: ["iOS Periphery Dead Code", "macOS Periphery Dead Code"]
types: [completed]
env:
@@ -21,7 +21,8 @@ jobs:
if: >
github.repository == 'openclaw/openclaw' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.name == 'iOS Periphery Dead Code'
(github.event.workflow_run.name == 'iOS Periphery Dead Code' ||
github.event.workflow_run.name == 'macOS Periphery Dead Code')
steps:
- name: Upsert Periphery PR comment
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
@@ -30,8 +31,40 @@ jobs:
const path = require("node:path");
const zlib = require("node:zlib");
const marker = "<!-- openclaw-ios-periphery-dead-code -->";
const run = context.payload.workflow_run;
const workflowConfig = {
"iOS Periphery Dead Code": {
artifactPrefix: "ios-periphery-dead-code",
heading: "### iOS Periphery",
marker: "<!-- openclaw-ios-periphery-dead-code -->",
repoPathPrefix: "apps/ios/",
scanJobName: "Scan iOS dead code",
scopeJobName: "Detect iOS scan scope",
scopeLabel: "iOS",
},
"macOS Periphery Dead Code": {
artifactPrefix: "macos-periphery-dead-code",
heading: "### macOS Periphery",
marker: "<!-- openclaw-macos-periphery-dead-code -->",
repoPathPrefix: "apps/macos/",
scanJobName: "Scan macOS dead code",
scopeJobName: "Detect macOS scan scope",
scopeLabel: "macOS",
},
}[run.name];
if (!workflowConfig) {
core.info(`Skipping unexpected workflow ${run.name ?? "unknown"}.`);
return;
}
const {
artifactPrefix,
heading,
marker,
repoPathPrefix,
scanJobName,
scopeJobName,
scopeLabel,
} = workflowConfig;
const pr = run.pull_requests?.[0];
if (!pr) {
core.info("No pull request attached to workflow_run.");
@@ -48,11 +81,6 @@ jobs:
core.info(`Skipping workflow_run for ${run.event ?? "unknown"} event.`);
return;
}
if (run.name !== "iOS Periphery Dead Code") {
core.info(`Skipping unexpected workflow ${run.name ?? "unknown"}.`);
return;
}
const livePull = await github.rest.pulls.get({
owner,
repo,
@@ -78,8 +106,8 @@ jobs:
filter: "latest",
per_page: 100,
});
const scopeJob = jobs.find((job) => job.name === "Detect iOS scan scope");
const scanJob = jobs.find((job) => job.name === "Scan iOS dead code");
const scopeJob = jobs.find((job) => job.name === scopeJobName);
const scanJob = jobs.find((job) => job.name === scanJobName);
const scanSkipped =
scopeJob?.conclusion === "success" && scanJob?.conclusion === "skipped";
if (scanSkipped) {
@@ -99,7 +127,7 @@ jobs:
if (scanSkipped) {
return;
}
const artifactName = `ios-periphery-dead-code-${run.id}-${run.run_attempt}`;
const artifactName = `${artifactPrefix}-${run.id}-${run.run_attempt}`;
const artifact = artifacts.find((item) => item.name === artifactName);
if (!artifact) {
core.warning(`No ${artifactName} artifact found.`);
@@ -349,7 +377,7 @@ jobs:
const location = String(finding.location ?? "");
const [file, line] = location.split(":");
return {
file: file ? `apps/ios/${file}` : "",
file: file ? `${repoPathPrefix}${file}` : "",
line: line || "",
kind: String(finding.kind ?? ""),
name: String(finding.name ?? ""),
@@ -361,27 +389,27 @@ jobs:
if (scanSkipped) {
mode = "skipped";
body += [
"### iOS Periphery",
heading,
"",
"Periphery scan skipped because the pull request is a draft or no longer touches iOS scan scope.",
`Periphery scan skipped because the pull request is a draft or no longer touches ${scopeLabel} scan scope.`,
].join("\n");
} else if (findings === null) {
body += [
"### iOS Periphery",
heading,
"",
"Periphery did not complete or its report could not be safely read. Check the workflow run for details.",
].join("\n");
} else if (rows.length === 0 && status === 0) {
mode = "success";
body += [
"### iOS Periphery",
heading,
"",
"No dead Swift code found.",
].join("\n");
} else if (rows.length > 0) {
const shown = rows.slice(0, 50);
body += [
"### iOS Periphery",
heading,
"",
`Found ${rows.length} dead Swift code ${rows.length === 1 ? "symbol" : "symbols"}. Remove the code or add a narrow Periphery exemption with a comment explaining why it must stay.`,
"",
@@ -393,7 +421,7 @@ jobs:
].filter(Boolean).join("\n");
} else {
body += [
"### iOS Periphery",
heading,
"",
"Periphery exited with a non-zero status before producing findings. Check the workflow artifact for stdout/stderr.",
].join("\n");
@@ -403,7 +431,7 @@ jobs:
if (body.length > maxCommentChars) {
body = [
marker,
"### iOS Periphery",
heading,
"",
`Found ${rows.length} dead Swift code ${rows.length === 1 ? "symbol" : "symbols"}. The rendered report exceeded the safe comment limit; use the workflow artifact for details.`,
"",
+43 -27
View File
@@ -14,7 +14,6 @@ env:
permissions:
contents: read
pull-requests: read
jobs:
scope:
@@ -23,6 +22,22 @@ jobs:
outputs:
should-scan: ${{ steps.scope.outputs.should-scan }}
steps:
- name: Checkout
if: github.event_name == 'pull_request' && github.event.pull_request.draft == false
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Ensure base commit
if: github.event_name == 'pull_request' && github.event.pull_request.draft == false
uses: ./.github/actions/ensure-base-commit
with:
base-sha: ${{ github.event.pull_request.base.sha }}
fetch-ref: ${{ github.event.pull_request.base.ref }}
- name: Detect changed paths
id: scope
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
@@ -37,32 +52,33 @@ jobs:
return;
}
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
per_page: 100,
});
const isScanPath = (filename) =>
typeof filename === "string" && (
filename.startsWith("apps/ios/") ||
filename.startsWith("apps/shared/OpenClawKit/Sources/") ||
filename.startsWith("apps/swabble/Sources/SwabbleKit/") ||
filename === ".github/workflows/ios-periphery.yml" ||
filename === ".github/workflows/ios-periphery-comment.yml" ||
filename === "config/swiftformat" ||
filename === "config/swiftlint.yml" ||
filename === "scripts/check-swift-tools.sh" ||
filename === "scripts/format-swift.sh" ||
filename === "scripts/install-swift-tools.sh" ||
filename === "scripts/ios-write-swift-filelist.mjs" ||
filename === "scripts/lint-swift.sh"
);
const shouldScan = files.some(
({ filename, previous_filename: previousFilename }) =>
isScanPath(filename) || isScanPath(previousFilename)
);
core.setOutput("should-scan", String(shouldScan));
const baseSha = context.payload.pull_request?.base?.sha;
if (!baseSha) {
throw new Error("missing pull request base SHA");
}
const result = await exec.getExecOutput("git", [
"diff",
"--quiet",
baseSha,
"HEAD",
"--",
"apps/ios/",
"apps/shared/OpenClawKit/Sources/",
"apps/swabble/Sources/SwabbleKit/",
".github/workflows/ios-periphery.yml",
".github/workflows/ios-periphery-comment.yml",
"config/swiftformat",
"config/swiftlint.yml",
"scripts/check-swift-tools.sh",
"scripts/format-swift.sh",
"scripts/install-swift-tools.sh",
"scripts/ios-write-swift-filelist.mjs",
"scripts/lint-swift.sh",
], { ignoreReturnCode: true, silent: true });
if (result.exitCode !== 0 && result.exitCode !== 1) {
throw new Error(`git diff failed with exit code ${result.exitCode}`);
}
core.setOutput("should-scan", String(result.exitCode === 1));
scan:
name: Scan iOS dead code
+132
View File
@@ -0,0 +1,132 @@
name: Linux App Release
on:
workflow_dispatch:
inputs:
tag:
description: Existing OpenClaw release tag to receive Linux companion bundles, for example v2026.7.1
required: true
type: string
permissions:
contents: write
concurrency:
group: linux-app-release-${{ inputs.tag }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
build_and_attach:
name: Build and attach Linux companion bundles
# Oldest supported build base: bundles link against this glibc, so newer
# runners would silently drop Ubuntu 22.04/Debian 12 users.
runs-on: ubuntu-22.04
timeout-minutes: 45
steps:
- name: Validate tag input format
env:
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*)?$ ]]; then
# Alpha/beta semver suffixes map to a Debian revision that sorts
# AFTER the plain stable version, breaking beta-to-stable upgrades;
# numeric stable revisions (-N) order correctly and stay allowed.
echo "Linux bundles ship for stable release tags only (vYYYY.M.PATCH or vYYYY.M.PATCH-N); got: ${RELEASE_TAG}"
exit 1
fi
- name: Checkout selected tag
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: refs/tags/${{ inputs.tag }}
fetch-depth: 0
persist-credentials: false
- name: Ensure tag commit is reachable from main
env:
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
git fetch --quiet origin main
tag_sha=$(git rev-parse "refs/tags/${RELEASE_TAG}^{commit}")
if ! git merge-base --is-ancestor "${tag_sha}" origin/main; then
echo "Tag ${RELEASE_TAG} (${tag_sha}) is not reachable from main; Linux bundles ship for main-based releases only."
exit 1
fi
- name: Ensure matching GitHub release exists
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.tag }}
run: gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" --json tagName --jq .tagName
- name: Install Tauri system dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
build-essential \
curl \
file \
libayatana-appindicator3-dev \
librsvg2-dev \
libssl-dev \
libwebkit2gtk-4.1-dev \
libxdo-dev \
wget
- name: Install Rust
run: rustup toolchain install stable --profile minimal
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
install-deps: "false"
- name: Build Linux companion bundles
working-directory: apps/linux/src-tauri
env:
# appimagetool strips fail on CI runners; Tauri documents NO_STRIP for Actions.
NO_STRIP: "true"
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
version="${RELEASE_TAG#v}"
# Stamp the release version into the bundles; the committed manifests
# keep a placeholder 0.1.0 so releases stay tag-driven.
pnpm dlx @tauri-apps/cli@2.11.4 build --bundles deb,appimage \
--config "{\"version\":\"${version}\"}"
- name: Verify and rename bundles for the release tag
env:
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
version="${RELEASE_TAG#v}"
deb=$(ls apps/linux/src-tauri/target/release/bundle/deb/*.deb)
deb_version=$(dpkg-deb -f "${deb}" Version)
if [[ "${deb_version}" != "${version}"* ]]; then
echo "Debian package version '${deb_version}' does not match release version '${version}'"
exit 1
fi
mkdir -p dist/linux-app
cp "${deb}" "dist/linux-app/OpenClaw-${version}-amd64.deb"
cp apps/linux/src-tauri/target/release/bundle/appimage/*.AppImage \
"dist/linux-app/OpenClaw-${version}-amd64.AppImage"
(cd dist/linux-app && sha256sum ./* > SHA256SUMS.linux-app.txt)
cat dist/linux-app/SHA256SUMS.linux-app.txt
- name: Attach bundles to the release
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
gh release upload "${RELEASE_TAG}" \
--repo "${GITHUB_REPOSITORY}" \
--clobber \
dist/linux-app/*
+88
View File
@@ -0,0 +1,88 @@
name: Linux App
on:
pull_request:
paths:
- "apps/linux/**"
- ".github/workflows/linux-app.yml"
workflow_dispatch:
concurrency:
group: linux-app-${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
permissions:
contents: read
jobs:
build:
name: Build Linux companion
# Match the release build base so PR artifacts have the same glibc floor.
runs-on: ubuntu-22.04
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Install Tauri system dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
build-essential \
curl \
file \
libayatana-appindicator3-dev \
librsvg2-dev \
libssl-dev \
libwebkit2gtk-4.1-dev \
libxdo-dev \
wget
- name: Install Rust
run: rustup toolchain install stable --profile minimal --component rustfmt
- name: Cache Cargo
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: |
~/.cargo/registry
~/.cargo/git
apps/linux/src-tauri/target
key: linux-app-${{ runner.os }}-${{ hashFiles('apps/linux/src-tauri/Cargo.lock') }}
restore-keys: |
linux-app-${{ runner.os }}-
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
install-deps: "false"
- name: Check Rust formatting
working-directory: apps/linux/src-tauri
run: cargo +stable fmt --check
- name: Build Linux companion bundles
working-directory: apps/linux/src-tauri
env:
# appimagetool strips fail on CI runners; Tauri documents NO_STRIP for Actions.
NO_STRIP: "true"
run: pnpm dlx @tauri-apps/cli@2.11.4 build --bundles deb,appimage
- name: Upload bundles
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: openclaw-linux-companion
retention-days: 14
if-no-files-found: error
path: |
apps/linux/src-tauri/target/release/bundle/deb/*.deb
apps/linux/src-tauri/target/release/bundle/appimage/*.AppImage
+238
View File
@@ -0,0 +1,238 @@
name: macOS Periphery Dead Code
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review, converted_to_draft]
workflow_dispatch:
concurrency:
group: macos-periphery-${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
permissions:
contents: read
jobs:
scope:
name: Detect macOS scan scope
runs-on: ubuntu-24.04
outputs:
should-scan: ${{ steps.scope.outputs.should-scan }}
steps:
- name: Checkout
if: github.event_name == 'pull_request' && github.event.pull_request.draft == false
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Ensure base commit
if: github.event_name == 'pull_request' && github.event.pull_request.draft == false
uses: ./.github/actions/ensure-base-commit
with:
base-sha: ${{ github.event.pull_request.base.sha }}
fetch-ref: ${{ github.event.pull_request.base.ref }}
- name: Detect changed paths
id: scope
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
if (context.eventName === "workflow_dispatch") {
core.setOutput("should-scan", "true");
return;
}
if (context.payload.pull_request?.draft) {
core.setOutput("should-scan", "false");
return;
}
const baseSha = context.payload.pull_request?.base?.sha;
if (!baseSha) {
throw new Error("missing pull request base SHA");
}
const result = await exec.getExecOutput("git", [
"diff",
"--quiet",
baseSha,
"HEAD",
"--",
"apps/macos/",
".github/workflows/macos-periphery.yml",
".github/workflows/ios-periphery-comment.yml",
"apps/macos/.periphery.yml",
], { ignoreReturnCode: true, silent: true });
if (result.exitCode !== 0 && result.exitCode !== 1) {
throw new Error(`git diff failed with exit code ${result.exitCode}`);
}
core.setOutput("should-scan", String(result.exitCode === 1));
scan:
name: Scan macOS dead code
needs: scope
if: ${{ needs.scope.outputs.should-scan == 'true' }}
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-26' || (github.repository == 'openclaw/openclaw' && 'blacksmith-12vcpu-macos-26' || 'macos-26') }}
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Verify Xcode
run: |
set -euo pipefail
for xcode_app in /Applications/Xcode_26.5.app /Applications/Xcode-26.5.0.app; do
if [ -d "$xcode_app/Contents/Developer" ]; then
sudo xcode-select -s "$xcode_app/Contents/Developer"
break
fi
done
xcodebuild -version
xcode_version="$(xcodebuild -version | awk 'NR == 1 { print $2 }')"
if [[ "$xcode_version" != 26.* ]]; then
echo "error: expected Xcode 26.x, got $xcode_version" >&2
exit 1
fi
swift --version
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Install Periphery
run: |
brew update
brew install periphery
- name: Run Periphery
run: |
set -euo pipefail
output_dir="$RUNNER_TEMP/macos-periphery"
mkdir -p "$output_dir"
cd apps/macos
set +e
periphery scan \
--config .periphery.yml \
--strict \
--format json \
--write-results "$output_dir/periphery.json" \
>"$output_dir/periphery.stdout.json" \
2>"$output_dir/periphery.stderr.log"
periphery_status="$?"
set -e
printf '%s\n' "$periphery_status" >"$output_dir/periphery.status"
if [ ! -s "$output_dir/periphery.json" ]; then
cp "$output_dir/periphery.stdout.json" "$output_dir/periphery.json"
fi
- name: Build Periphery report
run: |
set -euo pipefail
node <<'NODE'
const fs = require("node:fs");
const path = require("node:path");
const outputDir = path.join(process.env.RUNNER_TEMP, "macos-periphery");
const read = (name) => {
const file = path.join(outputDir, name);
return fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
};
const status = Number(read("periphery.status").trim() || "1");
let findings = null;
for (const name of ["periphery.json", "periphery.stdout.json"]) {
try {
const parsed = JSON.parse(read(name));
if (Array.isArray(parsed)) {
findings = parsed;
break;
}
} catch {}
}
const escapeCommandData = (value) =>
String(value ?? "")
.replaceAll("%", "%25")
.replaceAll("\r", "%0D")
.replaceAll("\n", "%0A");
const escapeCommandProperty = (value) =>
escapeCommandData(value)
.replaceAll(":", "%3A")
.replaceAll(",", "%2C");
const rows = (findings ?? []).map((finding) => {
const location = String(finding.location ?? "");
const [file, line] = location.split(":");
const repoFile = file ? `apps/macos/${file}` : "";
return {
file: repoFile,
line: line || "",
kind: String(finding.kind ?? ""),
name: String(finding.name ?? ""),
};
});
for (const row of rows) {
if (!row.file) continue;
const line = row.line ? `,line=${escapeCommandProperty(row.line)}` : "";
const title = `${row.kind || "Unused code"} ${row.name}`.trim();
console.log(`::error file=${escapeCommandProperty(row.file)}${line},title=Dead Swift code::${escapeCommandData(title)}`);
}
let shouldFail = "1";
let summary = "";
if (findings === null) {
summary = [
"### macOS Periphery",
"",
"Periphery did not complete. Check the workflow artifact for stdout/stderr.",
].join("\n");
} else if (rows.length === 0 && status === 0) {
shouldFail = "0";
summary = [
"### macOS Periphery",
"",
"No dead Swift code found.",
].join("\n");
} else if (rows.length > 0) {
summary = [
"### macOS Periphery",
"",
`Found ${rows.length} dead Swift code ${rows.length === 1 ? "symbol" : "symbols"}. See the PR comment or workflow artifact for details.`,
].join("\n");
} else {
summary = [
"### macOS Periphery",
"",
"Periphery exited with a non-zero status before producing findings. Check the workflow artifact for stdout/stderr.",
].join("\n");
}
fs.writeFileSync(path.join(outputDir, "should-fail.txt"), `${shouldFail}\n`);
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary.trim()}\n`);
NODE
- name: Upload Periphery report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: macos-periphery-dead-code-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/macos-periphery
if-no-files-found: error
retention-days: 14
- name: Fail on dead code
run: |
set -euo pipefail
test "$(cat "$RUNNER_TEMP/macos-periphery/should-fail.txt")" = "0"
-6
View File
@@ -54,12 +54,6 @@ jobs:
node-version: ${{ env.NODE_VERSION }}
install-bun: "false"
- name: Ensure matching GitHub release exists
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.tag }}
run: gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null
- name: Build
run: pnpm build
@@ -13,6 +13,8 @@ on:
- apps/.i18n/native/**
- apps/.i18n/native-source.json
- scripts/control-ui-i18n.ts
- scripts/android-app-i18n.ts
- scripts/apple-app-i18n.ts
- scripts/native-app-i18n.ts
- ui/src/i18n/.i18n/glossary.*.json
- .github/actions/create-generated-pr-tokens/action.yml
@@ -264,8 +266,17 @@ jobs:
- name: Refresh shared native inventory
run: node --import tsx scripts/native-app-i18n.ts sync --write
- name: Refresh Android native resources
run: node --import tsx scripts/android-app-i18n.ts sync
- name: Refresh Apple native resources
run: node --import tsx scripts/apple-app-i18n.ts sync-ios --write
- name: Validate native locale refresh
run: node --import tsx scripts/native-app-i18n.ts check
run: |
node --import tsx scripts/native-app-i18n.ts check
node --import tsx scripts/android-app-i18n.ts check
node --import tsx scripts/apple-app-i18n.ts check
- name: Open or update generated locale PR
uses: ./.github/actions/publish-generated-pr
@@ -281,6 +292,15 @@ jobs:
generated-paths: |
apps/.i18n/native
apps/.i18n/native-source.json
apps/.i18n/apple-translation-contradictions.json
apps/android/app/src/main/java/ai/openclaw/app/i18n/NativeStringResources.kt
apps/android/app/src/main/res/values*/assistant.xml
apps/android/app/src/main/res/values*/strings.xml
apps/ios/Resources/Localizable.xcstrings
apps/ios/Sources/*.lproj/InfoPlist.strings
apps/ios/WatchApp/*.lproj/InfoPlist.strings
apps/ios/ShareExtension/*.lproj/InfoPlist.strings
apps/ios/ActivityWidget/*.lproj/InfoPlist.strings
invalidation-paths: |
apps/android/app/src/main
apps/ios
@@ -288,6 +308,8 @@ jobs:
apps/macos/Package.swift
apps/shared/OpenClawKit/Sources
scripts/control-ui-i18n.ts
scripts/android-app-i18n.ts
scripts/apple-app-i18n.ts
scripts/native-app-i18n.ts
ui/src/i18n/.i18n/glossary.*.json
.github/actions/create-generated-pr-tokens/action.yml
@@ -310,3 +332,5 @@ jobs:
- [Locale refresh run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
- `node --import tsx scripts/native-app-i18n.ts check`
- `node --import tsx scripts/android-app-i18n.ts check`
- `node --import tsx scripts/apple-app-i18n.ts check`
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: "22.19.0"
node-version: "22.22.3"
install-bun: "false"
- name: Configure Node test resources
@@ -1197,7 +1197,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)"
if [[ "${{ steps.plan.outputs.needs_live_image }}" == "1" ]]; then
OPENCLAW_DOCKER_BUILD_ON_MISSING=1 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" bash .release-harness/scripts/test-live-build-docker.sh
OPENCLAW_SKIP_DOCKER_BUILD=0 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" bash .release-harness/scripts/test-live-build-docker.sh
fi
node .release-harness/scripts/test-docker-all.mjs
@@ -1525,7 +1525,7 @@ jobs:
export OPENCLAW_DOCKER_ALL_TIMINGS_FILE=".artifacts/docker-tests/targeted-${ARTIFACT_SUFFIX}-timings.json"
export OPENCLAW_DOCKER_ALL_PNPM_COMMAND="$(command -v pnpm)"
if [[ "${{ steps.plan.outputs.needs_live_image }}" == "1" ]]; then
OPENCLAW_DOCKER_BUILD_ON_MISSING=1 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" bash .release-harness/scripts/test-live-build-docker.sh
OPENCLAW_SKIP_DOCKER_BUILD=0 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" bash .release-harness/scripts/test-live-build-docker.sh
fi
export OPENCLAW_DOCKER_ALL_BUILD=0
@@ -1717,7 +1717,7 @@ jobs:
export OPENCLAW_DOCKER_ALL_TIMINGS_FILE=".artifacts/docker-tests/release-openwebui-timings.json"
export OPENCLAW_DOCKER_ALL_PNPM_COMMAND="$(command -v pnpm)"
if [[ "${{ steps.plan.outputs.needs_live_image }}" == "1" ]]; then
OPENCLAW_DOCKER_BUILD_ON_MISSING=1 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" bash .release-harness/scripts/test-live-build-docker.sh
OPENCLAW_SKIP_DOCKER_BUILD=0 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" bash .release-harness/scripts/test-live-build-docker.sh
fi
node .release-harness/scripts/test-docker-all.mjs
+2 -2
View File
@@ -50,7 +50,7 @@ on:
kova_ref:
description: openclaw/Kova Git ref to install
required: false
default: 2b02b7d33418db0c6952c4cf8fe8a608e7964859
default: 678ff0b764b8786c2e436efbe4efac7d9aac10f8
type: string
dispatch_id:
description: Optional parent workflow dispatch identifier
@@ -153,7 +153,7 @@ jobs:
include_filters: "scenario:agent-cold-warm-message"
expected_release_entries: "agent-cold-warm-message:mock-openai-provider"
env:
KOVA_REF: ${{ inputs.kova_ref || '2b02b7d33418db0c6952c4cf8fe8a608e7964859' }}
KOVA_REF: ${{ inputs.kova_ref || '678ff0b764b8786c2e436efbe4efac7d9aac10f8' }}
KOVA_HOME: ${{ github.workspace }}/.artifacts/kova/home/${{ matrix.lane }}
PERFORMANCE_HELPER_DIR: ${{ github.workspace }}/.artifacts/performance-workflow
REPORT_DIR: ${{ github.workspace }}/.artifacts/kova/reports/${{ matrix.lane }}
+145 -59
View File
@@ -1110,13 +1110,17 @@ jobs:
retention-days: 14
if-no-files-found: error
qa_lab_runtime_parity_release_checks:
name: Run QA Lab runtime parity lane
qa_lab_runtime_parity_tier_release_checks:
name: Run QA Lab runtime parity tier (${{ matrix.tier }})
needs: [resolve_target]
if: contains(fromJSON('["all","qa","qa-parity"]'), needs.resolve_target.outputs.rerun_group)
continue-on-error: true
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
tier: ${{ fromJSON(needs.resolve_target.outputs.run_release_soak == 'true' && '["agentic","standard","soak"]' || '["agentic","standard"]') }}
permissions:
contents: read
env:
@@ -1149,76 +1153,52 @@ jobs:
NODE_OPTIONS: --max-old-space-size=8192
run: node scripts/build-all.mjs qaRuntime
- name: Run runtime parity lane
- name: Run runtime parity tier
id: runtime_parity_lane
env:
RUNTIME_PARITY_TIER: ${{ matrix.tier }}
run: |
set -euo pipefail
case "$RUNTIME_PARITY_TIER" in
agentic)
tier_args=(--parity-pack agentic)
output_dir="runtime-parity"
;;
standard|soak)
tier_args=(--runtime-parity-tier "$RUNTIME_PARITY_TIER")
output_dir="runtime-parity-${RUNTIME_PARITY_TIER}"
;;
*)
echo "Unknown runtime parity tier: $RUNTIME_PARITY_TIER" >&2
exit 1
;;
esac
pnpm openclaw qa suite \
--provider-mode mock-openai \
--parity-pack agentic \
"${tier_args[@]}" \
--concurrency "${QA_PARITY_CONCURRENCY}" \
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model "openai/gpt-5.6-luna-alt" \
--runtime-pair openclaw,codex \
--output-dir ".artifacts/qa-e2e/runtime-parity"
--output-dir ".artifacts/qa-e2e/${output_dir}"
- name: Run standard runtime parity tier
id: runtime_parity_standard_lane
if: ${{ always() && steps.runtime_parity_lane.outcome != 'skipped' && steps.runtime_parity_lane.outcome != 'cancelled' }}
run: |
set -euo pipefail
pnpm openclaw qa suite \
--provider-mode mock-openai \
--runtime-parity-tier standard \
--concurrency "${QA_PARITY_CONCURRENCY}" \
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model "openai/gpt-5.6-luna-alt" \
--runtime-pair openclaw,codex \
--output-dir ".artifacts/qa-e2e/runtime-parity-standard"
- name: Run soak runtime parity tier
id: runtime_parity_soak_lane
if: ${{ always() && needs.resolve_target.outputs.run_release_soak == 'true' && steps.runtime_parity_lane.outcome != 'skipped' && steps.runtime_parity_lane.outcome != 'cancelled' }}
run: |
set -euo pipefail
pnpm openclaw qa suite \
--provider-mode mock-openai \
--runtime-parity-tier soak \
--concurrency "${QA_PARITY_CONCURRENCY}" \
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model "openai/gpt-5.6-luna-alt" \
--runtime-pair openclaw,codex \
--output-dir ".artifacts/qa-e2e/runtime-parity-soak"
- name: Generate runtime parity report
- name: Generate runtime parity tier report
id: generate_runtime_parity_report
if: always()
env:
RUNTIME_PARITY_TIER: ${{ matrix.tier }}
run: |
set -euo pipefail
pnpm openclaw qa parity-report \
--repo-root . \
--runtime-axis \
--summary .artifacts/qa-e2e/runtime-parity/qa-suite-summary.json \
--output-dir .artifacts/qa-e2e/runtime-parity-report
- name: Generate standard runtime parity report
id: generate_runtime_parity_standard_report
if: always()
run: |
set -euo pipefail
pnpm openclaw qa parity-report \
--repo-root . \
--runtime-axis \
--summary .artifacts/qa-e2e/runtime-parity-standard/qa-suite-summary.json \
--output-dir .artifacts/qa-e2e/runtime-parity-standard-report
- name: Generate soak runtime parity report
id: generate_runtime_parity_soak_report
if: ${{ always() && needs.resolve_target.outputs.run_release_soak == 'true' && steps.runtime_parity_soak_lane.outcome != 'skipped' && steps.runtime_parity_soak_lane.outcome != 'cancelled' }}
run: |
set -euo pipefail
summary=".artifacts/qa-e2e/runtime-parity-soak/qa-suite-summary.json"
if [[ ! -f "$summary" ]]; then
if [[ "$RUNTIME_PARITY_TIER" == "agentic" ]]; then
output_dir="runtime-parity"
else
output_dir="runtime-parity-${RUNTIME_PARITY_TIER}"
fi
summary=".artifacts/qa-e2e/${output_dir}/qa-suite-summary.json"
if [[ "$RUNTIME_PARITY_TIER" == "soak" && ! -f "$summary" ]]; then
echo "No soak runtime parity summary was produced."
exit 0
fi
@@ -1226,7 +1206,113 @@ jobs:
--repo-root . \
--runtime-axis \
--summary "$summary" \
--output-dir .artifacts/qa-e2e/runtime-parity-soak-report
--output-dir ".artifacts/qa-e2e/${output_dir}-report"
- name: Upload runtime parity tier artifacts
id: upload_runtime_parity_artifacts
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: release-qa-runtime-parity-tier-${{ matrix.tier }}-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: error
- name: Record runtime parity tier status
if: always()
shell: bash
env:
RELEASE_CHECK_JOB: qa_lab_runtime_parity_tier_release_checks
RELEASE_CHECK_VARIANT: ${{ matrix.tier }}
RELEASE_CHECK_TARGET_SHA: ${{ needs.resolve_target.outputs.revision }}
JOB_STATUS: ${{ job.status }}
RELEASE_CHECK_STEP_OUTCOMES: ${{ steps.runtime_parity_lane.outcome }} ${{ steps.generate_runtime_parity_report.outcome }} ${{ steps.upload_runtime_parity_artifacts.outcome }}
run: *record_release_check_advisory_status
- name: Upload runtime parity tier status
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: release-internal-status-qa-runtime-parity-tier-${{ matrix.tier }}-${{ needs.resolve_target.outputs.revision }}-${{ github.run_id }}-${{ github.run_attempt }}
path: .artifacts/release-check-status/qa_lab_runtime_parity_tier_release_checks-${{ matrix.tier }}-${{ github.run_id }}-${{ github.run_attempt }}.env
retention-days: 14
if-no-files-found: error
qa_lab_runtime_parity_release_checks:
name: Run QA Lab runtime parity lane
needs: [resolve_target, qa_lab_runtime_parity_tier_release_checks]
if: always() && contains(fromJSON('["all","qa","qa-parity"]'), needs.resolve_target.outputs.rerun_group)
continue-on-error: true
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
actions: read
steps:
- name: Download runtime parity tier artifacts
id: download_runtime_parity_artifacts
if: always()
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: release-qa-runtime-parity-tier-*-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
merge-multiple: true
- name: Download runtime parity tier statuses
id: download_runtime_parity_statuses
if: always()
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: release-internal-status-qa-runtime-parity-tier-*-${{ needs.resolve_target.outputs.revision }}-${{ github.run_id }}-${{ github.run_attempt }}
path: .artifacts/release-check-status/
merge-multiple: true
- name: Verify runtime parity tier statuses
id: verify_runtime_parity_statuses
if: always()
shell: bash
env:
RELEASE_CHECK_TARGET_SHA: ${{ needs.resolve_target.outputs.revision }}
RUN_RELEASE_SOAK: ${{ needs.resolve_target.outputs.run_release_soak }}
run: |
set -euo pipefail
tiers=(agentic standard)
if [[ "$RUN_RELEASE_SOAK" == "true" ]]; then
tiers+=(soak)
fi
status_field() {
local file="$1"
local key="$2"
awk -v key="$key" '
index($0, key "=") == 1 {
count += 1
value = substr($0, length(key) + 2)
}
END {
if (count != 1) {
exit 1
}
print value
}
' "$file"
}
for tier in "${tiers[@]}"; do
status_path=".artifacts/release-check-status/qa_lab_runtime_parity_tier_release_checks-${tier}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.env"
run_id="$(status_field "$status_path" run_id)"
run_attempt="$(status_field "$status_path" run_attempt)"
target_sha="$(status_field "$status_path" target_sha)"
job_name="$(status_field "$status_path" job)"
variant="$(status_field "$status_path" variant)"
status="$(status_field "$status_path" status)"
if [[ "$run_id" != "$GITHUB_RUN_ID" ||
"$run_attempt" != "$GITHUB_RUN_ATTEMPT" ||
"$target_sha" != "$RELEASE_CHECK_TARGET_SHA" ||
"$job_name" != "qa_lab_runtime_parity_tier_release_checks" ||
"$variant" != "$tier" ||
"$status" != "success" ]]; then
echo "Runtime parity tier ${tier} did not produce matching successful evidence." >&2
exit 1
fi
done
- name: Upload runtime parity artifacts
id: upload_runtime_parity_artifacts
@@ -1245,7 +1331,7 @@ jobs:
RELEASE_CHECK_JOB: qa_lab_runtime_parity_release_checks
RELEASE_CHECK_TARGET_SHA: ${{ needs.resolve_target.outputs.revision }}
JOB_STATUS: ${{ job.status }}
RELEASE_CHECK_STEP_OUTCOMES: ${{ steps.runtime_parity_lane.outcome }} ${{ steps.runtime_parity_standard_lane.outcome }} ${{ steps.runtime_parity_soak_lane.outcome }} ${{ steps.generate_runtime_parity_report.outcome }} ${{ steps.generate_runtime_parity_standard_report.outcome }} ${{ steps.generate_runtime_parity_soak_report.outcome }} ${{ steps.upload_runtime_parity_artifacts.outcome }}
RELEASE_CHECK_STEP_OUTCOMES: ${{ steps.download_runtime_parity_artifacts.outcome }} ${{ steps.download_runtime_parity_statuses.outcome }} ${{ steps.verify_runtime_parity_statuses.outcome }} ${{ steps.upload_runtime_parity_artifacts.outcome }}
run: *record_release_check_advisory_status
- name: Upload advisory status
+23 -3
View File
@@ -99,6 +99,7 @@ jobs:
steps:
- name: Validate inputs
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.tag }}
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
@@ -111,6 +112,7 @@ jobs:
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
RELEASE_PROFILE: ${{ inputs.release_profile }}
WORKFLOW_REF: ${{ github.ref }}
WORKFLOW_SHA: ${{ github.sha }}
run: |
set -euo pipefail
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-(alpha|beta)\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then
@@ -171,8 +173,24 @@ jobs:
if [[ "${RELEASE_TAG}" == *"-alpha."* && "${RELEASE_NPM_DIST_TAG}" == "alpha" && "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
tideclaw_alpha_publish=true
fi
if [[ "${WORKFLOW_REF}" != "refs/heads/main" && "${tideclaw_alpha_publish}" != "true" ]]; then
echo "OpenClaw Release Publish must use trusted main workflow tooling, except for a matching Tideclaw alpha branch." >&2
sha_pinned_release_publish=false
if [[ "${WORKFLOW_REF}" =~ ^refs/heads/release-publish/([a-f0-9]{12})-[1-9][0-9]*$ ]]; then
if [[ "${WORKFLOW_SHA:0:12}" != "${BASH_REMATCH[1]}" ]]; then
echo "SHA-pinned release publish branch does not match workflow SHA ${WORKFLOW_SHA}." >&2
exit 1
fi
merge_base_sha="$(
gh api "repos/${GITHUB_REPOSITORY}/compare/${WORKFLOW_SHA}...main" \
--jq '.merge_base_commit.sha | select(test("^[a-f0-9]{40}$"))'
)"
if [[ "${merge_base_sha}" != "${WORKFLOW_SHA}" ]]; then
echo "SHA-pinned release publish workflow revision is not reachable from current main." >&2
exit 1
fi
sha_pinned_release_publish=true
fi
if [[ "${WORKFLOW_REF}" != "refs/heads/main" && "${tideclaw_alpha_publish}" != "true" && "${sha_pinned_release_publish}" != "true" ]]; then
echo "OpenClaw Release Publish must use trusted main workflow tooling, a SHA-pinned release-publish branch, or a matching Tideclaw alpha branch." >&2
exit 1
fi
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && "${PLUGIN_PUBLISH_SCOPE}" != "all-publishable" ]]; then
@@ -607,7 +625,9 @@ jobs:
"${plan_args[@]}" > "${plan_path}"
cat "${plan_path}"
bootstrap_should_dispatch="$(jq -er '.bootstrap.shouldDispatch' "${plan_path}")"
bootstrap_should_dispatch="$(
jq -er '.bootstrap.shouldDispatch | select(type == "boolean") | tostring' "${plan_path}"
)"
echo "bootstrap_should_dispatch=${bootstrap_should_dispatch}" >> "${GITHUB_OUTPUT}"
if [[ "${bootstrap_should_dispatch}" != "true" ]]; then
exit 0
@@ -283,12 +283,6 @@ jobs:
"repos/${GITHUB_REPOSITORY}/compare/${candidate_sha}...main" \
--jq '.status'
)"
git -C .candidate fetch --force --no-tags origin \
'+refs/heads/release/*:refs/remotes/origin/release/*'
git -C .candidate fetch --force --no-tags origin \
'+refs/heads/extended-stable/*:refs/remotes/origin/extended-stable/*'
git -C .candidate fetch --force origin '+refs/tags/v*:refs/tags/v*'
trusted_reason=""
trusted_release_branch=""
if [[ "$compare_status" == "ahead" || "$compare_status" == "identical" ]]; then
@@ -296,31 +290,43 @@ jobs:
else
normalized_ref="${TARGET_REF#refs/heads/}"
if [[ "$normalized_ref" =~ ^(release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*|extended-stable/[0-9]{4}\.[1-9][0-9]*\.33)$ ]]; then
[[ "$(git -C .candidate rev-parse "refs/remotes/origin/${normalized_ref}")" == "$candidate_sha" ]]
branch_sha="$(
git -C .candidate ls-remote --exit-code --refs origin \
"refs/heads/${normalized_ref}" |
awk 'NR == 1 { print $1 } END { if (NR != 1) exit 1 }'
)"
[[ "$branch_sha" == "$candidate_sha" ]]
trusted_reason="release-branch-head"
trusted_release_branch="$normalized_ref"
elif [[ "$TARGET_REF" =~ ^refs/tags/v ]] || [[ "$TARGET_REF" =~ ^v ]]; then
normalized_tag="${TARGET_REF#refs/tags/}"
[[ "$(git -C .candidate rev-parse "refs/tags/${normalized_tag}^{commit}")" == "$candidate_sha" ]]
tag_refs="$(
git -C .candidate ls-remote --exit-code origin \
"refs/tags/${normalized_tag}" "refs/tags/${normalized_tag}^{}"
)"
awk -v sha="$candidate_sha" '$1 == sha { found = 1 } END { exit(found ? 0 : 1) }' \
<<<"$tag_refs"
trusted_reason="release-tag"
elif [[ "$TARGET_REF" =~ ^[a-f0-9]{40}$ && "$TARGET_REF" == "$candidate_sha" ]]; then
matching_release_branches="$(
git -C .candidate for-each-ref \
--format='%(objectname) %(refname:strip=3)' \
refs/remotes/origin/release \
refs/remotes/origin/extended-stable |
awk -v sha="$candidate_sha" '$1 == sha { print $2 }'
gh api --paginate \
"repos/${GITHUB_REPOSITORY}/commits/${candidate_sha}/branches-where-head" \
--jq '.[].name' |
awk '$0 ~ /^release\/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$/ ||
$0 ~ /^extended-stable\/[0-9]{4}\.[1-9][0-9]*\.33$/ { print }'
)"
if [[ "$(wc -l <<<"$matching_release_branches" | tr -d ' ')" == "1" && -n "$matching_release_branches" ]]; then
trusted_reason="release-branch-head"
trusted_release_branch="$matching_release_branches"
else
while IFS= read -r tag_ref; do
if [[ "$(git -C .candidate rev-parse "${tag_ref}^{commit}")" == "$candidate_sha" ]]; then
trusted_reason="release-tag"
break
fi
done < <(git -C .candidate for-each-ref --format='%(refname)' 'refs/tags/v*')
matching_release_tags="$(
git -C .candidate ls-remote origin 'refs/tags/v*' |
awk -v sha="$candidate_sha" '$1 == sha { sub(/\^\{\}$/, "", $2); print $2 }' |
sort -u
)"
if [[ -n "$matching_release_tags" ]]; then
trusted_reason="release-tag"
fi
fi
fi
fi
@@ -974,12 +980,6 @@ jobs:
"repos/${GITHUB_REPOSITORY}/compare/${candidate_sha}...main" \
--jq '.status'
)"
git fetch --force --no-tags origin \
'+refs/heads/release/*:refs/remotes/origin/release/*'
git fetch --force --no-tags origin \
'+refs/heads/extended-stable/*:refs/remotes/origin/extended-stable/*'
git fetch --force origin '+refs/tags/v*:refs/tags/v*'
trusted_reason=""
trusted_release_branch=""
if [[ "$compare_status" == "ahead" || "$compare_status" == "identical" ]]; then
@@ -987,31 +987,42 @@ jobs:
else
normalized_ref="${TARGET_REF#refs/heads/}"
if [[ "$normalized_ref" =~ ^(release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*|extended-stable/[0-9]{4}\.[1-9][0-9]*\.33)$ ]]; then
[[ "$(git rev-parse "refs/remotes/origin/${normalized_ref}")" == "$candidate_sha" ]]
branch_sha="$(
git ls-remote --exit-code --refs origin "refs/heads/${normalized_ref}" |
awk 'NR == 1 { print $1 } END { if (NR != 1) exit 1 }'
)"
[[ "$branch_sha" == "$candidate_sha" ]]
trusted_reason="release-branch-head"
trusted_release_branch="$normalized_ref"
elif [[ "$TARGET_REF" =~ ^refs/tags/v ]] || [[ "$TARGET_REF" =~ ^v ]]; then
normalized_tag="${TARGET_REF#refs/tags/}"
[[ "$(git rev-parse "refs/tags/${normalized_tag}^{commit}")" == "$candidate_sha" ]]
tag_refs="$(
git ls-remote --exit-code origin \
"refs/tags/${normalized_tag}" "refs/tags/${normalized_tag}^{}"
)"
awk -v sha="$candidate_sha" '$1 == sha { found = 1 } END { exit(found ? 0 : 1) }' \
<<<"$tag_refs"
trusted_reason="release-tag"
elif [[ "$TARGET_REF" =~ ^[a-f0-9]{40}$ && "$TARGET_REF" == "$candidate_sha" ]]; then
matching_release_branches="$(
git for-each-ref \
--format='%(objectname) %(refname:strip=3)' \
refs/remotes/origin/release \
refs/remotes/origin/extended-stable |
awk -v sha="$candidate_sha" '$1 == sha { print $2 }'
gh api --paginate \
"repos/${GITHUB_REPOSITORY}/commits/${candidate_sha}/branches-where-head" \
--jq '.[].name' |
awk '$0 ~ /^release\/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$/ ||
$0 ~ /^extended-stable\/[0-9]{4}\.[1-9][0-9]*\.33$/ { print }'
)"
if [[ "$(wc -l <<<"$matching_release_branches" | tr -d ' ')" == "1" && -n "$matching_release_branches" ]]; then
trusted_reason="release-branch-head"
trusted_release_branch="$matching_release_branches"
else
while IFS= read -r tag_ref; do
if [[ "$(git rev-parse "${tag_ref}^{commit}")" == "$candidate_sha" ]]; then
trusted_reason="release-tag"
break
fi
done < <(git for-each-ref --format='%(refname)' 'refs/tags/v*')
matching_release_tags="$(
git ls-remote origin 'refs/tags/v*' |
awk -v sha="$candidate_sha" '$1 == sha { sub(/\^\{\}$/, "", $2); print $2 }' |
sort -u
)"
if [[ -n "$matching_release_tags" ]]; then
trusted_reason="release-tag"
fi
fi
fi
fi
@@ -1244,6 +1255,7 @@ jobs:
OPENCLAW_EMBEDDED_ABORT_SETTLE_TIMEOUT_MS
OPENCLAW_GATEWAY_TOKEN
OPENCLAW_HOME
OPENCLAW_LOG_LEVEL
OPENCLAW_NO_RESPAWN
OPENCLAW_OAUTH_DIR
OPENCLAW_QA_ALLOW_LOCAL_IMAGE_PROVIDER
@@ -1482,6 +1494,71 @@ jobs:
pgrep -u "$SUT_UID" >/dev/null 2>&1
}
capture_live_model_config() {
local config_path="${1:?}"
local config_id diagnostics_dir diagnostics_real proof_bytes proof_path proof_tmp
umask 077
diagnostics_dir="${EVIDENCE_ROOT}/trusted-runtime-diagnostics"
install -d -o "$RUNNER_UID" -g "$RUNNER_GID" -m 0700 "$diagnostics_dir"
[[ ! -L "$diagnostics_dir" ]]
diagnostics_real="$(realpath -e "$diagnostics_dir")"
[[ "$diagnostics_real" == "$diagnostics_dir" ]]
proof_tmp="${RUNTIME_ROOT}/gateway-model-config-${BASHPID}.json"
# Capture only bounded routing identifiers before the QA suite removes its temp config.
jq -c '
def bounded_text:
if type == "string" then .[0:256] else null end;
def bounded_texts:
if type == "array" then
[.[:64][] | bounded_text | select(. != null)]
else
[]
end;
{
agentDefaultModel: (
if (.agents.defaults.model | type) == "string" then
{
primary: (.agents.defaults.model | bounded_text),
fallbacks: []
}
elif (.agents.defaults.model | type) == "object" then
{
primary: (.agents.defaults.model.primary | bounded_text),
fallbacks: (.agents.defaults.model.fallbacks | bounded_texts)
}
else
{primary: null, fallbacks: []}
end
),
agentModelRefs: (
((.agents.defaults.models // {}) | keys | sort)[:128] |
map(bounded_text | select(. != null))
),
providers: (
(.models.providers // {}) |
to_entries |
sort_by(.key) |
.[:32] |
map({
id: (.key | bounded_text),
api: (.value.api | bounded_text),
modelIds: ([.value.models[]?.id][:128] | map(bounded_text | select(. != null)))
})
)
}
' "$config_path" >"$proof_tmp"
proof_bytes="$(stat -c '%s' "$proof_tmp")"
((proof_bytes > 0 && proof_bytes <= 65536))
# Content addressing deduplicates credential-specific temp configs
# that project to the same bounded routing proof.
config_id="$(sha256sum "$proof_tmp" | awk '{print $1}')"
proof_path="${diagnostics_dir}/gateway-model-config-${config_id}.json"
chown "$RUNNER_UID:$RUNNER_GID" "$proof_tmp"
chmod 0600 "$proof_tmp"
mv -f "$proof_tmp" "$proof_path"
}
terminate_sut_uid() {
pkill -TERM -U "$SUT_UID" >/dev/null 2>&1 || true
pkill -TERM -u "$SUT_UID" >/dev/null 2>&1 || true
@@ -1631,6 +1708,7 @@ jobs:
config_path="${temp_root}/openclaw.json"
[[ -f "$config_path" && ! -L "$config_path" ]]
[[ "$(realpath -e "$config_path")" == "$config_path" ]]
capture_live_model_config "$config_path"
export OPENCLAW_QA_TEMP_ROOT="$temp_root"
export HOME="${temp_root}/home"
@@ -1988,7 +2066,8 @@ jobs:
- name: Validate required QA credential env
id: validate_credentials
env:
CREDENTIAL_ACQUIRE_TIMEOUT_MS: "60000"
# Release trains share a bounded Telegram pool; wait through overlapping lanes.
CREDENTIAL_ACQUIRE_TIMEOUT_MS: "600000"
JOB_TIMEOUT_MINUTES: "60"
LEASE_TTL_MS: "7200000"
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
@@ -2010,7 +2089,8 @@ jobs:
EVIDENCE_ROOT: ${{ steps.create_sut.outputs.evidence_root }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "60000"
# Keep the CLI lease wait aligned with the validated workflow contract above.
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "600000"
OPENCLAW_QA_CREDENTIAL_LEASE_TTL_MS: "7200000"
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_SUT_FORBIDDEN_SENTINEL: trusted-parent-${{ github.run_id }}-${{ github.run_attempt }}
@@ -2022,8 +2102,10 @@ jobs:
OPENCLAW_QA_TELEGRAM_SUT_PROCESS_BOUNDARY_DIR: ${{ steps.create_sut.outputs.boundary_evidence_dir }}
OPENCLAW_QA_TELEGRAM_SUT_RUNTIME_EXECUTABLE: ${{ steps.create_sut.outputs.runtime_executable }}
OPENCLAW_QA_TELEGRAM_SUT_UID: ${{ steps.create_sut.outputs.uid }}
OPENCLAW_LOG_LEVEL: trace
SUT_RUNTIME_ROOT: ${{ steps.create_sut.outputs.runtime_root }}
SUT_UID: ${{ steps.create_sut.outputs.uid }}
TARGET_REF: ${{ inputs.target_ref }}
TARGET_SHA: ${{ inputs.target_sha }}
shell: bash
run: |
@@ -2043,6 +2125,15 @@ jobs:
trap terminate_sut_uid_on_exit EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
qa_model="mock-openai/gpt-5.6-luna"
qa_alt_model="mock-openai/gpt-5.6-luna-alt"
candidate_telegram_qa="$CANDIDATE_ROOT/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts"
if [[ -f "$candidate_telegram_qa" ]] &&
grep -Fq '"openai/gpt-5.5": {' "$candidate_telegram_qa" &&
! grep -Fq '"openai/gpt-5.6-luna": {' "$candidate_telegram_qa"; then
qa_model="mock-openai/gpt-5.5"
qa_alt_model="mock-openai/gpt-5.5-alt"
fi
{
printf 'trusted_harness_sha=%s\n' "${{ needs.trusted_identity.outputs.workflow_sha }}"
printf 'candidate_runtime_plugin_sha=%s\n' "$TARGET_SHA"
@@ -2064,8 +2155,8 @@ jobs:
--repo-root "$CANDIDATE_ROOT" \
--output-dir "${EVIDENCE_RELATIVE}/${output_name}" \
--provider-mode mock-openai \
--model mock-openai/gpt-5.6-luna \
--alt-model mock-openai/gpt-5.6-luna-alt \
--model "$qa_model" \
--alt-model "$qa_alt_model" \
--fast \
--credential-source convex \
--credential-role ci \
@@ -2143,12 +2234,217 @@ jobs:
echo "Isolated Telegram SUT UID still owns processes after terminal cleanup." >&2
exit 1
- name: Capture isolated Telegram runtime diagnostics
id: capture_diagnostics
if: always() && steps.terminate_sut.outputs.quiescent == 'true' && steps.run_lane.outputs.output_dir != ''
env:
OUTPUT_DIR: ${{ steps.run_lane.outputs.output_dir }}
RUNTIME_ROOT: ${{ steps.create_sut.outputs.runtime_root }}
RUN_LANE_OUTCOME: ${{ steps.run_lane.outcome }}
shell: bash
run: |
set -euo pipefail
umask 077
[[ "$RUNTIME_ROOT" == "/var/lib/openclaw-telegram-sut-runtime-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" ]]
diagnostics_dir="${OUTPUT_DIR}/trusted-runtime-diagnostics"
install -d -m 0700 "$diagnostics_dir"
trusted_temp_root="$(mktemp -d "${RUNNER_TEMP}/openclaw-telegram-diagnostics.XXXXXX")"
trap 'rm -rf -- "$trusted_temp_root"' EXIT
redactor_script="${trusted_temp_root}/redact-gateway-tail.mts"
cat >"$redactor_script" <<'NODE'
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const [outputPath] = process.argv.slice(2);
const redactorPath = path.resolve(
"extensions/qa-lab/src/gateway-log-redaction.ts",
);
const { redactQaGatewayDebugText } = await import(pathToFileURL(redactorPath).href);
const limitBytes = 131_072;
const maxInputRecordBytes = 1_048_576;
const oversizedMarker = "[truncated oversized gateway log record] ";
const omittedMarker = "[omitted oversized gateway log record]\n";
const safeVerboseMessagePrefixes = [
"embedded run start:",
"[trace:embedded-run] prep stages:",
"[trace:embedded-run] core-plugin-tool stages:",
"embedded run prompt start:",
"[context-diag] pre-prompt:",
"model.call.started",
"model_call_started",
"embedded run prompt end:",
"run cleanup:",
];
const retained = [];
let retainedBytes = 0;
function extractLogTapeMessage(record) {
if (!record || typeof record !== "object" || Array.isArray(record)) {
return undefined;
}
const numericKeys = Object.keys(record)
.filter((key) => /^\d+$/u.test(key))
.sort((left, right) => Number(left) - Number(right));
for (let index = numericKeys.length - 1; index >= 0; index -= 1) {
const value = record[numericKeys[index]];
if (typeof value === "string") {
return value;
}
}
return undefined;
}
function shouldRetainRecord(line) {
let record;
try {
record = JSON.parse(line);
} catch {
return false;
}
const level = record?._meta?.logLevelName;
if (level !== "DEBUG" && level !== "TRACE") {
return true;
}
const message = extractLogTapeMessage(record);
return (
typeof message === "string" &&
safeVerboseMessagePrefixes.some((prefix) => message.startsWith(prefix))
);
}
function boundRedactedLine(line) {
const redactedLine = `${redactQaGatewayDebugText(line)}\n`;
const lineBuffer = Buffer.from(redactedLine);
if (lineBuffer.length <= limitBytes) {
return redactedLine;
}
const markerBytes = Buffer.byteLength(oversizedMarker);
const suffixBuffer = lineBuffer.subarray(
lineBuffer.length - (limitBytes - markerBytes),
);
let suffix = suffixBuffer.toString("utf8");
while (Buffer.byteLength(`${oversizedMarker}${suffix}`) > limitBytes) {
suffix = suffix.slice(1);
}
return `${oversizedMarker}${suffix}`;
}
function retainLine(boundedLine) {
const lineBytes = Buffer.byteLength(boundedLine);
while (retained.length > 0 && retainedBytes + lineBytes > limitBytes) {
const removed = retained.shift();
retainedBytes -= Buffer.byteLength(removed);
}
retained.push(boundedLine);
retainedBytes += lineBytes;
}
let recordParts = [];
let recordBytes = 0;
let recordOmitted = false;
function appendRecordPart(part) {
if (recordOmitted) {
return;
}
if (recordBytes + part.length > maxInputRecordBytes) {
recordParts = [];
recordBytes = 0;
recordOmitted = true;
return;
}
recordParts.push(Buffer.from(part));
recordBytes += part.length;
}
function finishRecord() {
if (recordOmitted) {
retainLine(omittedMarker);
} else {
const line = Buffer.concat(recordParts, recordBytes).toString("utf8").replace(/\r$/u, "");
if (shouldRetainRecord(line)) {
retainLine(boundRedactedLine(line));
}
}
recordParts = [];
recordBytes = 0;
recordOmitted = false;
}
for await (const value of process.stdin) {
const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value);
let offset = 0;
while (offset < chunk.length) {
const newline = chunk.indexOf(0x0a, offset);
const end = newline === -1 ? chunk.length : newline;
appendRecordPart(chunk.subarray(offset, end));
if (newline === -1) {
break;
}
finishRecord();
offset = newline + 1;
}
}
if (recordOmitted || recordBytes > 0) {
finishRecord();
}
fs.writeFileSync(outputPath, retained.join(""), { encoding: "utf8", mode: 0o600 });
NODE
chmod 0600 "$redactor_script"
# The live suite can launch more than eight gateways. Keep the newest
# bounded tails instead of failing diagnostics after a successful lane.
mapfile -d '' -t gateway_logs < <(
sudo find "$RUNTIME_ROOT/tmp" -xdev -type f -name 'openclaw-*.log' -printf '%T@\t%p\0' |
sort -z -nr |
sed -z -n '1,8p' |
cut -z -f2-
)
((${#gateway_logs[@]} <= 8))
if [[ "$RUN_LANE_OUTCOME" != "success" ]]; then
((${#gateway_logs[@]} > 0))
fi
for index in "${!gateway_logs[@]}"; do
log_path="$(sudo realpath -e "${gateway_logs[$index]}")"
case "$log_path" in
"$RUNTIME_ROOT"/tmp/*) ;;
*) echo "Telegram gateway log escaped the isolated runtime root." >&2; exit 1 ;;
esac
[[ "$(sudo stat -c '%F' "$log_path")" == "regular file" ]]
output_path="${diagnostics_dir}/gateway-internal-tail-$((index + 1)).log"
sudo cat "$log_path" | node --import tsx "$redactor_script" "$output_path"
done
mapfile -t model_config_proofs < <(
find "$diagnostics_dir" -maxdepth 1 -type f -name 'gateway-model-config-*.json' -print |
sort
)
((${#model_config_proofs[@]} <= 8))
if [[ "$RUN_LANE_OUTCOME" != "success" ]]; then
((${#model_config_proofs[@]} > 0))
fi
for proof_path in "${model_config_proofs[@]}"; do
[[ "$(stat -c '%F:%a' "$proof_path")" == "regular file:600" ]]
proof_bytes="$(stat -c '%s' "$proof_path")"
((proof_bytes > 0 && proof_bytes <= 65536))
jq -e '
(keys | sort) == ["agentDefaultModel", "agentModelRefs", "providers"] and
(.agentDefaultModel | keys | sort) == ["fallbacks", "primary"] and
([.agentDefaultModel.primary, .agentDefaultModel.fallbacks[], .agentModelRefs[]] |
all(. == null or type == "string")) and
(.providers | all(
(keys | sort) == ["api", "id", "modelIds"] and
([.id, .api, .modelIds[]] | all(. == null or type == "string"))
))
' "$proof_path" >/dev/null
done
- name: Finalize trusted Telegram process-boundary evidence
id: finalize_boundary
if: always() && steps.terminate_sut.outputs.quiescent == 'true' && steps.run_lane.outputs.output_dir != ''
env:
BOUNDARY_EVIDENCE_DIR: ${{ steps.create_sut.outputs.boundary_evidence_dir }}
OUTPUT_DIR: ${{ steps.run_lane.outputs.output_dir }}
RUN_LANE_OUTCOME: ${{ steps.run_lane.outcome }}
shell: bash
run: |
set -euo pipefail
@@ -2192,6 +2488,7 @@ jobs:
(.candidateArtifact.version | length > 0)
' "$context_path" >/dev/null
jq -e \
--arg runLaneOutcome "$RUN_LANE_OUTCOME" \
'
.version == 1 and
.kind == "qa-gateway-process-boundary" and
@@ -2222,7 +2519,10 @@ jobs:
)
)
) and
([.launches[] | select(.terminalState == "ready-exited")] | length >= 2)
(
[.launches[] | select(.terminalState == "ready-exited")] | length >=
(if $runLaneOutcome == "success" then 2 else 1 end)
)
' "$runtime_path" >/dev/null
while IFS=$'\t' read -r relative_path expected_sha256; do
@@ -2334,6 +2634,7 @@ jobs:
${{ steps.validate_credentials.outcome }}
${{ steps.run_lane.outcome }}
${{ steps.terminate_sut.outcome }}
${{ steps.capture_diagnostics.outcome }}
${{ steps.finalize_boundary.outcome }}
${{ steps.validate_evidence.outcome }}
${{ steps.remove_sut.outcome }}
@@ -17,6 +17,11 @@ on:
description: UTC date of the private rollback drill in YYYY-MM-DD form; must be within 90 days
required: false
type: string
allow_failed_publish_recovery:
description: Accept a failed Release Publish parent only after every stable platform asset was repaired and published
required: false
default: false
type: boolean
permissions:
actions: read
@@ -295,6 +300,11 @@ jobs:
echo "Stable closeout manifest for $tag does not match immutable postpublish evidence; refusing to accept it." >&2
exit 1
fi
if [[ "$EVENT_NAME" == "push" && -f "$closeout_checksum_path" ]]; then
echo "Stable closeout already complete for $tag."
echo "should_closeout=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if [[ -z "$ROLLBACK_DRILL_ID" || -z "$ROLLBACK_DRILL_DATE" ]]; then
if [[ "$EVENT_NAME" == "push" ]]; then
echo "::warning::Stable closeout skipped: rollback drill repository variables are missing; manual dispatch remains required to complete closeout."
@@ -391,6 +401,7 @@ jobs:
FULL_RELEASE_VALIDATION_RUN_ID: ${{ needs.resolve.outputs.full_release_validation_run_id }}
FULL_RELEASE_VALIDATION_RUN_ATTEMPT: ${{ needs.resolve.outputs.full_release_validation_run_attempt }}
RELEASE_PUBLISH_RUN_ID: ${{ needs.resolve.outputs.release_publish_run_id }}
ALLOW_FAILED_PUBLISH_RECOVERY: ${{ github.event_name == 'workflow_dispatch' && inputs.allow_failed_publish_recovery && 'true' || 'false' }}
run: |
set -euo pipefail
. "$RUNNER_TEMP/github-api-backoff.sh"
@@ -414,7 +425,7 @@ jobs:
}
NODE
gh_with_retry run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" \
--json workflowName,event,status,conclusion \
--json workflowName,event,status,conclusion,headSha \
> "$RUNNER_TEMP/release-publish-run.json"
node --input-type=module - "$RUNNER_TEMP/release-publish-run.json" <<'NODE'
import { readFileSync } from "node:fs";
@@ -423,14 +434,125 @@ jobs:
["workflowName", "OpenClaw Release Publish"],
["event", "workflow_dispatch"],
["status", "completed"],
["conclusion", "success"],
]) {
if (run[key] !== expected) {
throw new Error(`OpenClaw Release Publish must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`);
}
}
const recoveryRequested = process.env.ALLOW_FAILED_PUBLISH_RECOVERY === "true";
if (recoveryRequested && run.conclusion !== "failure") {
throw new Error(
`Failed-publish recovery requires conclusion=failure, got ${run.conclusion ?? "<missing>"}.`,
);
}
const failedRecovery = recoveryRequested && run.conclusion === "failure";
if (run.conclusion !== "success" && !failedRecovery) {
throw new Error(
`OpenClaw Release Publish must have conclusion=success, got ${run.conclusion ?? "<missing>"}.`,
);
}
if (failedRecovery) {
console.log("Accepting failed Release Publish parent under explicit complete-platform recovery.");
}
NODE
if [[ "$ALLOW_FAILED_PUBLISH_RECOVERY" == "true" ]]; then
parent_log="$RUNNER_TEMP/release-publish-run.log"
gh_with_retry run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --log \
> "$parent_log"
mapfile -t windows_node_run_ids < <(
sed -nE '/Dispatched windows-node-release\.yml/ { s#.*https://github\.com/openclaw/openclaw/actions/runs/([1-9][0-9]*).*#\1#p; }' "$parent_log" |
LC_ALL=C sort -u
)
if [[ "${#windows_node_run_ids[@]}" != "1" ]]; then
echo "Failed-publish recovery requires exactly one Windows Node Release run dispatched by the parent; found ${#windows_node_run_ids[@]}." >&2
exit 1
fi
windows_node_run_id="${windows_node_run_ids[0]}"
windows_node_run_json="$RUNNER_TEMP/windows-node-release-run.json"
gh_with_retry run view "$windows_node_run_id" --repo "$GITHUB_REPOSITORY" \
--json workflowName,event,status,conclusion,headSha,url,jobs \
> "$windows_node_run_json"
parent_head_sha="$(jq -r '.headSha // empty' "$RUNNER_TEMP/release-publish-run.json")"
PARENT_HEAD_SHA="$parent_head_sha" \
node --input-type=module -e '
import { readFileSync } from "node:fs";
const run = JSON.parse(readFileSync(process.argv[1], "utf8"));
for (const [key, expected] of [
["workflowName", "Windows Node Release"],
["event", "workflow_dispatch"],
["status", "completed"],
["conclusion", "success"],
["headSha", process.env.PARENT_HEAD_SHA],
]) {
if (run[key] !== expected) {
throw new Error(`Windows Node Release must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`);
}
}
const jobs = (run.jobs ?? []).filter(
(job) => job.name === "Promote signed Windows installers" && job.conclusion === "success",
);
if (jobs.length !== 1) {
throw new Error("Windows Node Release must contain one successful signed-installer promotion job.");
}
for (const name of [
"Validate inputs",
"Verify Authenticode signatures",
"Upload to OpenClaw release",
"Verify promoted release asset contract",
]) {
const steps = (jobs[0].steps ?? []).filter(
(step) => step.name === name && step.conclusion === "success",
);
if (steps.length !== 1) {
throw new Error(`Windows Node Release is missing successful step: ${name}.`);
}
}
' "$windows_node_run_json"
windows_node_log="$RUNNER_TEMP/windows-node-release-run.log"
gh_with_retry run view "$windows_node_run_id" --repo "$GITHUB_REPOSITORY" --log \
> "$windows_node_log"
windows_node_installer_digests="$(
node --input-type=module -e '
import { readFileSync } from "node:fs";
const log = readFileSync(process.argv[1], "utf8");
const marker = "EXPECTED_INSTALLER_DIGESTS:";
const names = [
"OpenClawCompanion-Setup-arm64.exe",
"OpenClawCompanion-Setup-x64.exe",
];
const contracts = new Set();
for (const line of log.split(/\r?\n/u)) {
const markerIndex = line.indexOf(marker);
if (markerIndex === -1) continue;
const candidate = line.slice(markerIndex + marker.length).trim();
let parsed;
try {
parsed = JSON.parse(candidate);
} catch {
continue;
}
const keys = Object.keys(parsed).toSorted((left, right) => left.localeCompare(right));
if (
JSON.stringify(keys) !== JSON.stringify(names) ||
!names.every((name) => /^sha256:[0-9a-f]{64}$/u.test(parsed[name] ?? ""))
) {
continue;
}
contracts.add(JSON.stringify(Object.fromEntries(names.map((name) => [name, parsed[name]]))));
}
if (contracts.size !== 1) {
throw new Error(`Windows Node Release logs must contain exactly one candidate-approved digest contract, got ${contracts.size}.`);
}
process.stdout.write([...contracts][0]);
' "$windows_node_log"
)"
{
echo "WINDOWS_NODE_RELEASE_RUN_ID=$windows_node_run_id"
echo "WINDOWS_NODE_INSTALLER_DIGESTS=$windows_node_installer_digests"
} >> "$GITHUB_ENV"
fi
manifest_dir="$RUNNER_TEMP/full-release-validation-manifest"
rm -rf "$manifest_dir"
mkdir -p "$manifest_dir"
@@ -476,6 +598,7 @@ jobs:
ROLLBACK_DRILL_ID: ${{ needs.resolve.outputs.rollback_drill_id }}
ROLLBACK_DRILL_DATE: ${{ needs.resolve.outputs.rollback_drill_date }}
REPAIR_PARTIAL_CLOSEOUT: ${{ needs.resolve.outputs.repair_partial_closeout }}
ALLOW_FAILED_PUBLISH_RECOVERY: ${{ github.event_name == 'workflow_dispatch' && inputs.allow_failed_publish_recovery && 'true' || 'false' }}
CLOSEOUT_DIR: ${{ runner.temp }}/openclaw-stable-main-closeout
run: |
set -euo pipefail
@@ -484,6 +607,44 @@ jobs:
gh_with_retry release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
--json tagName,isDraft,isPrerelease,assets \
> "$CLOSEOUT_DIR/github-release.json"
if [[ "$ALLOW_FAILED_PUBLISH_RECOVERY" == "true" ]]; then
recovery_dir="$CLOSEOUT_DIR/platform-recovery"
mkdir -p "$recovery_dir"
gh_with_retry release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
--pattern OpenClaw-Android.apk \
--pattern OpenClaw-Android-SHA256SUMS.txt \
--pattern OpenClawCompanion-Setup-arm64.exe \
--pattern OpenClawCompanion-Setup-x64.exe \
--pattern OpenClawCompanion-SHA256SUMS.txt \
--dir "$recovery_dir"
(
cd "$recovery_dir"
verify_checksum_manifest() {
local manifest="$1" actual expected normalized
shift
normalized="${manifest}.normalized"
sed 's/\r$//' "$manifest" > "$normalized"
expected="$(printf '%s\n' "$@" | LC_ALL=C sort)"
actual="$(awk 'NF { name=$2; sub(/^\*/, "", name); print name }' "$normalized" | LC_ALL=C sort)"
if [[ "$actual" != "$expected" ]]; then
echo "$manifest must list exactly: $*" >&2
exit 1
fi
sha256sum --strict --check "$normalized"
rm -f "$normalized"
}
verify_checksum_manifest OpenClaw-Android-SHA256SUMS.txt \
OpenClaw-Android.apk
verify_checksum_manifest OpenClawCompanion-SHA256SUMS.txt \
OpenClawCompanion-Setup-arm64.exe \
OpenClawCompanion-Setup-x64.exe
)
gh_with_retry attestation verify "$recovery_dir/OpenClaw-Android.apk" \
--repo "$GITHUB_REPOSITORY" \
--signer-workflow "$GITHUB_REPOSITORY/.github/workflows/android-release.yml" \
--source-ref "refs/tags/$RELEASE_TAG" \
--deny-self-hosted-runners
fi
node scripts/verify-stable-main-closeout.mjs \
--tag "$RELEASE_TAG" \
--main-dir "$GITHUB_WORKSPACE" \
@@ -495,6 +656,9 @@ jobs:
--rollback-drill-id "$ROLLBACK_DRILL_ID" \
--rollback-drill-date "$ROLLBACK_DRILL_DATE" \
--allow-stale-rollback-drill "$REPAIR_PARTIAL_CLOSEOUT" \
--require-complete-platform-assets "$ALLOW_FAILED_PUBLISH_RECOVERY" \
--windows-node-release-run-id "${WINDOWS_NODE_RELEASE_RUN_ID:-}" \
--windows-node-installer-digests "${WINDOWS_NODE_INSTALLER_DIGESTS:-}" \
--output "$CLOSEOUT_DIR/stable-main-closeout.json"
release_version="${RELEASE_TAG#v}"
sha256sum "$CLOSEOUT_DIR/stable-main-closeout.json" | awk -v asset="openclaw-${release_version}-stable-main-closeout.json" \
@@ -524,7 +688,11 @@ jobs:
}
return
fi
gh_with_retry release upload "$RELEASE_TAG" "$source_path#$asset_name" --repo "$GITHUB_REPOSITORY"
# The #suffix is only a display label. Stage the desired basename so
# GitHub stores the versioned asset name used by repair lookups.
cp -- "$source_path" "$existing_dir/$asset_name"
gh_with_retry release upload "$RELEASE_TAG" \
"$existing_dir/$asset_name#$asset_name" --repo "$GITHUB_REPOSITORY"
}
attach_or_verify \
"$CLOSEOUT_DIR/stable-main-closeout.json" \
+31 -5
View File
@@ -366,11 +366,36 @@ jobs:
artifact_dir="${RUNNER_TEMP}/${ARTIFACT_NAME}"
rm -rf "${artifact_dir}"
mkdir -p "${artifact_dir}"
pack_output="${RUNNER_TEMP}/${EXTENSION_ID}-npm-pack-output.txt"
pack_json="${artifact_dir}/npm-pack.json"
OPENCLAW_PLUGIN_NPM_RUNTIME_BUILD=0 \
OPENCLAW_PLUGIN_NPM_PACK_OUTPUT_DIR="${artifact_dir}" \
bash scripts/plugin-npm-publish.sh --pack "${PACKAGE_DIR}" > "${pack_json}"
bash scripts/plugin-npm-publish.sh --pack "${PACKAGE_DIR}" > "${pack_output}"
node - "${pack_output}" "${pack_json}" <<'NODE'
const fs = require("node:fs");
const raw = fs.readFileSync(process.argv[2], "utf8").trim();
let pack;
for (let index = raw.lastIndexOf("["); index >= 0; index = raw.lastIndexOf("[", index - 1)) {
try {
const candidate = JSON.parse(raw.slice(index));
if (Array.isArray(candidate)) {
pack = candidate;
break;
}
} catch {
// npm can print bundled-dependency summaries before its JSON result.
}
if (index === 0) {
break;
}
}
if (!pack) {
throw new Error("npm pack did not emit a JSON array.");
}
fs.writeFileSync(process.argv[3], `${JSON.stringify(pack, null, 2)}\n`);
NODE
tarball_name="$(
node - "${pack_json}" <<'NODE'
@@ -458,8 +483,8 @@ jobs:
if (repositoryUrl !== "https://github.com/openclaw/openclaw") {
fail(`Packed repository route mismatch: ${JSON.stringify(repositoryUrl)}.`);
}
if (pluginManifest.id !== process.env.EXTENSION_ID) {
fail(`Packed plugin id mismatch: expected ${process.env.EXTENSION_ID}.`);
if (typeof pluginManifest.id !== "string" || pluginManifest.id.length === 0) {
fail("Packed plugin id is missing.");
}
if (packEntry.integrity !== actualIntegrity || packEntry.shasum !== actualShasum) {
fail("npm pack integrity metadata does not match the prepared tarball.");
@@ -756,9 +781,10 @@ jobs:
manifest.package.installNpmSpec !== process.env.INSTALL_NPM_SPEC ||
manifest.package.publishTag !== process.env.PUBLISH_TAG ||
repositoryUrl !== "https://github.com/openclaw/openclaw" ||
manifest.package.pluginId !== process.env.EXTENSION_ID ||
typeof pluginManifest.id !== "string" ||
pluginManifest.id.length === 0 ||
manifest.package.pluginId !== pluginManifest.id ||
manifest.package.repositoryUrl !== repositoryUrl ||
pluginManifest.id !== process.env.EXTENSION_ID ||
manifest.package.packageJsonSha256 !== packedPackageJsonSha256 ||
manifest.package.sourcePackageJsonSha256 !== sourcePackageJsonSha256
) {
@@ -0,0 +1,279 @@
name: Shared OpenClawKit Periphery
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review, converted_to_draft]
workflow_dispatch:
concurrency:
group: shared-openclawkit-periphery-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
permissions:
contents: read
jobs:
scope:
name: Detect shared OpenClawKit scan scope
runs-on: ubuntu-24.04
outputs:
should-scan: ${{ steps.scope.outputs.should-scan }}
steps:
- name: Checkout
if: github.event_name == 'pull_request' && github.event.pull_request.draft == false
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Ensure base commit
if: github.event_name == 'pull_request' && github.event.pull_request.draft == false
uses: ./.github/actions/ensure-base-commit
with:
base-sha: ${{ github.event.pull_request.base.sha }}
fetch-ref: ${{ github.event.pull_request.base.ref }}
- name: Detect changed paths
id: scope
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
if (context.eventName === "workflow_dispatch") {
core.setOutput("should-scan", "true");
return;
}
if (context.payload.pull_request?.draft) {
core.setOutput("should-scan", "false");
return;
}
const baseSha = context.payload.pull_request?.base?.sha;
if (!baseSha) {
throw new Error("missing pull request base SHA");
}
const result = await exec.getExecOutput("git", [
"diff",
"--quiet",
baseSha,
"HEAD",
"--",
"apps/ios/",
"apps/macos/",
"apps/shared/OpenClawKit/",
".github/workflows/shared-openclawkit-periphery.yml",
"scripts/periphery-intersection.mjs",
"scripts/ios-configure-signing.sh",
"scripts/ios-write-swift-filelist.mjs",
"scripts/ios-write-version-xcconfig.sh",
"test/scripts/periphery-intersection.test.ts",
], { ignoreReturnCode: true, silent: true });
if (result.exitCode !== 0 && result.exitCode !== 1) {
throw new Error(`git diff failed with exit code ${result.exitCode}`);
}
core.setOutput("should-scan", String(result.exitCode === 1));
scan-ios:
name: Scan shared kit from iOS
needs: scope
if: ${{ needs.scope.outputs.should-scan == 'true' }}
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-26' || (github.repository == 'openclaw/openclaw' && 'blacksmith-12vcpu-macos-26' || 'macos-26') }}
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Verify Xcode
run: |
set -euo pipefail
for xcode_app in /Applications/Xcode_26.5.app /Applications/Xcode-26.5.0.app; do
if [ -d "$xcode_app/Contents/Developer" ]; then
sudo xcode-select -s "$xcode_app/Contents/Developer"
break
fi
done
xcodebuild -version
xcode_version="$(xcodebuild -version | awk 'NR == 1 { print $2 }')"
if [[ "$xcode_version" != 26.* ]]; then
echo "error: expected Xcode 26.x, got $xcode_version" >&2
exit 1
fi
swift --version
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Install iOS scan tooling
run: |
brew update
brew install xcodegen periphery
swift_tools_dir="$RUNNER_TEMP/openclaw-swift-tools"
./scripts/install-swift-tools.sh "$swift_tools_dir"
echo "$swift_tools_dir" >> "$GITHUB_PATH"
"$swift_tools_dir/swiftformat" --version
"$swift_tools_dir/swiftlint" version
- name: Generate iOS project
run: |
set -euo pipefail
./scripts/ios-configure-signing.sh
./scripts/ios-write-version-xcconfig.sh
node scripts/ios-write-swift-filelist.mjs
cd apps/ios
xcodegen generate
- name: Scan shared kit
run: |
set -euo pipefail
output_dir="$RUNNER_TEMP/shared-periphery-ios"
mkdir -p "$output_dir"
cd apps/ios
set +e
periphery scan \
--config .periphery.yml \
--clean-build \
--format json \
--report-include '../shared/OpenClawKit/Sources/**' \
--retain-files '../shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift' \
--write-results "$output_dir/periphery.json" \
>"$output_dir/periphery.stdout.json" \
2>"$output_dir/periphery.stderr.log"
periphery_status="$?"
set -e
printf '%s\n' "$periphery_status" >"$output_dir/periphery.status"
if [ ! -s "$output_dir/periphery.json" ]; then
cp "$output_dir/periphery.stdout.json" "$output_dir/periphery.json"
fi
- name: Upload iOS consumer report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: shared-periphery-ios-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/shared-periphery-ios
if-no-files-found: error
retention-days: 14
scan-macos:
name: Scan shared kit from macOS
needs: scope
if: ${{ needs.scope.outputs.should-scan == 'true' }}
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-26' || (github.repository == 'openclaw/openclaw' && 'blacksmith-12vcpu-macos-26' || 'macos-26') }}
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Verify Xcode
run: |
set -euo pipefail
for xcode_app in /Applications/Xcode_26.5.app /Applications/Xcode-26.5.0.app; do
if [ -d "$xcode_app/Contents/Developer" ]; then
sudo xcode-select -s "$xcode_app/Contents/Developer"
break
fi
done
xcodebuild -version
xcode_version="$(xcodebuild -version | awk 'NR == 1 { print $2 }')"
if [[ "$xcode_version" != 26.* ]]; then
echo "error: expected Xcode 26.x, got $xcode_version" >&2
exit 1
fi
swift --version
- name: Install Periphery
run: |
brew update
brew install periphery
- name: Scan shared kit
run: |
set -euo pipefail
output_dir="$RUNNER_TEMP/shared-periphery-macos"
mkdir -p "$output_dir"
cd apps/macos
set +e
periphery scan \
--config .periphery.yml \
--clean-build \
--format json \
--report-include '../shared/OpenClawKit/Sources/**' \
--retain-files '../shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift' \
--write-results "$output_dir/periphery.json" \
>"$output_dir/periphery.stdout.json" \
2>"$output_dir/periphery.stderr.log"
periphery_status="$?"
set -e
printf '%s\n' "$periphery_status" >"$output_dir/periphery.status"
if [ ! -s "$output_dir/periphery.json" ]; then
cp "$output_dir/periphery.stdout.json" "$output_dir/periphery.json"
fi
- name: Upload macOS consumer report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: shared-periphery-macos-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/shared-periphery-macos
if-no-files-found: error
retention-days: 14
intersect:
name: Intersect shared OpenClawKit dead code
needs: [scope, scan-ios, scan-macos]
if: ${{ always() && needs.scope.outputs.should-scan == 'true' && needs.scan-ios.result != 'cancelled' && needs.scan-macos.result != 'cancelled' }}
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Download iOS consumer report
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: shared-periphery-ios-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/shared-periphery-ios
- name: Download macOS consumer report
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: shared-periphery-macos-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/shared-periphery-macos
- name: Intersect exact Swift identities
run: |
node scripts/periphery-intersection.mjs \
--ios-results "$RUNNER_TEMP/shared-periphery-ios/periphery.json" \
--ios-status "$RUNNER_TEMP/shared-periphery-ios/periphery.status" \
--macos-results "$RUNNER_TEMP/shared-periphery-macos/periphery.json" \
--macos-status "$RUNNER_TEMP/shared-periphery-macos/periphery.status" \
--output "$RUNNER_TEMP/shared-periphery-intersection/periphery.json"
- name: Upload shared intersection
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: shared-periphery-intersection-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/shared-periphery-intersection
if-no-files-found: warn
retention-days: 14
+34 -78
View File
@@ -489,6 +489,7 @@ jobs:
}
lock-closed-issues:
needs: stale
if: ${{ github.event_name != 'workflow_dispatch' || inputs.backfill_stale_closures != true }}
permissions:
issues: write
@@ -499,94 +500,49 @@ jobs:
with:
app-id: "2729701"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Lock closed issues after 48h of no comments
- name: Lock closed issues after 48h of no activity
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const lockAfterHours = 48;
const lockAfterMs = lockAfterHours * 60 * 60 * 1000;
const perPage = 100;
const maxLocksPerRun = 50;
const mutationDelayMs = 1100;
const cutoffMs = Date.now() - lockAfterMs;
const { owner, repo } = context.repo;
let locked = 0;
let inspected = 0;
let cursor = null;
const cutoff = new Date(cutoffMs).toISOString();
const query = [
`repo:${owner}/${repo}`,
"is:issue",
"is:closed",
"is:unlocked",
`updated:<${cutoff}`,
].join(" ");
const { data } = await github.rest.search.issuesAndPullRequests({
q: query,
sort: "updated",
order: "asc",
per_page: maxLocksPerRun,
});
const candidates = data.items.filter(issue => {
const updatedAtMs = Date.parse(issue.updated_at);
return Number.isFinite(updatedAtMs) && updatedAtMs <= cutoffMs;
});
while (true) {
const result = await github.graphql(
`query ClosedIssuesForLocking(
$owner: String!
$repo: String!
$cursor: String
$perPage: Int!
) {
repository(owner: $owner, name: $repo) {
issues(
first: $perPage
after: $cursor
states: CLOSED
orderBy: { field: CREATED_AT, direction: ASC }
) {
nodes {
number
locked
closedAt
comments(last: 1) {
nodes {
createdAt
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}`,
{
owner,
repo,
cursor,
perPage,
},
);
const issues = result.repository.issues;
for (const issue of issues.nodes) {
if (issue.locked || !issue.closedAt) {
continue;
}
inspected += 1;
const closedAtMs = Date.parse(issue.closedAt);
if (!Number.isFinite(closedAtMs) || closedAtMs > cutoffMs) {
continue;
}
const lastComment = issue.comments.nodes[0];
const lastCommentMs = lastComment ? Date.parse(lastComment.createdAt) : 0;
const lastActivityMs = Math.max(closedAtMs, lastCommentMs || 0);
if (lastActivityMs > cutoffMs) {
continue;
}
await github.rest.issues.lock({
owner,
repo,
issue_number: issue.number,
lock_reason: "resolved",
});
locked += 1;
for (const [index, issue] of candidates.entries()) {
await github.rest.issues.lock({
owner,
repo,
issue_number: issue.number,
lock_reason: "resolved",
});
if (index < candidates.length - 1) {
await new Promise(resolve => setTimeout(resolve, mutationDelayMs));
}
if (!issues.pageInfo.hasNextPage || !issues.pageInfo.endCursor) {
break;
}
cursor = issues.pageInfo.endCursor;
}
core.info(`Inspected ${inspected} closed issues; locked ${locked}.`);
core.info(
`Found ${data.total_count} unlocked issues inactive for 48h; locked ${candidates.length}.`,
);
+6
View File
@@ -228,6 +228,12 @@
"**/node_modules/**"
],
"overrides": [
{
"files": ["extensions/browser/src/browser/routes/*.ts"],
"rules": {
"oxc/no-async-endpoint-handlers": "off"
}
},
{
"files": [
"packages/markdown-core/**/*.ts",
+69 -8
View File
@@ -68,6 +68,7 @@ Skills own workflows; root owns hard policy and routing.
- Config/env surface bar is high; `openclaw.json` and environment variables are already large. Before adding a config option or env var, first prove existing product behavior, provider selection, defaults, or doctor migration cannot solve it. Prefer removing or consolidating config/env options when touching these surfaces. Core supports only the latest config shape; `openclaw doctor --fix` migrates older shipped shapes into the current one.
- CLI setup flows are public API when external docs, installers, or integrations can copy them. Changes to `openclaw onboard`, `openclaw configure`, their documented flags, non-interactive behavior, or generated config shape are compatibility-sensitive API contract changes; prefer additive flags/aliases, deprecation windows, and backward-preserving migrations over breaking existing snippets.
- Fix shape: default to clean bounded refactor, not smallest patch. Move ownership to right boundary; delete stale abstractions, duplicate policy, dead branches, wrappers, fallback stacks.
- New binary fallible-operation results use `Result` from `@openclaw/normalization-core/result`; domain-rich outcomes keep named discriminated unions.
- Fix observed local failures with generic product rules; do not hardcode names, ids, log phrases, or user examples in prod code unless they are an explicit contract.
- Tests may use observed examples, but prod literals need a short contract reason.
- Compatibility is opt-in. "Shipped" means reachable from a release Git tag; main/GitHub/PR/unreleased code is not shipped.
@@ -112,32 +113,49 @@ Skills own workflows; root owns hard policy and routing.
## Commands
- Runtime: Node 22.19+; Node 24 recommended. Keep Node + Bun paths working.
- Runtime: Node 22.22.3+, 24.15+, or 25.9+; Node 24 recommended. Keep Node + Bun paths working.
- Package manager/runtime: repo defaults only. No swaps without approval.
- Install: `pnpm install` (keep Bun lock/patches aligned if touched). Agent dependency installation for tests/builds defaults to the selected remote box; do not reconcile a local Codex worktree just to run validation.
- CLI: `pnpm openclaw ...` or `pnpm dev`; build: `pnpm build`.
- Agent tests default remote through Crabbox, including focused tests. Trusted maintainer code defaults to Blacksmith Testbox. Contributor/fork code remains untrusted unless a maintainer explicitly approves credentialed execution after review; an explicit owner/maintainer instruction to land named, reviewed PRs is that approval, so do not ask twice. Otherwise use secretless fork CI or sanitized direct AWS Crabbox, never a credential-hydrated Testbox. Sanitized AWS must launch an installed trusted Crabbox binary from a clean trusted `main` checkout and fetch only the remote PR via `--fresh-pr`; never execute a wrapper, config, or command from the untrusted local checkout. Before warmup, unset `CRABBOX_AWS_INSTANCE_PROFILE` and all `CRABBOX_TAILSCALE*` overrides; fail closed unless resolved `aws.instanceProfile` is empty. Force `--network public --tailscale=false`, clear exit-node/LAN flags, and require `crabbox inspect` to report public networking with no Tailscale state before any script. Upload trusted `scripts/crabbox-untrusted-bootstrap.sh` from clean `main` alongside `--fresh-pr`; it proves the remote IMDSv2 IAM credentials endpoint returns 404, verifies the reviewed head SHA, unsets `NODE_OPTIONS`, installs pinned Node/pnpm, verifies the package-manager pin, isolates `HOME`, installs dependencies, then runs the requested test. Use a newly warmed lease bound to one reviewed head SHA, set `CRABBOX_ENV_ALLOW=CI`, and use `--no-hydrate`. Never reuse a trusted/previously hydrated lease or carry an untrusted lease across head revisions; stop and rewarm when the SHA changes. No repo `OPENCLAW_*` allowlist, existing auth profile, instance role, tailnet/LAN access, moving PR head, or ambient Node preload may reach untrusted execution. When a code task is likely to need tests, classify source trust, pre-warm the safe backend immediately, keep working while it hydrates, reuse trusted leases or same-SHA untrusted leases, then stop before handoff.
- Test commands (run inside the selected remote box by default): `pnpm test <path-or-filter> [vitest args...]`, `pnpm test:changed`, `pnpm test:serial`, `pnpm test:coverage`; never raw `vitest`.
- If raw Vitest is unavoidable, use `vitest run ...`; bare `vitest ...` starts local watch mode and will not exit on its own.
- Vitest repetition: no `--repeat`; use a bounded shell loop around the focused repo test command.
- Local agent test execution is opt-in: only when the user explicitly requests local proof or Testbox is unavailable and the fallback is reported. In a Codex worktree or linked/sparse checkout, the narrow local fallback is `node scripts/run-vitest.mjs <path-or-filter>`; never direct local `pnpm test*`.
- Checks/lint in a normal source checkout: `pnpm check:changed` delegates to Crabbox/Testbox; lanes: `pnpm changed:lanes --json`; staged/path-scoped: `pnpm check:changed --staged` or `pnpm check:changed -- <files...>`; full `pnpm check`/`pnpm lint` only when required.
- Checks in a Codex worktree or linked/sparse checkout: avoid direct local `pnpm check*`; use `node scripts/crabbox-wrapper.mjs run ... -- env OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed` so pnpm runs inside the selected remote box, not locally.
- Extension tests: `pnpm test:extensions`, `pnpm test extensions`, `pnpm test extensions/<id>`.
- Typecheck: `tsgo` lanes only (`pnpm tsgo*`, `pnpm check:test-types`); never add `tsc --noEmit`, `typecheck`, `check:types`.
- Formatting: `oxfmt`, not Prettier. Use repo wrappers (`pnpm format:*`, `scripts/run-oxlint.mjs`; full `pnpm lint:*` only when scope requires).
- SDK surface gate: `pnpm plugin-sdk:surface:check`; no `plugin-sdk:surface-report` script.
- Build before push when build output, packaging, lazy/module boundaries, dynamic imports, or published surfaces can change; agent builds default to the selected remote box unless platform-specific proof requires another remote host.
## Validation
- Use `$openclaw-testing` for test/CI choice and `$crabbox` for remote/full/E2E proof.
- At task start, if code changes, tests, builds, typechecks, lint fan-out, Docker, packaging, E2E, or live proof are likely, classify source trust and immediately pre-warm the safe Crabbox backend in a background command session. Trusted maintainer code defaults to Blacksmith Testbox; untrusted contributor/fork code uses secretless fork CI or sanitized direct AWS Crabbox under the rule above. Continue inspection/editing while it hydrates; sync the current checkout for every run, reuse the lease, then stop it before handoff.
- Warm Testbox from the task checkout; ownership is checkout-path scoped; `--reclaim` only for intentional transfer.
- One Testbox lease, one active command; never sync/reclaim during a run.
- Testbox `--reclaim` does not retarget the remote checkout; never cross repos.
- Base/head changed: stop and rewarm Testbox; never override stale lease checks.
- Compound Testbox commands: `bash -lc`, never `sh -lc`; job env uses Bash `declare`.
- Testbox cleanup: `blacksmith testbox stop --id <tbx_id>`; id is not positional.
- Delegated Testbox rejects `--fresh-pr` and `--stop-after`; sync current checkout, workflow owns lifecycle.
- PR review artifacts: keep template enum values; put evidence detail in summaries.
- Crabbox request means real scenario proof: install/update/call/repro user path; not just copy tests and run them remotely.
- Visual proof: use Crabbox, set up like a user, then screenshot-verify. No harness/bypass/shortcut unless explicitly asked.
- Local agent work is limited to lightweight non-test checks such as `git diff --check`, targeted formatting, and cheap static probes. Tests and computationally intensive work default to the selected remote box.
- In Codex worktrees, direct local `pnpm test*`, `pnpm check*`, `pnpm crabbox:run`, and `scripts/committer` can trigger pnpm dependency reconciliation or install prompts. Prefer `node` wrappers locally and Crabbox/Testbox for pnpm-gated proof.
- In Codex or linked worktrees, direct local `pnpm test*`, `pnpm check*`, `pnpm crabbox:run`, and `scripts/committer` can trigger pnpm dependency reconciliation or install prompts. Prefer `node` wrappers locally and Crabbox/Testbox for pnpm-gated proof.
- Crabbox wrapper `stop` has no `--timing-json`; use `node scripts/crabbox-wrapper.mjs stop --provider <provider> --id <id>`.
- Repo-native PR worktree may omit `node_modules`; prove remotely, then use `git commit --no-verify`, not `scripts/committer`.
- Release-branch formatting: Testbox or existing binary; never local `pnpm exec` reconciliation.
- Parallel agents share the checkout; never switch its branch while sibling work runs.
- Testbox status: `blacksmith testbox status --id <tbx_id>`; no `--json` flag.
- QA CLI `--output-dir` must be repo-relative.
- Full suites, changed gates, builds, typechecks, lint fan-out, Docker/package/E2E/live/cross-OS proof, or anything computationally intensive: Crabbox/Testbox.
- If an allowed local fallback fans out or becomes expensive, stop it and move the work to the pre-warmed remote box.
- Before handoff/push: prove touched surface. Before landing to `main`: issue proof plus appropriate full/broad proof unless scope is clearly narrow.
- Before handoff/push: prove touched surface. Before landing to `main`: proof matches actual risk. Bounded behavior-neutral refactor: focused tests/checks enough; no issue proof or full/broad suite by default.
- Release-branch full validation: freeze the product-complete **Code SHA**, then use `node scripts/full-release-validation-at-sha.mjs --sha <code-sha> --target-ref release/YYYY.M.PATCH`; no raw dispatch without `target_context_ref`.
- Pre-land/pre-commit code changes: mandatory fresh `$autoreview` until no accepted/actionable findings remain. Do not land code on CI, ClawSweeper, prior review comments, or your own manual review alone unless user explicitly opts out or scope is truly trivial/docs-only. If findings want refactor, refactor; no ugly fixes.
- If proof is blocked, say exactly what is missing and why.
- Do not land related failing format/lint/type/build/tests. If unrelated on latest `origin/main`, say so with scoped proof.
@@ -147,11 +165,34 @@ Skills own workflows; root owns hard policy and routing.
## GitHub / PRs
- Fresh GitHub items: read `CONTRIBUTING.md`, the issue chooser/form, PR template, and `.github/CODEOWNERS`; blank issues are disabled; preserve templates and evidence requirements.
- Agent-authored/non-trivial work: create or reuse the issue first; tiny fixes may go direct. PRs use the template, link context, and keep durable problem/impact/evidence sections.
- Issue first for bugs, user-facing features, architecture/product decisions, or work needing durable discussion. Bounded maintainer-requested refactor may go direct; agent decides whether an issue adds value. PRs use the template, link context, and keep durable problem/impact/evidence sections.
- Route support to Discord and security through `SECURITY.md`. Use listed maintainer areas/`CODEOWNERS`; never guess mentions.
- Use `$openclaw-pr-maintainer` immediately for maintainer-side OpenClaw issue/PR review, triage, duplicates, labels, comments, close, land, or evidence. Contributor PR creation/refresh follows the requested contributor workflow; linked refs alone do not require maintainer archive tooling.
- Issue/PR start: `git status -sb`; if clean, `git pull --ff-only`; if dirty, yell before pull/rebase.
- PR refs: `gh pr view/diff` or `gh api`, not web search. Prefer `gitcrawl` for maintainer discovery; missing/stale `gitcrawl` falls through to live `gh`, not contributor setup. Verify live with `gh` before mutation.
- `gh pr view` takes the branch positionally; no `--head` flag.
- zsh: quote `gh api` endpoints containing `?` or brackets; otherwise glob expansion corrupts the invocation.
- Blacksmith Testbox status/stop: `--id <tbx_id>`; no status JSON flag.
- Crabbox final timing JSON = proof complete; if portal sync hangs after it, interrupt wrapper only.
- Sparse-sync temp checkout may claim kept Testbox; repo-path reuse needs `--reclaim`.
- GitHub Actions: resolve workflow files from `.github/workflows` or API; never infer filenames from display names.
- zsh: quote command globs; unmatched patterns abort before the tool runs.
- zsh: don't use `path` as a variable; it rewrites `$PATH`.
- `scripts/pr` artifacts: preserve template enum values; validate before prepare.
- `scripts/pr` subcommands require a PR number; no subcommand `--help` placeholder.
- `scripts/pr` review: checkout main baseline, then PR, before artifact validation.
- Review artifacts: validate from PR-head mode; moving main invalidates main-baseline guard.
- `scripts/pr` prepare/merge: `main` PRs only; non-main uses reviewed release-branch flow.
- PR head changed: rerun `scripts/pr review-init`; checkout alone leaves stale guard SHA.
- `rg`: options/globs before `--`; `--` immediately before a leading-dash pattern only.
- `gh --jq` is not standalone `jq`; pipe JSON to `jq` for variables or `--arg`.
- `gh api --paginate '<endpoint>' | jq -s ...`; gh `--slurp` may emit nothing and forbids `--jq`/`--template`.
- Main-bound workflow dispatch: resolve server `main` SHA immediately before dispatch; retry if identity fails after `main` advances.
- `gh run view --json` uses `attempt`, not `attemptNumber`.
- Crabbox stop: no `--timing-json`; use `node scripts/crabbox-wrapper.mjs stop --provider <provider> --id <id>`.
- macOS `find` has no `-printf`; use `-print0` plus `stat`.
- Actions checkout refs: use full 40-char SHAs; short SHAs resolve as branches/tags.
- zsh Git object paths: use `${sha}:path`; `$sha:path` invokes parameter modifiers.
- Bare issue/PR URL/number: inspect live and take the efficient maintainer path; switch branches/refs when useful.
- No unsolicited PR labels/retitles/rebases/fixups/landing. Comments/reviews ok only for reviewable findings, pre-merge proof, or close/duplicate reason after explicit close/sweep/landing request.
- Maintainer decision closes the cluster: if deciding reported behavior/proposed fix is not planned, comment+close all directly associated open issues/PRs unless explicitly told to keep one open. Associated means linked PRs/issues, duplicates, companion workaround PRs, and the canonical issue for the rejected behavior.
@@ -178,8 +219,13 @@ Skills own workflows; root owns hard policy and routing.
- GitHub issue/PR create: read `$agent-transcript`; ask about sanitized transcript logs when available.
- Contributor PRs: parsed context requires authored `What Problem This Solves` and `Evidence` sections. Do not require field-level proof forms; reviewers inspect code, tests, and CI for correctness.
- PR artifacts/screenshots: attach to PR/comment/external artifact store. Never push screenshots, videos, proof images, or proof assets to OpenClaw or any product repo branch, including temp artifact branches. Use Crabbox artifact publishing plus the manifest URL. Do not commit `.github/pr-assets`.
- CI polling: exact SHA, relevant checks only, minimal fields. Skip routine noise (`Auto response`, `Labeler`, docs agents, performance/stale). Logs only after failure/completion or concrete need.
- Agent PR landing to `main`: use only the repo-native `scripts/pr` wrapper: run `scripts/pr review-init <PR>`, follow its emitted checkout/guard guidance, initialize and complete review artifacts with `scripts/pr review-artifacts-init <PR>`, validate them with `scripts/pr review-validate-artifacts <PR>`, then run `OPENCLAW_TESTBOX=1 scripts/pr prepare-run <PR>` and `scripts/pr merge-run <PR>`. The Testbox flag is mandatory for agents so prepare verifies hosted CI/Testbox on the current head or reuses a patch-identical pre-rebase run green within 24 hours instead of running full gates locally. For owner-approved reviewed fork code without hosted Testbox, use `OPENCLAW_PR_GATES_REMOTE=testbox` instead. Do not rebase only because `main` advanced; merge drift is advisory unless strict drift is explicitly enabled, while GitHub still blocks conflicts. Do not idle on `auto-response` or `check-docs`.
- CI polling: exact SHA, relevant checks only, minimal fields. Skip routine noise (`Auto response`, `Labeler`, docs agents, performance/stale). Logs only after failure/completion or concrete need. Never `gh run watch`; its 3s polling exhausts API quota. Use sparse GraphQL rollups.
- Trusted-workflow release-branch CI: pass `target_ref` + `release_candidate_ref`; never `release_gate` (requires workflow head == target).
- Agent PR landing to `main`: use only the repo-native `scripts/pr` wrapper: run `scripts/pr review-init <PR>`, follow its emitted checkout/guard guidance, initialize and complete review artifacts with `scripts/pr review-artifacts-init <PR>`, validate them with `scripts/pr review-validate-artifacts <PR>`, then run `OPENCLAW_TESTBOX=1 scripts/pr prepare-run <PR>` and `scripts/pr merge-run <PR>`. The Testbox flag is mandatory for agents so prepare verifies hosted CI/Testbox on the current head or reuses a patch-identical pre-rebase run green within 24 hours instead of running full gates locally. `prepare-run` fails fast; invoke only after exact-head CI is complete and green. For owner-approved reviewed fork code without hosted Testbox, use `OPENCLAW_PR_GATES_REMOTE=testbox` instead. Do not rebase only because `main` advanced; merge drift is advisory unless strict drift is explicitly enabled, while GitHub still blocks conflicts. Do not idle on `auto-response` or `check-docs`.
- Non-main PRs: do not run `scripts/pr prepare-run` or `merge-run`; they diff against `main`. Use review artifacts, exact base-head CI, revalidate `headRefOid`, then `gh pr merge --match-head-commit <verified-sha>`.
- Merge guard shells: start `set -euo pipefail`; a failed `[[ ... ]]` alone does not stop a later merge command.
- After `scripts/pr merge-run` removes its worktree, `cd` to a persistent repo before follow-up commands.
- `scripts/pr` review JSON: land-ready recommendation `READY FOR /prepare-pr`, `issueValidation.status=valid`; never `APPROVE`.
## Code
@@ -228,7 +274,7 @@ Skills own workflows; root owns hard policy and routing.
- Prefer injection and narrow `*.runtime.ts` mocks over broad barrels or `openclaw/plugin-sdk/*`.
- Do not edit baseline/inventory/ignore/snapshot/expected-failure files to silence checks without explicit approval.
- Do not run independent `pnpm test`/Vitest commands concurrently in one worktree; Vitest cache races with `ENOTEMPTY`. Group one command or use distinct `OPENCLAW_VITEST_FS_MODULE_CACHE_PATH`.
- Test workers max 16. Memory pressure: `OPENCLAW_VITEST_MAX_WORKERS=1 pnpm test`.
- Vitest rejects Jest `--runInBand`; use `OPENCLAW_VITEST_MAX_WORKERS=1 pnpm test` for serial proof. Test workers max 16.
- Live: `OPENCLAW_LIVE_TEST=1 pnpm test:live`; verbose `OPENCLAW_LIVE_TEST_QUIET=0`.
- Guide: `docs/reference/test.md`.
@@ -261,9 +307,24 @@ Skills own workflows; root owns hard policy and routing.
- Lockfiles/shrinkwrap are security surface: review `pnpm-lock.yaml`, `npm-shrinkwrap.json`, `package-lock.json`; root/plugin npm packages ship shrinkwrap, not package-lock.
- Carbon pins owner-only: do not change `@buape/carbon` unless Shadow (`@thewilloftheshadow`, verified by `gh`) asks.
- Releases/publish/version bumps need explicit approval. Use `$release-openclaw-maintainer`.
- Active release scope lock: freeze the operator-selected cut SHA and release
identity through publish and verification. Moving `main`, unrelated CI,
optional backports, refactors, cleanup, and normal forward-ports are not part
of the release work queue.
- Touch `main` during a release only when the operator requests it or the
smallest critical main-owned blocker prevents that release. Return to the
release branch immediately; defer broader main work until closeout.
- Release versions use `YYYY.M.PATCH`, where `PATCH` is a sequential monthly release-train number, never the calendar day. Stable and beta tags determine the current train; alpha-only tags do not consume or advance the beta/stable patch number. After `2026.6.5`, the next beta train is `2026.6.6-beta.1` even if higher alpha-only tags exist.
- Alpha/nightly versions use the next unreleased train plus an incrementing prerelease number. Repeated nightlies for the same train increment only `alpha.N`; they must not mint a new patch number from the date.
- Backport means apply to newest open `release/` branch unless user names another target.
- Backports are optional. Apply only the operator-selected set; when requested without a target, use the newest open `release/` branch.
- Regular beta/stable flow has two immutable identities:
- **Code SHA**: version prep plus any optional backports/release fixes, with no release changelog mutation. Full product validation belongs here.
- **Release SHA**: a descendant of the green Code SHA whose complete diff is exactly `CHANGELOG.md`. Tag, npm preflight, package/install acceptance, and publish belong here.
- Never generate the release changelog before the Code SHA has green Full Release Validation. A product/code failure changes the Code SHA and restarts product validation. A workflow/harness/infrastructure failure is fixed in trusted tooling and rerun against the same Code SHA; do not mutate the candidate to satisfy newer tooling.
- After green Code SHA validation, generate and review `CHANGELOG.md` once. Dispatch Full Release Validation for the Release SHA with evidence reuse enabled; `changelog-only-release-v1` may reuse the Code SHA product evidence only when GitHub independently proves the entire descendant delta is `CHANGELOG.md`. Any other path change requires a new Code SHA and fresh full validation.
- Release-SHA proof is intentionally narrow: release-note/provenance checks, npm preflight/package bytes, install/update acceptance, and publish readiness. Do not rerun the full product matrix merely because the changelog changed.
- Pass the successful Release-SHA validation run and npm preflight run into `release:candidate`; do not let the candidate helper dispatch duplicate copies of evidence that already passed.
- Keep one release operator and one watcher per release identity. Resume partial publish from successful immutable child artifacts/runs; never rebuild or republish an already-published package version.
- GHSA/advisories: `$openclaw-ghsa-maintainer` / `$security-triage`. Secret scanning: `$openclaw-secret-scanning-maintainer`.
- Beta tag/version match: `vYYYY.M.PATCH-beta.N` -> npm `YYYY.M.PATCH-beta.N --tag beta`.
+2430 -507
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -47,7 +47,7 @@ For coordinated change sets that genuinely need more than 20 PRs, join the **#cl
## Before You PR
- Use **Node 24** for source checkouts when possible. OpenClaw also supports Node 22.19+, but older Node 22 minors such as 22.17 are below the repository engine floor and can fail before `pnpm` commands run. See [Node install guidance](docs/install/node.md) if your local version is too old.
- Use **Node 24.15+** for source checkouts when possible. OpenClaw also supports Node 22.22.3+ and Node 25.9+, but Node 23, Node 22 before 22.22.3, and Node 24 before 24.15 are below the repository engine floor and can fail before `pnpm` commands run. See [Node install guidance](docs/install/node.md) if your local version is too old.
- Test locally with your OpenClaw instance
- External PRs must describe the user, product, or operational problem in **What Problem This Solves** and include useful validation in **Evidence**. Focused tests, CI results, screenshots, recordings, terminal output, live observations, redacted logs, and artifact links all count. Reviewers will inspect the code, tests, and CI; use the PR body to explain intent and make validation easy to understand.
- When ClawSweeper, Codex, Barnacle, or a maintainer asks for more context or evidence, edit the PR description instead of only replying in a new comment. Keep **What Problem This Solves**, **Why This Change Was Made**, **User Impact**, and **Evidence** current; a short comment can point reviewers to the update, but the PR body should remain the durable explanation for maintainers and bots.
+3 -3
View File
@@ -93,7 +93,7 @@ Model note: while many providers and models are supported, prefer a current flag
## Install (recommended)
Runtime: **Node 24 (recommended) or Node 22.19+**.
Runtime: **Node 24.15+ (recommended), Node 22.22.3+, or Node 25.9+**.
```bash
npm install -g openclaw@latest
@@ -106,7 +106,7 @@ OpenClaw Onboard installs the Gateway daemon (launchd/systemd user service) so i
## Quick start (TL;DR)
Runtime: **Node 24 (recommended) or Node 22.19+**.
Runtime: **Node 24.15+ (recommended), Node 22.22.3+, or Node 25.9+**.
Full beginner guide (auth, pairing, channels): [Getting started](https://docs.openclaw.ai/start/getting-started)
@@ -250,7 +250,7 @@ pnpm build
pnpm ui:build
```
`pnpm openclaw setup` writes the local config/workspace needed for `pnpm gateway:watch`. It is safe to re-run, but you normally only need it on first setup or after resetting local state. `pnpm gateway:watch` does not rebuild `dist/control-ui`, so rerun `pnpm ui:build` after `ui/` changes or use `pnpm ui:dev` when iterating on the Control UI. If you want this checkout to run onboarding directly, use `pnpm openclaw onboard --install-daemon`.
`pnpm openclaw setup` writes the local config/workspace needed for `pnpm gateway:watch`. It is safe to re-run, but you normally only need it on first setup or after resetting local state. `pnpm gateway:watch` hands the configured Gateway port from the installed service to a durable tmux pane; run `pnpm openclaw gateway start` when you want the installed service back. It does not rebuild `dist/control-ui`, so rerun `pnpm ui:build` after `ui/` changes or use `pnpm ui:dev` when iterating on the Control UI. If you want this checkout to run onboarding directly, use `pnpm openclaw onboard --install-daemon`.
Note: `pnpm openclaw ...` runs TypeScript directly (via `tsx`). `pnpm build` produces `dist/` for running via Node / the packaged `openclaw` binary, while `pnpm gateway:watch` rebuilds the runtime on demand during the dev loop.
+2 -2
View File
@@ -317,7 +317,7 @@ OpenClaw's web interface (Gateway Control UI + HTTP endpoints) is intended for *
### Node.js Version
OpenClaw requires **Node.js 22.19+, Node.js 23.11+, or Node.js 24+**. Node 24 is the recommended default runtime for new installs. The minimum supported Node 22 version includes important security patches:
OpenClaw requires **Node.js 22.22.3+, Node.js 24.15+, or Node.js 25.9+**. Node 24 is the recommended default runtime for new installs. These minimum versions include the upstream SQLite WAL-reset corruption fix; Node 23 is unsupported. The minimum supported Node 22 version also includes important security patches:
- CVE-2025-59466: async_hooks DoS vulnerability
- CVE-2026-21636: Permission model bypass vulnerability
@@ -325,7 +325,7 @@ OpenClaw requires **Node.js 22.19+, Node.js 23.11+, or Node.js 24+**. Node 24 is
Verify your Node.js version:
```bash
node --version # Should be v22.19+, v23.11+, or v24+
node --version # Should be v22.22.3+, v24.15+, or v25.9+
```
### Docker Security
+1750 -42
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+16310 -4318
View File
File diff suppressed because it is too large Load Diff
+9263 -1228
View File
File diff suppressed because it is too large Load Diff
+9265 -1230
View File
File diff suppressed because it is too large Load Diff
+9255 -1220
View File
File diff suppressed because it is too large Load Diff
+9256 -1221
View File
File diff suppressed because it is too large Load Diff
+9256 -1221
View File
File diff suppressed because it is too large Load Diff
+9274 -1239
View File
File diff suppressed because it is too large Load Diff
+9273 -1238
View File
File diff suppressed because it is too large Load Diff
+9254 -1219
View File
File diff suppressed because it is too large Load Diff
+9272 -1237
View File
File diff suppressed because it is too large Load Diff
+9252 -1217
View File
File diff suppressed because it is too large Load Diff
+9257 -1222
View File
File diff suppressed because it is too large Load Diff
+9260 -1225
View File
File diff suppressed because it is too large Load Diff
+9253 -1218
View File
File diff suppressed because it is too large Load Diff
+9265 -1230
View File
File diff suppressed because it is too large Load Diff
+9264 -1229
View File
File diff suppressed because it is too large Load Diff
+9268 -1233
View File
File diff suppressed because it is too large Load Diff
+9268 -1233
View File
File diff suppressed because it is too large Load Diff
+9267 -1232
View File
File diff suppressed because it is too large Load Diff
+9260 -1225
View File
File diff suppressed because it is too large Load Diff
+9265 -1230
View File
File diff suppressed because it is too large Load Diff
+9277 -1242
View File
File diff suppressed because it is too large Load Diff
+6 -7
View File
@@ -2,18 +2,17 @@
## Unreleased
Routes exec approval review through the Gateway's durable approval records, including first-answer-wins results from other authorized surfaces, fail-closed reconciliation after ambiguous writes, and compatibility with older Gateway v4 peers.
Shows the localized app version, Git commit, and build date together on the About screen, with real provenance in repository-backed debug builds.
Adds foreground, on-device Voice Wake with editable Gateway-synced wake words and automatic pause during other voice activity.
Recovers Android permission prompts after timeouts or cancellation without exhausting future requests. Thanks @NianJiuZst.
## 2026.7.2 - 2026-07-13
Requires a clear in-app disclosure and fresh consent before Installed Apps can share app names, package IDs, and status with a paired Gateway; existing opt-ins must consent again. Thanks @joshavant.
Adds Automations and Skills management with search, filters, editing, run tracking, install safety, and ClawHub risk review.
Adds an Android system share target that stages bounded text and image shares for review without losing existing composer drafts. Thanks @NianJiuZst.
Improves chat with per-device history, durable approval status, session search, sharing, and agent avatars.
Displays configured agent avatars across Android overview, settings, and chat, with bounded data and public remote image loading. Thanks @guarismo.
Adds provider model details, build identity, safer permission recovery, fresh Installed Apps consent, and Gateway protocol v3/v4 support.
Shows source-configured provider model inventory, capabilities, and route-aware availability in Android without exposing runtime route details. Thanks @snowzlmbot.
Thanks @snowzlmbot, @IWhatsskill, @NianJiuZst, and @guarismo.
## 2026.7.1 - 2026-07-08
+2 -2
View File
@@ -2,5 +2,5 @@
# Source of truth: apps/android/version.json
# Generated by scripts/android-sync-versioning.ts.
OPENCLAW_ANDROID_VERSION_NAME=2026.7.1
OPENCLAW_ANDROID_VERSION_CODE=2026070102
OPENCLAW_ANDROID_VERSION_NAME=2026.7.2
OPENCLAW_ANDROID_VERSION_CODE=2026070201
+3
View File
@@ -12,13 +12,16 @@ OpenClaw Android is the officially released Google Play app. It connects to an O
- [x] QR code scanning in onboarding
- [x] Performance improvements
- [x] Streaming support in chat UI
- [x] Dedicated per-device Android chat session created/adopted on connect without resetting history
- [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] Foreground on-device Voice Wake with Gateway-synced wake words
- [x] Screen tab full functionality
- [x] Skill Workshop settings can filter proposals, inspect proposal content, and apply/reject/quarantine drafts through Gateway RPCs
- [x] Skills settings can search installed skills, enable or disable them, and install Gateway-verified ClawHub releases
- [x] Per-app language selection for translated resources follows Android system settings and persistence
- [x] Cron job settings support details, run history, run now, edits, enable/disable, and deletion with admin-scoped Gateway access
@@ -30,6 +30,9 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
<intent>
<action android:name="android.speech.RecognitionService" />
</intent>
</queries>
<application
@@ -1,5 +1,6 @@
package ai.openclaw.app
import ai.openclaw.app.ui.SettingsRoute
import android.content.Intent
const val extraAndroidScreenshotMode = "openclaw.screenshotMode"
@@ -8,11 +9,14 @@ const val extraAndroidScreenshotScene = "openclaw.screenshotScene"
enum class AndroidScreenshotScene(
val rawValue: String,
val homeDestination: HomeDestination,
internal val settingsRoute: SettingsRoute? = null,
) {
Home("home", HomeDestination.Connect),
Chat("chat", HomeDestination.Chat),
Voice("voice", HomeDestination.Voice),
Settings("settings", HomeDestination.Settings),
Gateway("gateway", HomeDestination.Settings, SettingsRoute.Gateway),
VoiceWake("voice-wake", HomeDestination.Settings, SettingsRoute.Voice),
;
companion object {
@@ -1,5 +1,8 @@
package ai.openclaw.app
import ai.openclaw.app.i18n.NativeStringResources
import ai.openclaw.app.i18n.nativeString
import ai.openclaw.app.i18n.notifyNativeLocaleChanged
import android.content.Context
import android.content.res.Resources
import androidx.appcompat.app.AppCompatDelegate
@@ -70,8 +73,10 @@ internal fun localesForAppLanguage(language: AppLanguage): LocaleListCompat = la
internal fun setAppLanguage(language: AppLanguage) {
val locales = localesForAppLanguage(language)
NativeStringResources.setApplicationLocales(locales)
if (locales != AppCompatDelegate.getApplicationLocales()) {
AppCompatDelegate.setApplicationLocales(locales)
notifyNativeLocaleChanged()
}
}
@@ -86,6 +91,8 @@ internal fun appLanguageRowSubtitle(
systemLanguageTag: String,
): String {
val languageTag = language.languageTag
if (languageTag != null) return "OpenClaw translations · $languageTag"
return "Follow Android · $systemLanguageTag"
if (languageTag != null) {
return nativeString("OpenClaw translations · \$languageTag", languageTag)
}
return nativeString("Follow Android · \$systemLanguageTag", systemLanguageTag)
}
@@ -1,5 +1,9 @@
package ai.openclaw.app
import ai.openclaw.app.i18n.NativeText
import ai.openclaw.app.i18n.joinedNativeText
import ai.openclaw.app.i18n.nativeText
import ai.openclaw.app.i18n.verbatimText
import ai.openclaw.app.node.asObjectOrNull
import ai.openclaw.app.node.asStringOrNull
import kotlinx.serialization.json.JsonArray
@@ -16,8 +20,8 @@ data class GatewayCronJobDetail(
val enabled: Boolean,
val deleteAfterRun: Boolean,
val scheduleKind: String,
val scheduleLabel: String,
val scheduleDetail: String,
val scheduleLabel: NativeText,
val scheduleDetail: NativeText,
val scheduleAt: String?,
val scheduleEveryMs: Long?,
val scheduleAnchorMs: Long?,
@@ -30,13 +34,13 @@ data class GatewayCronJobDetail(
val wakeMode: String,
val payloadKind: String,
val payloadText: String?,
val payloadLabel: String,
val payloadLabel: NativeText,
val payloadModel: String?,
val payloadThinking: String?,
val payloadCommandArgv: List<String>?,
val payloadCommandCwd: String?,
val deliveryLabel: String,
val failureAlertLabel: String,
val deliveryLabel: NativeText,
val failureAlertLabel: NativeText,
val createdAtMs: Long,
val updatedAtMs: Long,
val configRevision: String?,
@@ -66,7 +70,7 @@ sealed interface GatewayCronJobDetailState {
data class Error(
val id: String,
val message: String,
val message: NativeText,
) : GatewayCronJobDetailState
}
@@ -203,41 +207,41 @@ internal fun parseGatewayCronJobDetail(job: JsonObject?): GatewayCronJobDetail?
)
}
internal fun formatCronInterval(everyMs: Long): String {
internal fun formatCronInterval(everyMs: Long): NativeText {
val minutes = everyMs / 60_000L
val hours = minutes / 60L
val days = hours / 24L
return when {
days >= 1 && hours % 24L == 0L -> "Every ${days}d"
hours >= 1 && minutes % 60L == 0L -> "Every ${hours}h"
minutes >= 1 -> "Every ${minutes}m"
else -> "Repeating"
days >= 1 && hours % 24L == 0L -> nativeText("Every \${days}d", days)
hours >= 1 && minutes % 60L == 0L -> nativeText("Every \${hours}h", hours)
minutes >= 1 -> nativeText("Every \${minutes}m", minutes)
else -> nativeText("Repeating")
}
}
private fun cronScheduleLabel(schedule: JsonObject): String =
private fun cronScheduleLabel(schedule: JsonObject): NativeText =
when (schedule.string("kind")) {
"at" -> "One time"
"every" -> schedule.long("everyMs")?.let(::formatCronInterval) ?: "Repeating"
"cron" -> schedule.string("expr") ?: "Cron"
else -> "Scheduled"
"at" -> nativeText("One time")
"every" -> schedule.long("everyMs")?.let(::formatCronInterval) ?: nativeText("Repeating")
"cron" -> schedule.string("expr")?.let(::verbatimText) ?: nativeText("Cron")
else -> nativeText("Scheduled")
}
private fun cronScheduleDetail(schedule: JsonObject): String =
private fun cronScheduleDetail(schedule: JsonObject): NativeText =
when (schedule.string("kind")) {
"at" -> schedule.string("at") ?: "One time"
"at" -> schedule.string("at")?.let(::verbatimText) ?: nativeText("One time")
"every" -> {
val every = schedule.long("everyMs")?.let(::formatCronInterval) ?: "Repeating"
val anchor = schedule.long("anchorMs")?.let { "Anchor $it" }
listOfNotNull(every, anchor).joinToString(" · ")
val every = schedule.long("everyMs")?.let(::formatCronInterval) ?: nativeText("Repeating")
val anchor = schedule.long("anchorMs")?.let { nativeText("Anchor \$it", it) }
joinedNativeText(" · ", listOfNotNull(every, anchor))
}
"cron" -> {
val expression = schedule.string("expr") ?: "Cron"
val timezone = schedule.string("tz")
val stagger = schedule.long("staggerMs")?.takeIf { it > 0L }?.let { "Stagger ${formatCronInterval(it)}" }
listOfNotNull(expression, timezone, stagger).joinToString(" · ")
val expression = schedule.string("expr")?.let(::verbatimText) ?: nativeText("Cron")
val timezone = schedule.string("tz")?.let(::verbatimText)
val stagger = schedule.long("staggerMs")?.takeIf { it > 0L }?.let { nativeText("Stagger \${formatCronInterval(it)}", formatCronInterval(it)) }
joinedNativeText(" · ", listOfNotNull(expression, timezone, stagger))
}
else -> "Scheduled"
else -> nativeText("Scheduled")
}
private fun cronPayloadText(payload: JsonObject): String? =
@@ -251,40 +255,44 @@ private fun cronPayloadText(payload: JsonObject): String? =
else -> null
}
private fun cronPayloadLabel(payload: JsonObject): String =
private fun cronPayloadLabel(payload: JsonObject): NativeText =
when (payload.string("kind")) {
"systemEvent" -> "System event"
"systemEvent" -> nativeText("System event")
"agentTurn" -> {
val model = payload.string("model")
val thinking = payload.string("thinking")?.let { "Thinking $it" }
listOfNotNull("Agent turn", model, thinking).joinToString(" · ")
val model = payload.string("model")?.let(::verbatimText)
val thinking = payload.string("thinking")?.let { nativeText("Thinking \$it", it) }
joinedNativeText(" · ", listOfNotNull(nativeText("Agent turn"), model, thinking))
}
"command" -> "Command"
else -> "Payload"
"command" -> nativeText("Command")
else -> nativeText("Payload")
}
private fun cronDeliveryLabel(delivery: JsonObject?): String {
val value = delivery ?: return "Default"
val mode = value.string("mode") ?: return "Default"
return listOfNotNull(
mode.replaceFirstChar { it.uppercaseChar() },
value.string("channel"),
value.string("to"),
value.string("accountId")?.let { "Account $it" },
).joinToString(" · ")
private fun cronDeliveryLabel(delivery: JsonObject?): NativeText {
val value = delivery ?: return nativeText("Default")
val mode = value.string("mode") ?: return nativeText("Default")
return joinedNativeText(
" · ",
listOfNotNull(
verbatimText(mode.replaceFirstChar { it.uppercaseChar() }),
value.string("channel")?.let(::verbatimText),
value.string("to")?.let(::verbatimText),
value.string("accountId")?.let { nativeText("Account \$it", it) },
),
)
}
private fun cronFailureAlertLabel(failureAlert: JsonElement?): String {
if ((failureAlert as? JsonPrimitive)?.booleanOrNull == false) return "Off"
val alert = failureAlert.asObjectOrNull() ?: return "Default"
return listOfNotNull(
alert.long("after")?.let { "After $it" },
alert.string("mode")?.replaceFirstChar { it.uppercaseChar() },
alert.string("channel"),
alert.string("to"),
alert.long("cooldownMs")?.takeIf { it > 0L }?.let { "Cooldown ${formatCronInterval(it)}" },
).joinToString(" · ")
.ifBlank { "On" }
private fun cronFailureAlertLabel(failureAlert: JsonElement?): NativeText {
if ((failureAlert as? JsonPrimitive)?.booleanOrNull == false) return nativeText("Off")
val alert = failureAlert.asObjectOrNull() ?: return nativeText("Default")
val parts =
listOfNotNull(
alert.long("after")?.let { nativeText("After \$it", it) },
alert.string("mode")?.replaceFirstChar { it.uppercaseChar() }?.let(::verbatimText),
alert.string("channel")?.let(::verbatimText),
alert.string("to")?.let(::verbatimText),
alert.long("cooldownMs")?.takeIf { it > 0L }?.let { nativeText("Cooldown \${formatCronInterval(it)}", formatCronInterval(it)) },
)
return if (parts.isEmpty()) nativeText("On") else joinedNativeText(" · ", parts)
}
private fun JsonObject.string(key: String): String? =
@@ -1,6 +1,9 @@
package ai.openclaw.app
import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.i18n.NativeText
import ai.openclaw.app.i18n.nativeText
import ai.openclaw.app.i18n.resolveNativeText
import ai.openclaw.app.node.asObjectOrNull
import ai.openclaw.app.node.asStringOrNull
import kotlinx.serialization.json.Json
@@ -65,10 +68,17 @@ sealed interface GatewayCronActionState {
data class Notice(
val id: String,
val message: String,
val message: NativeText,
val kind: GatewayCronNoticeKind,
val deleted: Boolean = false,
) : GatewayCronActionState
) : GatewayCronActionState {
constructor(
id: String,
message: String,
kind: GatewayCronNoticeKind,
deleted: Boolean = false,
) : this(id = id, message = nativeText(message), kind = kind, deleted = deleted)
}
}
/** Owns one queued manual run id per job so a stale tracker cannot clear a newer run. */
@@ -119,6 +129,33 @@ internal class PendingCronRunRegistry {
}
}
internal fun nextCronJobsPageOffset(
page: JsonObject?,
requestedOffset: Int,
pageCount: Int,
): Int? {
val responseOffset =
page
?.long("offset")
?.takeIf { it in 0L..Int.MAX_VALUE.toLong() }
?.toInt()
?: requestedOffset
val total = page?.long("total")?.takeIf { it >= 0 }
val hasMore =
(page?.get("hasMore") as? JsonPrimitive)?.booleanOrNull
?: (total != null && responseOffset.toLong() + pageCount < total)
if (!hasMore) return null
val nextOffset =
page
?.long("nextOffset")
?.takeIf { it in 0L..Int.MAX_VALUE.toLong() }
?.toInt()
?: Math.addExact(responseOffset, pageCount)
require(nextOffset > requestedOffset) { "Gateway returned a non-advancing cron jobs page." }
return nextOffset
}
sealed interface GatewayCronScheduleEdit {
data class At(
val at: String,
@@ -253,14 +290,26 @@ internal fun CronEditorDraftState.reconcileRestoredAction(
return if (isConnected && retainedSaveState) this else saveAborted()
}
internal enum class GatewayCronRunSkipReason(
val message: String,
) {
NotDue("Cron job is not due yet."),
AlreadyRunning("Cron job is already running."),
RestartRecoveryPending("Gateway restart recovery is still in progress."),
InvalidSpec("Cron job has an invalid configuration."),
Stopped("Cron scheduler is stopped."),
internal enum class GatewayCronRunSkipReason {
NotDue,
AlreadyRunning,
RestartRecoveryPending,
InvalidSpec,
Stopped,
;
val message: String
get() = messageText.resolveNativeText()
val messageText: NativeText
get() =
when (this) {
NotDue -> nativeText("Automation is not due yet.")
AlreadyRunning -> nativeText("Automation is already running.")
RestartRecoveryPending -> nativeText("Gateway restart recovery is still in progress.")
InvalidSpec -> nativeText("Automation has an invalid configuration.")
Stopped -> nativeText("Cron scheduler is stopped.")
}
}
internal sealed interface GatewayCronRunOutcome {
@@ -288,10 +337,10 @@ internal fun cronRunCompletionNotice(
): GatewayCronActionState.Notice {
val (message, kind) =
when (status) {
"ok" -> "Cron run finished." to GatewayCronNoticeKind.Success
"skipped" -> "Cron run skipped." to GatewayCronNoticeKind.Warning
"error" -> "Cron run failed." to GatewayCronNoticeKind.Error
else -> "Cron run finished with an unknown status." to GatewayCronNoticeKind.Warning
"ok" -> nativeText("Automation run finished.") to GatewayCronNoticeKind.Success
"skipped" -> nativeText("Automation run skipped.") to GatewayCronNoticeKind.Warning
"error" -> nativeText("Automation run failed.") to GatewayCronNoticeKind.Error
else -> nativeText("Automation run finished with an unknown status.") to GatewayCronNoticeKind.Warning
}
return GatewayCronActionState.Notice(id = jobId, message = message, kind = kind)
}
@@ -351,7 +400,7 @@ internal fun buildCronUpdateParams(
edit: GatewayCronJobEdit,
): String {
val name = edit.name.trim()
require(name.isNotEmpty()) { "Cron job name is required." }
require(name.isNotEmpty()) { "Automation name is required." }
val description = edit.description.trim()
val sessionTarget = edit.sessionTarget.trim()
require(
@@ -444,7 +493,7 @@ private fun buildCronSchedulePatch(
is GatewayCronScheduleEdit.At -> {
require(original.scheduleKind == "at") { "Changing schedule type is not supported here." }
val at = edit.at.trim()
require(at.isNotEmpty()) { "One-time cron jobs need an ISO time." }
require(at.isNotEmpty()) { "One-time automations need an ISO time." }
if (at == original.scheduleAt) {
null
} else {
@@ -495,7 +544,7 @@ private fun buildCronSchedulePatch(
is GatewayCronScheduleEdit.OnExit -> {
require(original.scheduleKind == "on-exit") { "Changing schedule type is not supported here." }
val command = edit.command.trim()
require(command.isNotEmpty()) { "On-exit cron jobs need a command." }
require(command.isNotEmpty()) { "On-exit automations need a command." }
val cwd = edit.cwd.trim().ifEmpty { null }
if (command == original.scheduleCommand && cwd == original.scheduleCwd) {
null
@@ -1,5 +1,9 @@
package ai.openclaw.app
import ai.openclaw.app.i18n.NativeText
import ai.openclaw.app.i18n.nativeString
import ai.openclaw.app.i18n.nativeText
import ai.openclaw.app.i18n.verbatimText
import ai.openclaw.app.node.asObjectOrNull
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
@@ -15,7 +19,7 @@ import java.util.concurrent.atomic.AtomicLong
data class GatewayExecApprovalSummary(
val id: String,
val commandText: String,
val commandText: NativeText,
val commandPreview: String?,
val warningText: String?,
val allowedDecisions: List<String>,
@@ -236,7 +240,7 @@ internal fun parseGatewayExecApprovalListEntry(item: JsonElement): GatewayExecAp
// details, so rendering waits for the reviewer-safe unified approval projection.
return GatewayExecApprovalSummary(
id = id,
commandText = gatewayExecApprovalCommandRequestText(),
commandText = nativeText("Command request"),
commandPreview = null,
warningText = null,
allowedDecisions = emptyList(),
@@ -248,7 +252,39 @@ internal fun parseGatewayExecApprovalListEntry(item: JsonElement): GatewayExecAp
)
}
private fun gatewayExecApprovalCommandRequestText(): String = "Command request"
internal fun gatewayExecApprovalTextForDisplay(text: String): String =
when (text) {
"Approval allowed and saved." -> nativeString("Approval allowed and saved.")
"Approval allowed once." -> nativeString("Approval allowed once.")
"A prior response already allowed this command and saved the choice." ->
nativeString("A prior response already allowed this command and saved the choice.")
"A prior response already allowed this command once." ->
nativeString("A prior response already allowed this command once.")
"Gateway recorded approval and saved the choice." ->
nativeString("Gateway recorded approval and saved the choice.")
"Gateway recorded approval once." -> nativeString("Gateway recorded approval once.")
"Approval denied." -> nativeString("Approval denied.")
"A prior response already denied this approval." ->
nativeString("A prior response already denied this approval.")
"Gateway recorded a denial." -> nativeString("Gateway recorded a denial.")
"This approval expired before it could be resolved." ->
nativeString("This approval expired before it could be resolved.")
"This approval was cancelled before it could be resolved." ->
nativeString("This approval was cancelled before it could be resolved.")
"A prior response already resolved this approval." ->
nativeString("A prior response already resolved this approval.")
"Command request" -> nativeString("Command request")
"Resolution outcome unknown. Actions stay disabled until the Gateway record is verified." ->
nativeString("Resolution outcome unknown. Actions stay disabled until the Gateway record is verified.")
"The Gateway still shows this approval as pending. Review it before trying again." ->
nativeString("The Gateway still shows this approval as pending. Review it before trying again.")
"Could not load approval details. Refresh and try again." ->
nativeString("Could not load approval details. Refresh and try again.")
"Could not load approvals." -> nativeString("Could not load approvals.")
"Could not resolve approval. Refresh and try again." ->
nativeString("Could not resolve approval. Refresh and try again.")
else -> text
}
internal fun parseGatewayExecApprovalGetPayload(
payloadJson: String,
@@ -309,7 +345,7 @@ internal fun parseLegacyGatewayExecApprovalGetPayload(
GatewayExecApprovalSnapshot.Pending(
GatewayExecApprovalSummary(
id = id,
commandText = commandText,
commandText = verbatimText(commandText),
commandPreview = commandPreview.value?.takeIf { it != commandText },
warningText = null,
allowedDecisions = allowedDecisions,
@@ -414,7 +450,7 @@ private fun parseGatewayExecApprovalPresentation(
val agentId = presentation.optionalString("agentId", requireNonEmpty = true) ?: return null
return GatewayExecApprovalSummary(
id = id,
commandText = commandText,
commandText = verbatimText(commandText),
commandPreview = commandPreview.value?.takeIf { it != commandText },
warningText = warningText.value,
allowedDecisions = allowedDecisions,
@@ -1,5 +1,10 @@
package ai.openclaw.app
import ai.openclaw.app.i18n.NativeText
import ai.openclaw.app.i18n.nativeString
import ai.openclaw.app.i18n.nativeText
import ai.openclaw.app.i18n.resolveNativeText
import ai.openclaw.app.i18n.verbatimText
import ai.openclaw.app.node.asObjectOrNull
import ai.openclaw.app.node.asStringOrNull
import kotlinx.serialization.json.JsonArray
@@ -40,10 +45,10 @@ sealed interface GatewayTalkSetupState {
}
enum class GatewayTalkSetupTarget(
val title: String,
val title: NativeText,
) {
REALTIME_TALK("Realtime Talk"),
DICTATION("Dictation"),
REALTIME_TALK(nativeText("Realtime Talk")),
DICTATION(nativeText("Dictation")),
}
sealed interface GatewayTalkSetupIssue {
@@ -98,32 +103,41 @@ val GatewayTalkSetupState.requiresSetup: Boolean
fun gatewayTalkSetupStatusText(state: GatewayTalkSetupState): String =
when (state) {
is GatewayTalkSetupState.Ready -> "Ready"
is GatewayTalkSetupState.NeedsSetup -> "Needs setup"
is GatewayTalkSetupState.Unverified -> "Unverified"
is GatewayTalkSetupState.Ready -> nativeString("Ready")
is GatewayTalkSetupState.NeedsSetup -> nativeString("Needs setup")
is GatewayTalkSetupState.Unverified -> nativeString("Unverified")
}
fun gatewayTalkSetupDescription(state: GatewayTalkSetupState): String =
fun gatewayTalkSetupDescription(state: GatewayTalkSetupState): String = gatewayTalkSetupDescriptionText(state).resolveNativeText()
internal fun gatewayTalkSetupDescriptionText(state: GatewayTalkSetupState): NativeText =
when (state) {
is GatewayTalkSetupState.Ready -> "${state.provider.label} via Gateway relay"
is GatewayTalkSetupState.NeedsSetup -> gatewayTalkSetupIssueDescription(state.issue)
is GatewayTalkSetupState.Unverified -> gatewayTalkSetupIssueDescription(state.issue)
is GatewayTalkSetupState.Ready ->
nativeText("\${state.provider.label} via Gateway relay", verbatimText(state.provider.label))
is GatewayTalkSetupState.NeedsSetup -> gatewayTalkSetupIssueDescriptionText(state.issue)
is GatewayTalkSetupState.Unverified -> gatewayTalkSetupIssueDescriptionText(state.issue)
}
private fun gatewayTalkSetupIssueDescription(issue: GatewayTalkSetupIssue): String =
internal fun gatewayTalkSetupIssueDescriptionText(issue: GatewayTalkSetupIssue): NativeText =
when (issue) {
GatewayTalkSetupIssue.CatalogNotLoaded -> "Gateway talk catalog not loaded"
GatewayTalkSetupIssue.CatalogLoadFailed -> "Could not load Gateway talk catalog"
is GatewayTalkSetupIssue.GroupMissing -> "Gateway did not return ${issue.target.title} setup"
is GatewayTalkSetupIssue.NoProvider -> "No ${issue.target.title} provider is configured on the Gateway"
is GatewayTalkSetupIssue.UnknownProvider -> "Gateway selected unknown provider ${issue.providerId}"
is GatewayTalkSetupIssue.MissingReadiness -> "Gateway did not return ${issue.target.title} readiness"
is GatewayTalkSetupIssue.ConfigureProvider -> "Configure a ${issue.target.title} provider on the Gateway"
GatewayTalkSetupIssue.CatalogNotLoaded -> nativeText("Gateway talk catalog not loaded")
GatewayTalkSetupIssue.CatalogLoadFailed -> nativeText("Could not load Gateway talk catalog")
is GatewayTalkSetupIssue.GroupMissing ->
nativeText("Gateway did not return \${issue.target.title} setup", issue.target.title)
is GatewayTalkSetupIssue.NoProvider ->
nativeText("No \${issue.target.title} provider is configured on the Gateway", issue.target.title)
is GatewayTalkSetupIssue.UnknownProvider ->
nativeText("Gateway selected unknown provider \${issue.providerId}", verbatimText(issue.providerId))
is GatewayTalkSetupIssue.MissingReadiness ->
nativeText("Gateway did not return \${issue.target.title} readiness", issue.target.title)
is GatewayTalkSetupIssue.ConfigureProvider ->
nativeText("Configure a \${issue.target.title} provider on the Gateway", issue.target.title)
is GatewayTalkSetupIssue.MissingActiveProvider ->
"Gateway did not identify the active ${issue.target.title} provider"
nativeText("Gateway did not identify the active \${issue.target.title} provider", issue.target.title)
is GatewayTalkSetupIssue.UnsupportedProvider ->
"Choose a supported ${issue.target.title} provider on the Gateway"
is GatewayTalkSetupIssue.ConfigureSelectedProvider -> "Configure ${issue.providerLabel} on the Gateway"
nativeText("Choose a supported \${issue.target.title} provider on the Gateway", issue.target.title)
is GatewayTalkSetupIssue.ConfigureSelectedProvider ->
nativeText("Configure \${issue.providerLabel} on the Gateway", verbatimText(issue.providerLabel))
}
internal fun parseGatewayTalkSetupReadiness(catalog: JsonObject?): GatewayTalkSetupReadiness {
@@ -1,5 +1,6 @@
package ai.openclaw.app
import ai.openclaw.app.i18n.nativeString
import ai.openclaw.app.ui.OpenClawTheme
import ai.openclaw.app.ui.RootScreen
import android.content.Intent
@@ -118,7 +119,7 @@ class MainActivity : AppCompatActivity() {
initializedViewModel?.let { handleLaunchIntent(viewModel = it, intent = routedIntent) }
}
if (!accepted) {
Toast.makeText(this, "Too many shares are waiting to be added.", Toast.LENGTH_SHORT).show()
Toast.makeText(this, nativeString("Too many shares are waiting to be added."), Toast.LENGTH_SHORT).show()
}
}
@@ -197,7 +198,7 @@ class MainActivity : AppCompatActivity() {
) {
parseShareLaunchIntent(intent)?.let { request ->
if (!viewModel.handleShareLaunch(request)) {
Toast.makeText(this, "Too many shares are waiting to be added.", Toast.LENGTH_SHORT).show()
Toast.makeText(this, nativeString("Too many shares are waiting to be added."), Toast.LENGTH_SHORT).show()
}
return
}
@@ -18,7 +18,9 @@ import ai.openclaw.app.node.CanvasController
import ai.openclaw.app.node.SmsManager
import ai.openclaw.app.ui.GatewayConnectPlan
import ai.openclaw.app.ui.GatewaySavedAuthAction
import ai.openclaw.app.ui.SettingsRoute
import ai.openclaw.app.voice.VoiceConversationEntry
import ai.openclaw.app.voice.VoiceWakePreferences
import android.Manifest
import android.app.Application
import android.net.Uri
@@ -141,11 +143,18 @@ internal class CronEditorDraftMemory {
* UI-facing bridge that exposes NodeRuntime and preference state as Compose-friendly StateFlows.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class MainViewModel(
class MainViewModel private constructor(
app: Application,
private val prefs: SecurePrefs,
) : AndroidViewModel(app) {
constructor(app: Application) : this(app, (app as NodeApp).prefs)
internal constructor(
app: NodeApp,
prefs: SecurePrefs,
) : this(app as Application, prefs)
private val nodeApp = app as NodeApp
private val prefs = nodeApp.prefs
private val runtimeRef = MutableStateFlow<NodeRuntime?>(null)
private val gatewayConfigOperationSeq = AtomicLong()
private val gatewayConfigOperationMutex = Mutex()
@@ -167,6 +176,8 @@ class MainViewModel(
private val _requestedHomeDestination = MutableStateFlow<HomeDestination?>(null)
val requestedHomeDestination: StateFlow<HomeDestination?> = _requestedHomeDestination
private val requestedSettingsRouteState = MutableStateFlow<SettingsRoute?>(null)
internal val requestedSettingsRoute: StateFlow<SettingsRoute?> get() = requestedSettingsRouteState
private val _startOnboardingAtGatewaySetup = MutableStateFlow(false)
val startOnboardingAtGatewaySetup: StateFlow<Boolean> = _startOnboardingAtGatewaySetup
private val _chatDraft = MutableStateFlow<ChatDraft?>(null)
@@ -199,17 +210,27 @@ class MainViewModel(
"Screenshot fixture mode must be selected before live runtime startup"
}
runtime.setForeground(foreground)
runtime.setVoiceWakeEnabled(scene == AndroidScreenshotScene.VoiceWake)
_requestedHomeDestination.value = scene.homeDestination
requestedSettingsRouteState.value = scene.settingsRoute
return
}
prefs.setOnboardingCompleted(true)
prefs.setAppearanceThemeMode(AppearanceThemeMode.Dark)
prefs.setDisplayName("Pixel")
prefs.setSpeakerEnabled(true)
prefs.setVoiceWakeEnabled(scene == AndroidScreenshotScene.VoiceWake)
prefs.setVoiceWakeWords(VoiceWakePreferences.defaultTriggerWords)
val runtime = nodeApp.ensureScreenshotFixtureRuntime()
runtime.setForeground(foreground)
runtimeRef.value = runtime
_requestedHomeDestination.value = scene.homeDestination
requestedSettingsRouteState.value = scene.settingsRoute
}
/** Acknowledges the one-shot settings-route request that accompanies a home destination. */
fun clearRequestedSettingsRoute() {
requestedSettingsRouteState.value = null
}
/**
@@ -225,6 +246,11 @@ class MainViewModel(
}
}
internal fun resumeNodeServiceForConnection() {
if (!prefs.onboardingCompleted.value) return
NodeForegroundService.resume(context = nodeApp, startNow = true)
}
/**
* Adapts a runtime StateFlow to a stable ViewModel StateFlow before runtime startup.
*/
@@ -305,6 +331,11 @@ class MainViewModel(
val skillsSummary: StateFlow<GatewaySkillsSummary> = runtimeState(initial = GatewaySkillsSummary(skills = emptyList())) { it.skillsSummary }
val skillsRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.skillsRefreshing }
val skillsErrorText: StateFlow<String?> = runtimeState(initial = null) { it.skillsErrorText }
val clawHubSkillMethodsAvailable: StateFlow<Boolean> =
runtimeState(initial = false) { it.clawHubSkillMethodsAvailable }
val skillMutationKeys: StateFlow<Set<String>> = runtimeState(initial = emptySet()) { it.skillMutationKeys }
val clawHubSkillSearchState: StateFlow<GatewayClawHubSkillSearchState> =
runtimeState(initial = GatewayClawHubSkillSearchState()) { it.clawHubSkillSearchState }
val skillWorkshopSummary: StateFlow<GatewaySkillWorkshopSummary> =
runtimeState(initial = GatewaySkillWorkshopSummary(proposals = emptyList())) { it.skillWorkshopSummary }
val skillWorkshopRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.skillWorkshopRefreshing }
@@ -350,6 +381,15 @@ class MainViewModel(
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
val installedAppsSharingEnabled: StateFlow<Boolean> = prefs.installedAppsSharingEnabled
val speakerEnabled: StateFlow<Boolean> = prefs.speakerEnabled
val voiceWakeEnabled: StateFlow<Boolean> = prefs.voiceWakeEnabled
val voiceWakeWords: StateFlow<List<String>> = prefs.voiceWakeWords
val voiceWakeAvailable: StateFlow<Boolean> = runtimeState(initial = false) { it.voiceWakeAvailable }
val voiceWakeIsListening: StateFlow<Boolean> = runtimeState(initial = false) { it.voiceWakeIsListening }
val voiceWakeStatusText: StateFlow<String> = runtimeState(initial = "Off") { it.voiceWakeStatusText }
val voiceWakeLastTriggeredCommand: StateFlow<String?> =
runtimeState(initial = null) { it.voiceWakeLastTriggeredCommand }
val voiceWakeWordsSaving: StateFlow<Boolean> = runtimeState(initial = false) { it.voiceWakeWordsSaving }
val voiceWakeWordsNoticeText: StateFlow<String?> = runtimeState(initial = null) { it.voiceWakeWordsNoticeText }
val appearanceThemeMode: StateFlow<AppearanceThemeMode> = prefs.appearanceThemeMode
val voiceCaptureMode: StateFlow<VoiceCaptureMode> = runtimeState(initial = VoiceCaptureMode.Off) { it.voiceCaptureMode }
val micEnabled: StateFlow<Boolean> = runtimeState(initial = false) { it.micEnabled }
@@ -415,7 +455,6 @@ class MainViewModel(
) {
val runtime = runtimeRef.value ?: return
runtime.camera.attachLifecycleOwner(owner)
runtime.camera.attachPermissionRequester(permissionRequester)
runtime.sms.attachPermissionRequester(permissionRequester)
this.permissionRequester = permissionRequester
}
@@ -444,11 +483,11 @@ class MainViewModel(
}
fun setCameraEnabled(value: Boolean) {
prefs.setCameraEnabled(value)
runtimeRef.value?.setCameraEnabled(value) ?: prefs.setCameraEnabled(value)
}
fun setLocationMode(mode: LocationMode) {
prefs.setLocationMode(mode)
runtimeRef.value?.setLocationMode(mode) ?: prefs.setLocationMode(mode)
}
fun setLocationPreciseEnabled(value: Boolean) {
@@ -483,6 +522,7 @@ class MainViewModel(
}
internal fun saveGatewayConfigAndConnect(plan: GatewayConnectPlan) {
resumeNodeServiceForConnection()
val operation = gatewayConfigOperationSeq.incrementAndGet()
// Gateway pairing touches encrypted prefs, identity files, and sockets; keep
// the whole sequence off the Compose thread so retries cannot trigger ANRs.
@@ -550,10 +590,14 @@ class MainViewModel(
ensureRuntime()
}
prefs.setOnboardingCompleted(value)
if (value) {
NodeForegroundService.resume(nodeApp, startNow = true)
}
}
/** Re-enters gateway setup after disconnecting and clearing one-time setup credentials. */
fun pairNewGateway() {
NodeForegroundService.stop(nodeApp)
val operation = gatewayConfigOperationSeq.incrementAndGet()
viewModelScope.launch(Dispatchers.Default) {
gatewayConfigOperationMutex.withLock {
@@ -741,11 +785,24 @@ class MainViewModel(
ensureRuntime().setSpeakerEnabled(enabled)
}
fun setVoiceWakeEnabled(enabled: Boolean) {
ensureRuntime().setVoiceWakeEnabled(enabled)
}
fun setVoiceWakeWords(values: List<String>) {
ensureRuntime().setVoiceWakeWords(values)
}
fun refreshVoiceWakePermission() {
ensureRuntime().refreshVoiceWakePermission()
}
fun setAppearanceThemeMode(mode: AppearanceThemeMode) {
prefs.setAppearanceThemeMode(mode)
}
fun refreshGatewayConnection() {
resumeNodeServiceForConnection()
viewModelScope.launch(Dispatchers.Default) {
ensureRuntime().refreshGatewayConnection()
}
@@ -756,6 +813,7 @@ class MainViewModel(
}
fun connect(endpoint: GatewayEndpoint) {
resumeNodeServiceForConnection()
viewModelScope.launch(Dispatchers.Default) {
ensureRuntime().connectSwitchingGateway(endpoint)
}
@@ -767,6 +825,7 @@ class MainViewModel(
bootstrapToken: String?,
password: String?,
) {
resumeNodeServiceForConnection()
viewModelScope.launch(Dispatchers.Default) {
ensureRuntime().connectSwitchingGateway(
endpoint,
@@ -780,10 +839,12 @@ class MainViewModel(
}
fun connectManual() {
resumeNodeServiceForConnection()
ensureRuntime().connectManual()
}
fun switchToGateway(stableId: String) {
resumeNodeServiceForConnection()
val operation = gatewayConfigOperationSeq.incrementAndGet()
viewModelScope.launch(Dispatchers.Default) {
gatewayConfigOperationMutex.withLock {
@@ -806,6 +867,7 @@ class MainViewModel(
}
fun disconnect() {
NodeForegroundService.stop(nodeApp)
val operation = gatewayConfigOperationSeq.incrementAndGet()
viewModelScope.launch(Dispatchers.Default) {
gatewayConfigOperationMutex.withLock {
@@ -952,6 +1014,37 @@ class MainViewModel(
ensureRuntime().clearSkillWorkshopMessage()
}
fun setSkillEnabled(
skillKey: String,
enabled: Boolean,
) {
ensureRuntime().setSkillEnabled(skillKey, enabled)
}
fun searchClawHubSkills(query: String) {
ensureRuntime().searchClawHubSkills(query)
}
fun reviewClawHubSkillInstall(skill: GatewayClawHubSkillSummary) {
ensureRuntime().reviewClawHubSkillInstall(skill)
}
fun dismissClawHubSkillInstallReview() {
ensureRuntime().dismissClawHubSkillInstallReview()
}
fun installClawHubSkill(
slug: String,
acknowledgeClawHubRisk: Boolean = false,
version: String? = null,
) {
ensureRuntime().installClawHubSkill(slug, acknowledgeClawHubRisk, version)
}
fun clearClawHubSkillMessage() {
ensureRuntime().clearClawHubSkillMessage()
}
fun refreshNodesDevices() {
ensureRuntime().refreshNodesDevices()
}
@@ -4,7 +4,10 @@ import ai.openclaw.app.chat.ChatCacheDatabase
import ai.openclaw.app.chat.RoomChatCommandOutbox
import ai.openclaw.app.gateway.DeviceAuthStore
import ai.openclaw.app.gateway.DeviceIdentityStore
import ai.openclaw.app.i18n.NativeStringResources
import ai.openclaw.app.i18n.notifyNativeLocaleChanged
import android.app.Application
import android.content.res.Configuration
import android.os.StrictMode
import androidx.room.withTransaction
import kotlinx.coroutines.CoroutineScope
@@ -102,6 +105,7 @@ class NodeApp : Application() {
override fun onCreate() {
super.onCreate()
NativeStringResources.install(this)
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy
@@ -119,4 +123,12 @@ class NodeApp : Application() {
)
}
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
// The process runtime survives Activity recreation, so retained text and
// serialized Home Canvas state need an explicit locale refresh signal.
NativeStringResources.setConfigurationLocales(newConfig)
notifyNativeLocaleChanged()
}
}
@@ -1,5 +1,7 @@
package ai.openclaw.app
import ai.openclaw.app.i18n.nativeLocaleChanges
import ai.openclaw.app.i18n.nativeString
import android.Manifest
import android.app.Notification
import android.app.NotificationChannel
@@ -20,9 +22,11 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicBoolean
/** Foreground service that keeps the Android node connection and voice capture visible to the OS. */
class NodeForegroundService : Service() {
@@ -38,7 +42,11 @@ class NodeForegroundService : Service() {
override fun onCreate() {
super.onCreate()
ensureChannel()
val initial = buildNotification(title = "OpenClaw Node", text = "Starting…")
val initial =
buildNotification(
title = nativeString("OpenClaw Node"),
text = nativeString("Starting…"),
)
startForegroundWithTypes(notification = initial)
}
@@ -83,45 +91,54 @@ class NodeForegroundService : Service() {
// can update without restarting runtime-owned connection work.
notificationJob =
scope.launch {
combine(
val notificationStates =
combine(
runtime.gatewayConnectionDisplay,
runtime.serverName,
runtime.voiceCaptureMode,
runtime.locationMode,
) { connection, server, mode, _ ->
VoiceNotificationBase(
status = connection.statusText,
server = server,
connected = connection.isConnected,
mode = mode,
)
},
combine(
runtime.micEnabled,
runtime.micIsListening,
runtime.talkModeListening,
runtime.talkModeSpeaking,
) { micEnabled, micListening, talkListening, talkSpeaking ->
VoiceNotificationCapture(
micEnabled = micEnabled,
micListening = micListening,
talkListening = talkListening,
talkSpeaking = talkSpeaking,
)
},
) { base, capture ->
VoiceNotificationState(base = base, capture = capture)
}.collect { state ->
combine(
runtime.gatewayConnectionDisplay,
runtime.serverName,
runtime.voiceCaptureMode,
runtime.locationMode,
) { connection, server, mode, _ ->
VoiceNotificationBase(
status = connection.statusText,
server = server,
connected = connection.isConnected,
mode = mode,
)
},
combine(
runtime.micEnabled,
runtime.micIsListening,
runtime.talkModeListening,
runtime.talkModeSpeaking,
) { micEnabled, micListening, talkListening, talkSpeaking ->
VoiceNotificationCapture(
micEnabled = micEnabled,
micListening = micListening,
talkListening = talkListening,
talkSpeaking = talkSpeaking,
)
},
) { base, capture ->
VoiceNotificationState(base = base, capture = capture)
}
refreshNotificationOnLocaleChanges(
states = notificationStates,
localeChanges = nativeLocaleChanges,
).collect { update ->
ensureChannelForLocaleRevision(update.localeRevision)
val state = update.state
voiceCaptureMode = state.mode
val title =
when {
state.connected && state.mode == VoiceCaptureMode.TalkMode -> "OpenClaw Node · Talk"
state.connected -> "OpenClaw Node · Connected"
else -> "OpenClaw Node"
state.connected && state.mode == VoiceCaptureMode.TalkMode ->
nativeString("OpenClaw Node · Talk")
state.connected -> nativeString("OpenClaw Node · Connected")
else -> nativeString("OpenClaw Node")
}
val displayStatus = gatewayConnectionStatusForDisplay(state.status)
val text =
(state.server?.let { "${state.status} · $it" } ?: state.status) +
(state.server?.let { nativeString("\$status · \$server", displayStatus, it) } ?: displayStatus) +
voiceNotificationSuffix(
mode = state.mode,
manualMicEnabled = state.capture.micEnabled,
@@ -137,6 +154,14 @@ class NodeForegroundService : Service() {
}
}
private var channelLocaleRevision: Long? = null
private fun ensureChannelForLocaleRevision(localeRevision: Long) {
if (channelLocaleRevision == localeRevision) return
ensureChannel()
channelLocaleRevision = localeRevision
}
override fun onStartCommand(
intent: Intent?,
flags: Int,
@@ -145,27 +170,39 @@ class NodeForegroundService : Service() {
latestStartId = maxOf(latestStartId, startId)
when (intent?.action) {
ACTION_STOP -> {
startSuppressed.set(true)
disconnectRequested = true
runtimeRestoreJob?.cancel()
runtimeRestoreJob = null
notificationJob?.cancel()
notificationJob = null
activeRuntime?.disconnect()
activeRuntime = null
(application as NodeApp).disconnectRuntimeAsync()
stopSelfResult(startId)
return START_NOT_STICKY
}
ACTION_RESUME -> {
startSuppressed.set(false)
disconnectRequested = false
}
ACTION_SET_VOICE_CAPTURE_MODE -> {
voiceCaptureMode = intent.getStringExtra(EXTRA_VOICE_CAPTURE_MODE).toVoiceCaptureMode()
startForegroundWithTypes(
notification =
buildNotification(
title = "OpenClaw Node",
text = if (voiceCaptureMode == VoiceCaptureMode.TalkMode) "Talk mode active" else "Connected",
title = nativeString("OpenClaw Node"),
text =
if (voiceCaptureMode == VoiceCaptureMode.TalkMode) {
nativeString("Talk mode active")
} else {
nativeString("Connected")
},
),
)
}
}
if (disconnectRequested) {
if (disconnectRequested || startSuppressed.get()) {
// A STOP can lose stopSelfResult to a newer queued start. Let the newest
// start id close the service instead of leaving a disconnected FGS alive.
stopSelfResult(startId)
@@ -190,10 +227,10 @@ class NodeForegroundService : Service() {
val channel =
NotificationChannel(
CHANNEL_ID,
"Connection",
nativeString("Connection"),
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "OpenClaw node connection status"
description = nativeString("OpenClaw node connection status")
setShowBadge(false)
}
mgr.createNotificationChannel(channel)
@@ -224,7 +261,7 @@ class NodeForegroundService : Service() {
.setOngoing(true)
.setOnlyAlertOnce(true)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.addAction(0, "Disconnect", stopPending)
.addAction(0, nativeString("Disconnect"), stopPending)
.build()
}
@@ -257,23 +294,38 @@ class NodeForegroundService : Service() {
private const val NOTIFICATION_ID = 1
private const val ACTION_STOP = "ai.openclaw.app.action.STOP"
private const val ACTION_RESUME = "ai.openclaw.app.action.RESUME"
private const val ACTION_SET_VOICE_CAPTURE_MODE = "ai.openclaw.app.action.SET_VOICE_CAPTURE_MODE"
private const val EXTRA_VOICE_CAPTURE_MODE = "ai.openclaw.app.extra.VOICE_CAPTURE_MODE"
private val startSuppressed = AtomicBoolean(false)
fun start(context: Context) {
if (startSuppressed.get()) return
val intent = Intent(context, NodeForegroundService::class.java)
context.startForegroundService(intent)
}
fun stop(context: Context) {
startSuppressed.set(true)
val intent = Intent(context, NodeForegroundService::class.java).setAction(ACTION_STOP)
context.startService(intent)
}
internal fun resume(
context: Context,
startNow: Boolean,
) {
startSuppressed.set(false)
if (!startNow) return
val intent = Intent(context, NodeForegroundService::class.java).setAction(ACTION_RESUME)
context.startForegroundService(intent)
}
fun setVoiceCaptureMode(
context: Context,
mode: VoiceCaptureMode,
) {
if (startSuppressed.get()) return
val intent =
Intent(context, NodeForegroundService::class.java)
.setAction(ACTION_SET_VOICE_CAPTURE_MODE)
@@ -334,7 +386,7 @@ internal fun foregroundServiceTypes(
internal fun backgroundLocationNotificationSuffix(active: Boolean): String =
if (active) {
" · Location: Always"
nativeString(" · Location: Always")
} else {
""
}
@@ -349,13 +401,17 @@ internal fun voiceNotificationSuffix(
when (mode) {
VoiceCaptureMode.TalkMode ->
when {
talkSpeaking -> " · Talk: Speaking"
talkListening -> " · Talk: Listening"
else -> " · Talk: On"
talkSpeaking -> nativeString(" · Talk: Speaking")
talkListening -> nativeString(" · Talk: Listening")
else -> nativeString(" · Talk: On")
}
VoiceCaptureMode.ManualMic ->
if (manualMicEnabled) {
if (manualMicListening) " · Mic: Listening" else " · Mic: Pending"
if (manualMicListening) {
nativeString(" · Mic: Listening")
} else {
nativeString(" · Mic: Pending")
}
} else {
""
}
@@ -397,3 +453,17 @@ private data class VoiceNotificationState(
val mode: VoiceCaptureMode
get() = base.mode
}
/** Re-emits stable runtime state when app-owned notification copy changes locale. */
internal data class LocaleAwareNotificationState<T>(
val state: T,
val localeRevision: Long,
)
internal fun <T> refreshNotificationOnLocaleChanges(
states: Flow<T>,
localeChanges: Flow<Long>,
): Flow<LocaleAwareNotificationState<T>> =
combine(states, localeChanges) { state, localeRevision ->
LocaleAwareNotificationState(state = state, localeRevision = localeRevision)
}
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,6 @@
package ai.openclaw.app
import ai.openclaw.app.i18n.nativeString
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
@@ -195,10 +196,10 @@ class PermissionRequester internal constructor(
dialog =
AlertDialog
.Builder(activity)
.setTitle("Permission required")
.setTitle(nativeString("Permission required"))
.setMessage(buildRationaleMessage(permissions))
.setPositiveButton("Continue") { _, _ -> finish(true) }
.setNegativeButton("Not now") { _, _ -> finish(false) }
.setPositiveButton(nativeString("Continue")) { _, _ -> finish(true) }
.setNegativeButton(nativeString("Not now")) { _, _ -> finish(false) }
.setOnCancelListener { finish(false) }
.show()
}
@@ -225,9 +226,9 @@ class PermissionRequester internal constructor(
dialog =
AlertDialog
.Builder(activity)
.setTitle("Enable permission in Settings")
.setTitle(nativeString("Enable permission in Settings"))
.setMessage(buildSettingsMessage(permissions))
.setPositiveButton("Open Settings") { _, _ ->
.setPositiveButton(nativeString("Open Settings")) { _, _ ->
if (activity.isFinishing || activity.isDestroyed) return@setPositiveButton
val intent =
Intent(
@@ -235,36 +236,42 @@ class PermissionRequester internal constructor(
Uri.fromParts("package", activity.packageName, null),
)
activity.startActivity(intent)
}.setNegativeButton("Cancel", null)
}.setNegativeButton(nativeString("Cancel"), null)
.setOnDismissListener { removeObserver() }
.show()
}
private fun buildRationaleMessage(permissions: List<String>): String {
val labels = permissions.map { permissionLabel(it) }
return "OpenClaw needs ${labels.joinToString(", ")} permissions to continue."
return nativeString(
"OpenClaw needs \${labels.joinToString(\", \")} permissions to continue.",
labels.joinToString(", "),
)
}
private fun buildSettingsMessage(permissions: List<String>): String {
val labels = permissions.map { permissionLabel(it) }
return "Please enable ${labels.joinToString(", ")} in Android Settings to continue."
return nativeString(
"Please enable \${labels.joinToString(\", \")} in Android Settings to continue.",
labels.joinToString(", "),
)
}
private fun permissionLabel(permission: String): String =
when (permission) {
Manifest.permission.CAMERA -> "Camera"
Manifest.permission.RECORD_AUDIO -> "Microphone"
Manifest.permission.SEND_SMS -> "Send SMS"
Manifest.permission.READ_SMS -> "Read SMS"
Manifest.permission.READ_CONTACTS -> "Read Contacts"
Manifest.permission.WRITE_CONTACTS -> "Write Contacts"
Manifest.permission.READ_CALENDAR -> "Read Calendar"
Manifest.permission.WRITE_CALENDAR -> "Write Calendar"
Manifest.permission.READ_CALL_LOG -> "Read Call Log"
Manifest.permission.ACTIVITY_RECOGNITION -> "Motion Activity"
Manifest.permission.READ_MEDIA_IMAGES -> "Photos"
Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED -> "Photos"
Manifest.permission.READ_EXTERNAL_STORAGE -> "Photos"
Manifest.permission.CAMERA -> nativeString("Camera")
Manifest.permission.RECORD_AUDIO -> nativeString("Microphone")
Manifest.permission.SEND_SMS -> nativeString("Send SMS")
Manifest.permission.READ_SMS -> nativeString("Read SMS")
Manifest.permission.READ_CONTACTS -> nativeString("Read Contacts")
Manifest.permission.WRITE_CONTACTS -> nativeString("Write Contacts")
Manifest.permission.READ_CALENDAR -> nativeString("Read Calendar")
Manifest.permission.WRITE_CALENDAR -> nativeString("Write Calendar")
Manifest.permission.READ_CALL_LOG -> nativeString("Read Call Log")
Manifest.permission.ACTIVITY_RECOGNITION -> nativeString("Motion Activity")
Manifest.permission.READ_MEDIA_IMAGES -> nativeString("Photos")
Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED -> nativeString("Photos")
Manifest.permission.READ_EXTERNAL_STORAGE -> nativeString("Photos")
else -> permission
}
}
@@ -5,6 +5,7 @@ package ai.openclaw.app
import ai.openclaw.app.gateway.GatewayCustomHeaders
import ai.openclaw.app.gateway.GatewayRegistryStore
import ai.openclaw.app.gateway.GatewayStoreMigration
import ai.openclaw.app.voice.VoiceWakePreferences
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
@@ -63,6 +64,8 @@ class SecurePrefs(
private const val currentInstalledAppsDisclosureConsentVersion = 1
private const val cameraEnabledKey = "camera.enabled"
private const val voiceMicEnabledKey = "voice.micEnabled"
private const val voiceWakeEnabledKey = "voiceWake.enabled"
private const val voiceWakeWordsKey = "voiceWake.triggerWords"
private const val appearanceThemeModeKey = "appearance.themeMode"
private const val chatModelFavoritesKey = "chat.modelFavorites"
private const val chatModelRecentsKey = "chat.modelRecents"
@@ -198,6 +201,12 @@ class SecurePrefs(
private val _voiceMicEnabled = MutableStateFlow(plainPrefs.getBoolean(voiceMicEnabledKey, false))
val voiceMicEnabled: StateFlow<Boolean> = _voiceMicEnabled
private val _voiceWakeEnabled = MutableStateFlow(plainPrefs.getBoolean(voiceWakeEnabledKey, false))
val voiceWakeEnabled: StateFlow<Boolean> = _voiceWakeEnabled
private val _voiceWakeWords = MutableStateFlow(loadVoiceWakeWords())
val voiceWakeWords: StateFlow<List<String>> = _voiceWakeWords
private val _speakerEnabled = MutableStateFlow(plainPrefs.getBoolean("voice.speakerEnabled", true))
val speakerEnabled: StateFlow<Boolean> = _speakerEnabled
@@ -621,11 +630,32 @@ class SecurePrefs(
_voiceMicEnabled.value = value
}
fun setVoiceWakeEnabled(value: Boolean) {
plainPrefs.edit { putBoolean(voiceWakeEnabledKey, value) }
_voiceWakeEnabled.value = value
}
fun setVoiceWakeWords(words: List<String>) {
val sanitized = VoiceWakePreferences.sanitizeTriggerWords(words)
plainPrefs.edit { putString(voiceWakeWordsKey, JsonArray(sanitized.map(::JsonPrimitive)).toString()) }
_voiceWakeWords.value = sanitized
}
fun setSpeakerEnabled(value: Boolean) {
plainPrefs.edit { putBoolean("voice.speakerEnabled", value) }
_speakerEnabled.value = value
}
private fun loadVoiceWakeWords(): List<String> {
val stored = plainPrefs.getString(voiceWakeWordsKey, null) ?: return VoiceWakePreferences.defaultTriggerWords
val decoded =
runCatching {
(json.parseToJsonElement(stored) as? JsonArray)
?.mapNotNull { (it as? JsonPrimitive)?.content }
}.getOrNull()
return VoiceWakePreferences.sanitizeTriggerWords(decoded.orEmpty())
}
fun setAppearanceThemeMode(mode: AppearanceThemeMode) {
plainPrefs.edit { putString(appearanceThemeModeKey, mode.rawValue) }
_appearanceThemeMode.value = mode
@@ -25,3 +25,13 @@ internal fun buildNodeMainSessionKey(
val resolvedAgentId = agentId?.trim().orEmpty().ifEmpty { "main" }
return "agent:$resolvedAgentId:node-${deviceId.take(12)}"
}
/** Human-readable, device-unique label applied when Android creates or adopts its session. */
internal fun buildAndroidAppSessionLabel(
displayName: String?,
deviceId: String,
): String {
val deviceSuffix = deviceId.take(12)
val displaySuffix = displayName?.trim()?.take(96)?.takeIf { it.isNotEmpty() }
return listOfNotNull("OpenClaw App", displaySuffix, deviceSuffix).joinToString(" · ")
}
@@ -0,0 +1,238 @@
package ai.openclaw.app
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.buildJsonObject
import kotlinx.serialization.json.contentOrNull
private const val CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED = "clawhub_risk_acknowledgement_required"
internal const val CLAWHUB_INSTALL_REQUEST_TIMEOUT_MS = 125_000L
internal const val CLAWHUB_SKILL_GATEWAY_UNAVAILABLE = "Update the Gateway to search and install ClawHub skills from Android."
internal val CLAWHUB_SKILL_GATEWAY_METHODS = setOf("skills.search", "skills.detail", "skills.install")
data class GatewayClawHubSkillSearchState(
val query: String = "",
val searching: Boolean = false,
val results: List<GatewayClawHubSkillSummary> = emptyList(),
val reviewingSlug: String? = null,
val installReview: GatewayClawHubInstallReview? = null,
val installingSlugs: Set<String> = emptySet(),
val acknowledgeSlug: String? = null,
val acknowledgeVersion: String? = null,
val errorText: String? = null,
val messageText: String? = null,
)
data class GatewayClawHubSkillSummary(
val slug: String,
val displayName: String,
val summary: String?,
val version: String?,
)
data class GatewayClawHubInstallReview(
val slug: String,
val displayName: String,
val summary: String?,
val version: String,
val author: String,
)
internal data class GatewayClawHubInstallRejection(
val message: String,
val warning: String?,
val acknowledgeVersion: String?,
val requiresAcknowledgement: Boolean,
)
internal fun parseClawHubSearchResults(
raw: String,
json: Json,
): List<GatewayClawHubSkillSummary> {
val root = json.parseToJsonElement(raw) as? JsonObject ?: return emptyList()
return (root["results"] as? JsonArray)
?.mapNotNull { item ->
val value = item as? JsonObject ?: return@mapNotNull null
val slug = value.string("slug") ?: return@mapNotNull null
val displayName = value.string("displayName") ?: return@mapNotNull null
GatewayClawHubSkillSummary(
slug = slug,
displayName = displayName,
summary = value.string("summary"),
version = value.string("version"),
)
}.orEmpty()
}
internal fun parseClawHubInstallReview(
raw: String,
fallback: GatewayClawHubSkillSummary,
json: Json,
): GatewayClawHubInstallReview? {
val root = json.parseToJsonElement(raw) as? JsonObject ?: return null
val skill = root["skill"] as? JsonObject
val latestVersion = root["latestVersion"] as? JsonObject
val owner = root["owner"] as? JsonObject
// The detail response is the install review boundary. Prefer its current
// version over the potentially stale search result shown before review.
val version = latestVersion?.string("version") ?: fallback.version ?: return null
val ownerDisplayName = owner?.string("displayName")
val ownerHandle = owner?.string("handle")
val reviewedSlug =
canonicalClawHubSkillReference(
slug = skill?.string("slug") ?: fallback.slug,
ownerHandle = ownerHandle,
) ?: return null
val author =
when {
ownerDisplayName != null && ownerHandle != null && !ownerDisplayName.equals(ownerHandle, ignoreCase = true) ->
"$ownerDisplayName (@$ownerHandle)"
ownerDisplayName != null -> ownerDisplayName
ownerHandle != null -> "@$ownerHandle"
else -> "Unknown publisher"
}
return GatewayClawHubInstallReview(
slug = reviewedSlug,
displayName = skill?.string("displayName") ?: fallback.displayName,
summary = skill?.string("summary") ?: fallback.summary,
version = version,
author = author,
)
}
internal fun clawHubInstallRejection(
error: GatewaySession.ErrorShape,
attemptedVersion: String?,
): GatewayClawHubInstallRejection {
val details = error.details
val reviewedVersion = attemptedVersion?.trim()?.takeIf(String::isNotEmpty)
val gatewayVersion = details?.clawhubVersion?.trim()?.takeIf(String::isNotEmpty)
val acknowledgementRequested =
details?.clawhubTrustCode == CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED
val requiresAcknowledgement =
acknowledgementRequested && reviewedVersion != null && gatewayVersion == reviewedVersion
return GatewayClawHubInstallRejection(
message =
if (acknowledgementRequested && !requiresAcknowledgement) {
"The Gateway evaluated a different ClawHub release. Review the skill again before installing."
} else {
error.message.ifBlank { "The Gateway rejected this ClawHub install." }
},
warning = details?.clawhubWarning?.trim()?.takeIf(String::isNotEmpty),
acknowledgeVersion = reviewedVersion.takeIf { requiresAcknowledgement },
requiresAcknowledgement = requiresAcknowledgement,
)
}
internal fun supportsClawHubSkillManagement(methods: Set<String>): Boolean = methods.containsAll(CLAWHUB_SKILL_GATEWAY_METHODS)
internal fun clawHubSearchParams(query: String): String =
buildJsonObject {
query.trim().takeIf(String::isNotEmpty)?.let { put("query", JsonPrimitive(it)) }
put("limit", JsonPrimitive(25))
}.toString()
internal fun clawHubDetailParams(slug: String): String = buildJsonObject { put("slug", JsonPrimitive(slug)) }.toString()
internal fun clawHubInstallParams(
slug: String,
version: String?,
acknowledgeRisk: Boolean,
): String =
buildJsonObject {
put("source", JsonPrimitive("clawhub"))
put("slug", JsonPrimitive(slug))
version?.trim()?.takeIf(String::isNotEmpty)?.let { put("version", JsonPrimitive(it)) }
if (acknowledgeRisk) put("acknowledgeClawHubRisk", JsonPrimitive(true))
put("timeoutMs", JsonPrimitive(120_000))
}.toString()
internal fun skillEnabledParams(
skillKey: String,
enabled: Boolean,
): String =
buildJsonObject {
put("skillKey", JsonPrimitive(skillKey))
put("enabled", JsonPrimitive(enabled))
}.toString()
internal fun formatClawHubInstallMessage(
message: String,
warning: String?,
): String = if (warning.isNullOrBlank()) message else "$message\n\n$warning"
internal fun isClawHubSkillInstalled(
skills: List<GatewaySkillSummary>,
slug: String,
): Boolean {
val reference = parseClawHubSkillReference(slug) ?: return false
return skills.any { it.matchesClawHubReference(reference) }
}
internal fun isClawHubSkillInstalled(
skills: List<GatewaySkillSummary>,
slug: String,
version: String,
): Boolean =
parseClawHubSkillReference(slug)?.let { reference ->
skills.any { it.matchesClawHubReference(reference) && it.clawHubInstalledVersion == version }
} ?: false
internal fun isClawHubSkillOperationActive(
activeSlugs: Set<String>,
slug: String,
): Boolean {
val reference = parseClawHubSkillReference(slug) ?: return false
return activeSlugs.any { activeSlug ->
val active = parseClawHubSkillReference(activeSlug) ?: return@any false
active.slug.equals(reference.slug, ignoreCase = true) &&
(
active.ownerHandle == null ||
reference.ownerHandle == null ||
active.ownerHandle.equals(reference.ownerHandle, ignoreCase = true)
)
}
}
private data class ClawHubSkillReference(
val slug: String,
val ownerHandle: String?,
)
private fun parseClawHubSkillReference(rawValue: String): ClawHubSkillReference? {
val value = rawValue.trim()
if (value.isEmpty()) return null
if (!value.startsWith("@")) return ClawHubSkillReference(value, null)
val parts = value.drop(1).split("/")
if (parts.size != 2 || parts.any(String::isEmpty)) return null
return ClawHubSkillReference(slug = parts[1], ownerHandle = parts[0].lowercase())
}
private fun canonicalClawHubSkillReference(
slug: String,
ownerHandle: String?,
): String? {
val reference = parseClawHubSkillReference(slug) ?: return null
val owner = ownerHandle?.trim()?.takeIf(String::isNotEmpty)?.lowercase() ?: reference.ownerHandle
return owner?.let { "@$it/${reference.slug}" } ?: reference.slug
}
private fun GatewaySkillSummary.matchesClawHubReference(reference: ClawHubSkillReference): Boolean {
if (!clawHubValid) return false
val installedReference = clawHubSlug?.let(::parseClawHubSkillReference) ?: return false
if (!installedReference.slug.equals(reference.slug, ignoreCase = true)) return false
val requestedOwner = reference.ownerHandle ?: return true
val installedOwner = installedReference.ownerHandle ?: clawHubOwnerHandle
return installedOwner?.equals(requestedOwner, ignoreCase = true) == true
}
internal fun clawHubInstallOutcomeUnknownMessage(slug: String): String = "The result for $slug is unknown. Reconnect, refresh Skills, then retry; the Gateway safely joins a matching install that is still running."
private fun JsonObject.string(key: String): String? =
(get(key) as? JsonPrimitive)
?.contentOrNull
?.trim()
?.takeIf(String::isNotEmpty)
File diff suppressed because it is too large Load Diff
@@ -1,8 +1,8 @@
package ai.openclaw.app.chat
import java.util.Locale
private val visibleChatMessageRoles = setOf("user", "assistant", "system", "custom")
internal const val CHAT_IMAGE_MAX_BASE64_CHARS = 300 * 1024
/** Keeps transcript rows limited to roles Android renders as user-visible chat. */
internal fun normalizeVisibleChatMessageRole(role: String?): String? =
@@ -61,10 +61,10 @@ internal val defaultChatThinkingLevelSelection =
ChatThinkingLevelSelection(
options =
listOf(
ChatThinkingLevelOption(id = "off", label = "off"),
ChatThinkingLevelOption(id = "low", label = "low"),
ChatThinkingLevelOption(id = "medium", label = "medium"),
ChatThinkingLevelOption(id = "high", label = "high"),
ChatThinkingLevelOption(id = "off", label = "Off"),
ChatThinkingLevelOption(id = "low", label = "Low"),
ChatThinkingLevelOption(id = "medium", label = "Medium"),
ChatThinkingLevelOption(id = "high", label = "High"),
),
isGatewayProvided = false,
)
@@ -1,7 +1,416 @@
// Generated by scripts/protocol-gen-kotlin.ts — do not edit by hand.
package ai.openclaw.app.gateway
/** Gateway protocol version emitted by Android node clients. */
const val GATEWAY_PROTOCOL_VERSION = 4
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/** Oldest gateway protocol version this Android client can speak safely. */
const val GATEWAY_MIN_PROTOCOL_VERSION = 4
const val GATEWAY_PROTOCOL_VERSION = 4
const val GATEWAY_MIN_PROTOCOL_VERSION = 3
@Serializable
data class GatewayProtocolError(
val code: String,
val message: String,
val details: JsonElement? = null,
val retryable: Boolean? = null,
val retryAfterMs: Long? = null,
)
@Serializable
data class GatewayRequestFrame(
val type: String = "req",
val id: String,
val method: String,
val params: JsonElement? = null,
)
@Serializable
data class GatewayResponseFrame(
val type: String = "res",
val id: String,
val ok: Boolean,
val payload: JsonElement? = null,
val error: GatewayProtocolError? = null,
)
@Serializable
data class GatewayEventFrame(
val type: String = "event",
val event: String,
val payload: JsonElement? = null,
val seq: Long? = null,
val stateVersion: GatewayEventFrameStateVersion? = null,
)
@Serializable
data class GatewayNodeEventParams(
val event: String,
val payload: JsonElement? = null,
@SerialName("payloadJSON")
val payloadJson: String? = null,
)
@Serializable
data class GatewayNodeInvokeResultParams(
val id: String,
val nodeId: String,
val ok: Boolean,
val payload: JsonElement? = null,
@SerialName("payloadJSON")
val payloadJson: String? = null,
val error: GatewayNodeInvokeResultParamsError? = null,
)
@Serializable
data class GatewayNodeInvokeRequest(
val id: String,
val nodeId: String,
val command: String,
@SerialName("paramsJSON")
val paramsJson: String? = null,
val timeoutMs: Long? = null,
val idempotencyKey: String? = null,
)
@Serializable
data class GatewayEventFrameStateVersion(
val presence: Long,
val health: Long,
)
@Serializable
data class GatewayNodeInvokeResultParamsError(
val code: String? = null,
val message: String? = null,
)
enum class GatewayMethod(
val rawValue: String,
) {
Health("health"),
DiagnosticsStability("diagnostics.stability"),
DoctorMemoryStatus("doctor.memory.status"),
DoctorMemoryDreamDiary("doctor.memory.dreamDiary"),
DoctorMemoryBackfillDreamDiary("doctor.memory.backfillDreamDiary"),
DoctorMemoryResetDreamDiary("doctor.memory.resetDreamDiary"),
DoctorMemoryResetGroundedShortTerm("doctor.memory.resetGroundedShortTerm"),
DoctorMemoryRepairDreamingArtifacts("doctor.memory.repairDreamingArtifacts"),
DoctorMemoryDedupeDreamDiary("doctor.memory.dedupeDreamDiary"),
DoctorMemoryRemHarness("doctor.memory.remHarness"),
LogsTail("logs.tail"),
ChannelsStatus("channels.status"),
ChannelsStart("channels.start"),
ChannelsStop("channels.stop"),
ChannelsLogout("channels.logout"),
Status("status"),
UsageStatus("usage.status"),
UsageCost("usage.cost"),
TtsStatus("tts.status"),
TtsProviders("tts.providers"),
TtsPersonas("tts.personas"),
TtsEnable("tts.enable"),
TtsDisable("tts.disable"),
TtsConvert("tts.convert"),
TtsSetProvider("tts.setProvider"),
TtsSetPersona("tts.setPersona"),
ConfigGet("config.get"),
ConfigSet("config.set"),
ConfigApply("config.apply"),
ConfigPatch("config.patch"),
ConfigSchema("config.schema"),
ConfigSchemaLookup("config.schema.lookup"),
ExecApprovalsGet("exec.approvals.get"),
ExecApprovalsSet("exec.approvals.set"),
ExecApprovalsNodeGet("exec.approvals.node.get"),
ExecApprovalsNodeSet("exec.approvals.node.set"),
ExecApprovalGet("exec.approval.get"),
ExecApprovalList("exec.approval.list"),
ExecApprovalRequest("exec.approval.request"),
ExecApprovalWaitDecision("exec.approval.waitDecision"),
ExecApprovalResolve("exec.approval.resolve"),
PluginApprovalList("plugin.approval.list"),
PluginApprovalRequest("plugin.approval.request"),
PluginApprovalWaitDecision("plugin.approval.waitDecision"),
PluginApprovalResolve("plugin.approval.resolve"),
PluginsUiDescriptors("plugins.uiDescriptors"),
PluginsSessionAction("plugins.sessionAction"),
CrestodianChat("crestodian.chat"),
CrestodianSetupDetect("crestodian.setup.detect"),
CrestodianSetupActivate("crestodian.setup.activate"),
CrestodianSetupAuthStart("crestodian.setup.auth.start"),
WizardStart("wizard.start"),
WizardNext("wizard.next"),
WizardCancel("wizard.cancel"),
WizardStatus("wizard.status"),
TalkCatalog("talk.catalog"),
TalkConfig("talk.config"),
TalkClientCreate("talk.client.create"),
TalkClientToolCall("talk.client.toolCall"),
TalkClientSteer("talk.client.steer"),
TalkSessionCreate("talk.session.create"),
TalkSessionJoin("talk.session.join"),
TalkSessionAppendAudio("talk.session.appendAudio"),
TalkSessionStartTurn("talk.session.startTurn"),
TalkSessionEndTurn("talk.session.endTurn"),
TalkSessionCancelTurn("talk.session.cancelTurn"),
TalkSessionCancelOutput("talk.session.cancelOutput"),
TalkSessionAcknowledgeMark("talk.session.acknowledgeMark"),
TalkSessionSubmitToolResult("talk.session.submitToolResult"),
TalkSessionSteer("talk.session.steer"),
TalkSessionClose("talk.session.close"),
TalkSpeak("talk.speak"),
TalkMode("talk.mode"),
CommandsList("commands.list"),
ModelsList("models.list"),
ModelsAuthStatus("models.authStatus"),
ModelsAuthLogout("models.authLogout"),
ToolsCatalog("tools.catalog"),
ToolsEffective("tools.effective"),
ToolsInvoke("tools.invoke"),
McpAppView("mcp.app.view"),
McpAppListTools("mcp.app.listTools"),
McpAppListResources("mcp.app.listResources"),
McpAppListResourceTemplates("mcp.app.listResourceTemplates"),
McpAppReadResource("mcp.app.readResource"),
McpAppCallTool("mcp.app.callTool"),
AuditList("audit.list"),
AuditActivityList("audit.activity.list"),
TasksList("tasks.list"),
TasksGet("tasks.get"),
TasksCancel("tasks.cancel"),
TaskSuggestionsList("taskSuggestions.list"),
TaskSuggestionsCreate("taskSuggestions.create"),
TaskSuggestionsAccept("taskSuggestions.accept"),
TaskSuggestionsDismiss("taskSuggestions.dismiss"),
EnvironmentsList("environments.list"),
EnvironmentsStatus("environments.status"),
WorktreesList("worktrees.list"),
WorktreesBranches("worktrees.branches"),
FsListDir("fs.listDir"),
WorktreesCreate("worktrees.create"),
WorktreesRemove("worktrees.remove"),
WorktreesRestore("worktrees.restore"),
WorktreesGc("worktrees.gc"),
AgentsList("agents.list"),
AgentsCreate("agents.create"),
AgentsUpdate("agents.update"),
AgentsDelete("agents.delete"),
AgentsFilesList("agents.files.list"),
AgentsFilesGet("agents.files.get"),
AgentsFilesSet("agents.files.set"),
SessionsFilesList("sessions.files.list"),
SessionsFilesGet("sessions.files.get"),
SessionsFilesSet("sessions.files.set"),
ArtifactsList("artifacts.list"),
ArtifactsGet("artifacts.get"),
ArtifactsDownload("artifacts.download"),
SkillsStatus("skills.status"),
SkillsSearch("skills.search"),
SkillsDetail("skills.detail"),
SkillsSecurityVerdicts("skills.securityVerdicts"),
SkillsSkillCard("skills.skillCard"),
SkillsBins("skills.bins"),
SkillsUploadBegin("skills.upload.begin"),
SkillsUploadChunk("skills.upload.chunk"),
SkillsUploadCommit("skills.upload.commit"),
SkillsInstall("skills.install"),
SkillsUpdate("skills.update"),
SkillsCuratorStatus("skills.curator.status"),
SkillsCuratorPin("skills.curator.pin"),
SkillsCuratorUnpin("skills.curator.unpin"),
SkillsCuratorRestore("skills.curator.restore"),
SkillsProposalsList("skills.proposals.list"),
SkillsProposalsInspect("skills.proposals.inspect"),
SkillsProposalsHistoryStatus("skills.proposals.historyStatus"),
SkillsProposalsHistoryScan("skills.proposals.historyScan"),
SkillsProposalsCreate("skills.proposals.create"),
SkillsProposalsUpdate("skills.proposals.update"),
SkillsProposalsRevise("skills.proposals.revise"),
SkillsProposalsRequestRevision("skills.proposals.requestRevision"),
SkillsProposalsApply("skills.proposals.apply"),
SkillsProposalsReject("skills.proposals.reject"),
SkillsProposalsQuarantine("skills.proposals.quarantine"),
UpdateStatus("update.status"),
UpdateRun("update.run"),
VoicewakeGet("voicewake.get"),
VoicewakeSet("voicewake.set"),
SecretsReload("secrets.reload"),
SecretsResolve("secrets.resolve"),
VoicewakeRoutingGet("voicewake.routing.get"),
VoicewakeRoutingSet("voicewake.routing.set"),
SessionsList("sessions.list"),
SessionsSubscribe("sessions.subscribe"),
SessionsUnsubscribe("sessions.unsubscribe"),
SessionsMessagesSubscribe("sessions.messages.subscribe"),
SessionsMessagesUnsubscribe("sessions.messages.unsubscribe"),
SessionsPreview("sessions.preview"),
SessionsDescribe("sessions.describe"),
SessionsCompactionList("sessions.compaction.list"),
SessionsCompactionGet("sessions.compaction.get"),
SessionsCompactionBranch("sessions.compaction.branch"),
SessionsCompactionRestore("sessions.compaction.restore"),
SessionsCreate("sessions.create"),
SessionsSend("sessions.send"),
SessionsAbort("sessions.abort"),
SessionsPatch("sessions.patch"),
SessionsPluginPatch("sessions.pluginPatch"),
SessionsCleanup("sessions.cleanup"),
SessionsReset("sessions.reset"),
SessionsDelete("sessions.delete"),
SessionsCompact("sessions.compact"),
SessionsGroupsList("sessions.groups.list"),
SessionsGroupsPut("sessions.groups.put"),
SessionsGroupsRename("sessions.groups.rename"),
SessionsGroupsDelete("sessions.groups.delete"),
LastHeartbeat("last-heartbeat"),
SetHeartbeats("set-heartbeats"),
Wake("wake"),
NodePairList("node.pair.list"),
NodePairApprove("node.pair.approve"),
NodePairReject("node.pair.reject"),
NodePairRemove("node.pair.remove"),
DevicePairList("device.pair.list"),
DevicePairApprove("device.pair.approve"),
DevicePairReject("device.pair.reject"),
DevicePairRemove("device.pair.remove"),
DevicePairRename("device.pair.rename"),
DeviceTokenRotate("device.token.rotate"),
DeviceTokenRevoke("device.token.revoke"),
DevicePairSetupCode("device.pair.setupCode"),
NodeRename("node.rename"),
NodeList("node.list"),
NodeDescribe("node.describe"),
NodePluginSurfaceRefresh("node.pluginSurface.refresh"),
NodePluginToolsUpdate("node.pluginTools.update"),
NodeSkillsUpdate("node.skills.update"),
NodePendingDrain("node.pending.drain"),
NodePendingEnqueue("node.pending.enqueue"),
NodeInvoke("node.invoke"),
NodePendingPull("node.pending.pull"),
NodePendingAck("node.pending.ack"),
NodeInvokeProgress("node.invoke.progress"),
NodeInvokeResult("node.invoke.result"),
NodeEvent("node.event"),
CronGet("cron.get"),
CronList("cron.list"),
CronStatus("cron.status"),
CronAdd("cron.add"),
CronUpdate("cron.update"),
CronRemove("cron.remove"),
CronRun("cron.run"),
CronRuns("cron.runs"),
GatewayIdentityGet("gateway.identity.get"),
GatewayRestartPreflight("gateway.restart.preflight"),
GatewayRestartRequest("gateway.restart.request"),
SystemPresence("system-presence"),
SystemEvent("system-event"),
MessageAction("message.action"),
Send("send"),
Agent("agent"),
AgentIdentityGet("agent.identity.get"),
AgentWait("agent.wait"),
ChatHistory("chat.history"),
ChatStartup("chat.startup"),
ChatMetadata("chat.metadata"),
ChatMessageGet("chat.message.get"),
ChatAbort("chat.abort"),
ChatSend("chat.send"),
TerminalOpen("terminal.open"),
TerminalInput("terminal.input"),
TerminalResize("terminal.resize"),
TerminalClose("terminal.close"),
AssistantMediaGet("assistant.media.get"),
SessionsGet("sessions.get"),
SessionsResolve("sessions.resolve"),
SessionsUsage("sessions.usage"),
SessionsUsageTimeseries("sessions.usage.timeseries"),
SessionsUsageLogs("sessions.usage.logs"),
Poll("poll"),
SessionsSteer("sessions.steer"),
PushTest("push.test"),
AttachGrant("attach.grant"),
AttachRevoke("attach.revoke"),
PushWebVapidPublicKey("push.web.vapidPublicKey"),
PushWebSubscribe("push.web.subscribe"),
PushWebUnsubscribe("push.web.unsubscribe"),
PushWebTest("push.web.test"),
ConfigOpenFile("config.openFile"),
Connect("connect"),
ChatInject("chat.inject"),
NativeHookInvoke("nativeHook.invoke"),
WebLoginStart("web.login.start"),
WebLoginWait("web.login.wait"),
TerminalAttach("terminal.attach"),
TerminalList("terminal.list"),
TerminalText("terminal.text"),
ControlUiGithubPreview("controlUi.githubPreview"),
SystemInfo("system.info"),
AgentsWorkspaceList("agents.workspace.list"),
AgentsWorkspaceGet("agents.workspace.get"),
TtsSpeak("tts.speak"),
PluginsList("plugins.list"),
PluginsSearch("plugins.search"),
PluginsInstall("plugins.install"),
PluginsSetEnabled("plugins.setEnabled"),
PluginsUninstall("plugins.uninstall"),
ControlUiSessionPullRequests("controlUi.sessionPullRequests"),
GatewaySuspendPrepare("gateway.suspend.prepare"),
GatewaySuspendStatus("gateway.suspend.status"),
GatewaySuspendResume("gateway.suspend.resume"),
ChatToolTitles("chat.toolTitles"),
SessionsDiff("sessions.diff"),
CrestodianSetupVerify("crestodian.setup.verify"),
EnvironmentsCreate("environments.create"),
EnvironmentsDestroy("environments.destroy"),
SessionsCatalogList("sessions.catalog.list"),
SessionsCatalogRead("sessions.catalog.read"),
SessionsCatalogContinue("sessions.catalog.continue"),
SessionsCatalogArchive("sessions.catalog.archive"),
ApprovalGet("approval.get"),
ApprovalResolve("approval.resolve"),
SessionsSearch("sessions.search"),
SessionsDispatch("sessions.dispatch"),
ModelsProbe("models.probe"),
MigrationsMemoryPlan("migrations.memory.plan"),
MigrationsMemoryApply("migrations.memory.apply"),
}
enum class GatewayEvent(
val rawValue: String,
) {
ConnectChallenge("connect.challenge"),
Agent("agent"),
Chat("chat"),
SessionApproval("session.approval"),
SessionMessage("session.message"),
SessionOperation("session.operation"),
SessionTool("session.tool"),
SessionsChanged("sessions.changed"),
Presence("presence"),
Tick("tick"),
TalkMode("talk.mode"),
TalkEvent("talk.event"),
Shutdown("shutdown"),
Health("health"),
Heartbeat("heartbeat"),
Cron("cron"),
Task("task"),
TaskSuggestion("task.suggestion"),
NodePairRequested("node.pair.requested"),
NodePairResolved("node.pair.resolved"),
NodePresence("node.presence"),
NodeInvokeCancel("node.invoke.cancel"),
NodeInvokeRequest("node.invoke.request"),
DevicePairRequested("device.pair.requested"),
DevicePairResolved("device.pair.resolved"),
VoicewakeChanged("voicewake.changed"),
VoicewakeRoutingChanged("voicewake.routing.changed"),
ExecApprovalRequested("exec.approval.requested"),
ExecApprovalResolved("exec.approval.resolved"),
PluginApprovalRequested("plugin.approval.requested"),
PluginApprovalResolved("plugin.approval.resolved"),
TerminalData("terminal.data"),
TerminalExit("terminal.exit"),
UpdateAvailable("update.available"),
}
@@ -89,6 +89,9 @@ data class GatewayConnectErrorDetails(
val clientMaxProtocol: Int? = null,
val expectedProtocol: Int? = null,
val minimumProbeProtocol: Int? = null,
val clawhubTrustCode: String? = null,
val clawhubWarning: String? = null,
val clawhubVersion: String? = null,
)
private val gatewayApprovalRequestIdPattern = Regex("^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
@@ -218,7 +221,24 @@ class GatewaySession(
val error: ErrorShape?,
)
private val json = Json { ignoreUnknownKeys = true }
/** One ready physical WebSocket captured before queued work starts waiting. */
internal class RequestLease internal constructor(
val endpointStableId: String,
private val requestImpl: suspend (method: String, paramsJson: String?, timeoutMs: Long) -> String,
) {
suspend fun request(
method: String,
paramsJson: String?,
timeoutMs: Long = 15_000,
): String = requestImpl(method, paramsJson, timeoutMs)
}
private val json =
Json {
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
}
private val writeLock = Mutex()
@Volatile private var pluginSurfaceUrls: Map<String, String> = emptyMap()
@@ -357,6 +377,9 @@ class GatewaySession(
internal fun isReady(): Boolean = readyConnection() != null
/** Current physical connection identity, including events sent during connect publication. */
internal fun currentEndpointStableId(): String? = currentConnection?.endpoint?.stableId
/** Sends a best-effort node.event and returns false instead of throwing on failure. */
suspend fun sendNodeEvent(
event: String,
@@ -384,13 +407,16 @@ class GatewaySession(
val conn = readyConnection(expectedEndpointStableId) ?: return NodeEventSendOutcome.DISCONNECTED
return try {
conn.request(
"node.event",
GatewayMethod.NodeEvent.rawValue,
buildNodeEventParams(event = event, payloadJson = payloadJson),
timeoutMs = 8_000,
)
NodeEventSendOutcome.COMPLETED
} catch (_: GatewayRequestNotEnqueued) {
NodeEventSendOutcome.DISCONNECTED
} catch (err: CancellationException) {
// Voice/audio ownership takeover must cancel before a stale node event dispatches.
throw err
} catch (err: Throwable) {
Log.w("OpenClawGateway", "node.event failed: ${err::class.java.simpleName}")
NodeEventSendOutcome.FAILED
@@ -419,7 +445,7 @@ class GatewaySession(
)
val params = buildNodeEventParams(event = event, payloadJson = payloadJson)
try {
val res = conn.request("node.event", params, timeoutMs = timeoutMs)
val res = conn.request(GatewayMethod.NodeEvent.rawValue, params, timeoutMs = timeoutMs)
return RpcResult(ok = res.ok, payloadJson = res.payloadJson, error = res.error)
} catch (err: Throwable) {
Log.w("OpenClawGateway", "node.event failed: ${err::class.java.simpleName}")
@@ -435,11 +461,11 @@ class GatewaySession(
event: String,
payloadJson: String?,
): JsonObject =
buildJsonObject {
put("event", JsonPrimitive(event))
// Gateway node events carry payloadJSON as a string for compatibility with non-JSON payload producers.
put("payloadJSON", JsonPrimitive(payloadJson ?: "{}"))
}
json
.encodeToJsonElement(
GatewayNodeEventParams.serializer(),
GatewayNodeEventParams(event = event, payloadJson = payloadJson ?: "{}"),
).asObjectOrNull() ?: error("GatewayNodeEventParams must encode as an object")
/** Sends an RPC request and throws a code-prefixed exception when the gateway returns an error. */
suspend fun request(
@@ -463,12 +489,30 @@ class GatewaySession(
throw GatewayRequestRejected(res.error ?: ErrorShape("UNAVAILABLE", "request failed"))
}
/** Captures the current physical connection; requests never resolve a replacement socket. */
internal fun captureRequestLease(expectedEndpointStableId: String? = null): RequestLease? {
val conn = readyConnection(expectedEndpointStableId) ?: return null
return RequestLease(endpointStableId = conn.endpoint.stableId) { method, paramsJson, timeoutMs ->
val res = requestDetailed(conn, method, paramsJson, timeoutMs)
if (!res.ok) {
throw GatewayRequestRejected(res.error ?: ErrorShape("UNAVAILABLE", "request failed"))
}
res.payloadJson ?: ""
}
}
/** Sends an RPC request and returns the structured success/error payload. */
suspend fun requestDetailed(
method: String,
paramsJson: String?,
timeoutMs: Long = 15_000,
): RpcResult = requestDetailed(expectedEndpointStableId = null, method, paramsJson, timeoutMs)
): RpcResult =
requestDetailed(
expectedEndpointStableId = null,
method,
paramsJson,
timeoutMs,
)
private suspend fun requestDetailed(
expectedEndpointStableId: String?,
@@ -477,12 +521,26 @@ class GatewaySession(
timeoutMs: Long,
): RpcResult {
val conn = readyConnection(expectedEndpointStableId) ?: throw GatewayRequestNotEnqueued("not connected")
return requestDetailed(conn, method, paramsJson, timeoutMs)
}
private suspend fun requestDetailed(
conn: Connection,
method: String,
paramsJson: String?,
timeoutMs: Long,
): RpcResult {
val params =
if (paramsJson.isNullOrBlank()) {
null
} else {
json.parseToJsonElement(paramsJson)
}
// Readiness and identity are checked after parsing, immediately before enqueue.
// A later reconnect can only close this exact socket, never retarget the lease.
if (currentConnection !== conn || !conn.isReady()) {
throw GatewayRequestNotEnqueued("gateway request lease changed")
}
val res = conn.request(method, params, timeoutMs)
return RpcResult(ok = res.ok, payloadJson = res.payloadJson, error = res.error)
}
@@ -686,12 +744,11 @@ class GatewaySession(
method: String,
params: JsonElement?,
): JsonObject =
buildJsonObject {
put("type", JsonPrimitive("req"))
put("id", JsonPrimitive(id))
put("method", JsonPrimitive(method))
if (params != null) put("params", params)
}
json
.encodeToJsonElement(
GatewayRequestFrame.serializer(),
GatewayRequestFrame(id = id, method = method, params = params),
).asObjectOrNull() ?: error("GatewayRequestFrame must encode as an object")
suspend fun awaitClose() = closedDeferred.await()
@@ -854,7 +911,7 @@ class GatewaySession(
connectNonce = connectNonce,
selectedAuth = selectedAuth,
)
val res = request("connect", payload, timeoutMs = CONNECT_RPC_TIMEOUT_MS)
val res = request(GatewayMethod.Connect.rawValue, payload, timeoutMs = CONNECT_RPC_TIMEOUT_MS)
if (!res.ok) {
val error = res.error ?: ErrorShape("UNAVAILABLE", "connect failed")
val shouldRetryWithDeviceToken =
@@ -893,13 +950,12 @@ class GatewaySession(
): List<String>? =
when (role.trim()) {
"node" -> emptyList()
// Setup-code bootstrap handoff is deliberately least-privilege. It never
// persists operator.admin, so Skill Workshop lifecycle actions remain
// disabled until shared token/password auth or an owner-approved scope
// upgrade issues an admin-scoped operator device token.
// The Gateway bounds setup-code handoff to a closed mobile profile. Persist
// only the supported full or limited scope set and drop unexpected extras.
"operator" -> {
val allowedOperatorScopes =
setOf(
"operator.admin",
"operator.approvals",
"operator.read",
"operator.talk.secrets",
@@ -1151,15 +1207,17 @@ class GatewaySession(
}
private fun handleResponse(frame: JsonObject) {
val id = frame["id"].asStringOrNull() ?: return
val response =
runCatching {
json.decodeFromJsonElement(GatewayResponseFrame.serializer(), frame)
}.getOrNull() ?: return
val id = response.id
if (id == connectRequestId) connectResponseAccepted.set(true)
val ok = frame["ok"].asBooleanOrNull() ?: false
val payloadJson = frame["payload"]?.let { payload -> payload.toString() }
// Read the raw element so an explicit JSON null remains distinguishable from an omitted payload.
val payloadJson = frame["payload"]?.toString()
val error =
frame["error"]?.asObjectOrNull()?.let { obj ->
val code = obj["code"].asStringOrNull() ?: "UNAVAILABLE"
val msg = obj["message"].asStringOrNull() ?: "request failed"
val detailObj = obj["details"].asObjectOrNull()
response.error?.let { wireError ->
val detailObj = wireError.details.asObjectOrNull()
val details =
detailObj?.let {
GatewayConnectErrorDetails(
@@ -1169,30 +1227,39 @@ class GatewaySession(
pauseReconnect = it["pauseReconnect"].asBooleanOrNull(),
reason = it["reason"].asStringOrNull(),
requestId = normalizeGatewayApprovalRequestId(it["requestId"].asStringOrNull()),
retryable = it["retryable"].asBooleanOrNull() == true,
retryable = it["retryable"].asBooleanOrNull() == true || wireError.retryable == true,
clientMinProtocol = it["clientMinProtocol"].asIntOrNull(),
clientMaxProtocol = it["clientMaxProtocol"].asIntOrNull(),
expectedProtocol = it["expectedProtocol"].asIntOrNull(),
minimumProbeProtocol = it["minimumProbeProtocol"].asIntOrNull(),
clawhubTrustCode = it["clawhubTrustCode"].asStringOrNull(),
clawhubWarning = it["warning"].asStringOrNull(),
clawhubVersion = it["version"].asStringOrNull(),
)
}
ErrorShape(code, msg, details)
ErrorShape(wireError.code, wireError.message, details)
}
pending.remove(id)?.complete(RpcResponse(id, ok, payloadJson, error))
pending.remove(id)?.complete(RpcResponse(id, response.ok, payloadJson, error))
}
private fun handleEvent(frame: JsonObject) {
val event = frame["event"].asStringOrNull() ?: return
val gatewayEvent =
runCatching {
json.decodeFromJsonElement(GatewayEventFrame.serializer(), frame)
}.getOrNull() ?: return
val event = gatewayEvent.event
val payloadJson =
frame["payload"]?.let { it.toString() } ?: frame["payloadJSON"].asStringOrNull()
if (event == "connect.challenge") {
frame["payload"]?.toString() ?: frame["payloadJSON"].asStringOrNull()
if (event == GatewayEvent.ConnectChallenge.rawValue) {
val nonce = extractConnectNonce(payloadJson)
if (!connectNonceDeferred.isCompleted && !nonce.isNullOrBlank()) {
connectNonceDeferred.complete(nonce.trim())
}
return
}
if (event == "node.invoke.request" && payloadJson != null && onInvoke != null) {
// Retired sockets can still drain queued frames after reconnect. Never let them mutate current state.
if (currentConnection !== this) return
if (event == GatewayEvent.NodeInvokeRequest.rawValue && payloadJson != null && onInvoke != null) {
handleInvokeEvent(payloadJson)
return
}
@@ -1214,22 +1281,27 @@ class GatewaySession(
private fun handleInvokeEvent(payloadJson: String) {
val payload =
try {
json.parseToJsonElement(payloadJson).asObjectOrNull()
} catch (_: Throwable) {
null
} ?: return
val id = payload["id"].asStringOrNull() ?: return
val nodeId = payload["nodeId"].asStringOrNull() ?: return
val command = payload["command"].asStringOrNull() ?: return
val params =
payload["paramsJSON"].asStringOrNull()
?: payload["params"]?.let { value -> if (value is JsonNull) null else value.toString() }
val timeoutMs = payload["timeoutMs"].asLongOrNull()
runCatching {
json.decodeFromString(GatewayNodeInvokeRequest.serializer(), payloadJson)
}.getOrNull() ?: return
// Older gateways sent structured `params`; keep accepting that shipped wire shape while
// generated models follow the canonical `paramsJSON` schema.
val paramsJson =
payload.paramsJson
?: runCatching {
json.parseToJsonElement(payloadJson).asObjectOrNull()?.get("params")
}.getOrNull()?.let { value -> if (value is JsonNull) null else value.toString() }
connectionScope.launch {
val request = InvokeRequest(id, nodeId, command, params, timeoutMs)
val request =
InvokeRequest(
id = payload.id,
nodeId = payload.nodeId,
command = payload.command,
paramsJson = paramsJson,
timeoutMs = payload.timeoutMs,
)
val result = executeInvokeRequest(request)
sendInvokeResult(id, nodeId, result, timeoutMs)
sendInvokeResult(payload.id, payload.nodeId, result, payload.timeoutMs)
}
}
@@ -1271,29 +1343,24 @@ class GatewaySession(
) {
val parsedPayload = result.payloadJson?.let { parseJsonOrNull(it) }
val params =
buildJsonObject {
put("id", JsonPrimitive(id))
put("nodeId", JsonPrimitive(nodeId))
put("ok", JsonPrimitive(result.ok))
if (parsedPayload != null) {
put("payload", parsedPayload)
} else if (result.payloadJson != null) {
// Preserve malformed/non-object payloads as payloadJSON so the gateway can report handler output.
put("payloadJSON", JsonPrimitive(result.payloadJson))
}
result.error?.let { err ->
put(
"error",
buildJsonObject {
put("code", JsonPrimitive(err.code))
put("message", JsonPrimitive(err.message))
},
)
}
}
json
.encodeToJsonElement(
GatewayNodeInvokeResultParams.serializer(),
GatewayNodeInvokeResultParams(
id = id,
nodeId = nodeId,
ok = result.ok,
payload = parsedPayload,
payloadJson = if (parsedPayload == null) result.payloadJson else null,
error =
result.error?.let { err ->
GatewayNodeInvokeResultParamsError(code = err.code, message = err.message)
},
),
).asObjectOrNull() ?: error("GatewayNodeInvokeResultParams must encode as an object")
val ackTimeoutMs = resolveInvokeResultAckTimeoutMs(invokeTimeoutMs)
try {
request("node.invoke.result", params, timeoutMs = ackTimeoutMs)
request(GatewayMethod.NodeInvokeResult.rawValue, params, timeoutMs = ackTimeoutMs)
} catch (err: Throwable) {
Log.w(
loggerTag,
@@ -6,6 +6,7 @@ import kotlinx.coroutines.withContext
import java.io.EOFException
import java.net.ConnectException
import java.net.InetSocketAddress
import java.net.Socket
import java.net.SocketException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
@@ -19,11 +20,13 @@ import javax.net.ssl.HostnameVerifier
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SNIHostName
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLEngine
import javax.net.ssl.SSLException
import javax.net.ssl.SSLParameters
import javax.net.ssl.SSLSocket
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509ExtendedTrustManager
import javax.net.ssl.X509TrustManager
/** TLS pinning inputs for a discovered or manually configured gateway endpoint. */
@@ -63,15 +66,27 @@ fun buildGatewayTlsConfig(
onStore: ((String) -> Unit)? = null,
): GatewayTlsConfig? {
if (params == null) return null
return buildGatewayTlsConfig(
params = params,
defaultTrust = defaultTrustManager(),
onStore = onStore,
)
}
internal fun buildGatewayTlsConfig(
params: GatewayTlsParams,
defaultTrust: X509TrustManager,
onStore: ((String) -> Unit)? = null,
): GatewayTlsConfig {
val expected =
params.expectedFingerprint
?.let(::normalizeGatewayTlsFingerprint)
?.takeIf { it.isNotBlank() }
val defaultTrust = defaultTrustManager()
val usesPlatformTrust = expected == null && !params.allowTOFU
@SuppressLint("CustomX509TrustManager")
val trustManager =
object : X509TrustManager {
object : X509ExtendedTrustManager() {
override fun checkClientTrusted(
chain: Array<X509Certificate>,
authType: String,
@@ -79,6 +94,30 @@ fun buildGatewayTlsConfig(
defaultTrust.checkClientTrusted(chain, authType)
}
override fun checkClientTrusted(
chain: Array<X509Certificate>,
authType: String,
socket: Socket,
) {
if (defaultTrust is X509ExtendedTrustManager) {
defaultTrust.checkClientTrusted(chain, authType, socket)
} else {
checkClientTrusted(chain, authType)
}
}
override fun checkClientTrusted(
chain: Array<X509Certificate>,
authType: String,
engine: SSLEngine,
) {
if (defaultTrust is X509ExtendedTrustManager) {
defaultTrust.checkClientTrusted(chain, authType, engine)
} else {
checkClientTrusted(chain, authType)
}
}
override fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
@@ -101,6 +140,31 @@ fun buildGatewayTlsConfig(
defaultTrust.checkServerTrusted(chain, authType)
}
override fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
socket: Socket,
) {
if (usesPlatformTrust && defaultTrust is X509ExtendedTrustManager) {
// Preserve the connected hostname for Android's domain-aware platform trust manager.
defaultTrust.checkServerTrusted(chain, authType, socket)
} else {
checkServerTrusted(chain, authType)
}
}
override fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
engine: SSLEngine,
) {
if (usesPlatformTrust && defaultTrust is X509ExtendedTrustManager) {
defaultTrust.checkServerTrusted(chain, authType, engine)
} else {
checkServerTrusted(chain, authType)
}
}
override fun getAcceptedIssuers(): Array<X509Certificate> = defaultTrust.acceptedIssuers
}
@@ -147,12 +211,24 @@ internal suspend fun probeGatewayTlsFingerprint(
val fingerprintRef = AtomicReference<String?>(null)
val probeTrustManager =
@SuppressLint("CustomX509TrustManager")
object : X509TrustManager {
object : X509ExtendedTrustManager() {
override fun checkClientTrusted(
chain: Array<X509Certificate>,
authType: String,
): Unit = throw CertificateException("gateway TLS probe does not accept client certificates")
override fun checkClientTrusted(
chain: Array<X509Certificate>,
authType: String,
socket: Socket,
) = checkClientTrusted(chain, authType)
override fun checkClientTrusted(
chain: Array<X509Certificate>,
authType: String,
engine: SSLEngine,
) = checkClientTrusted(chain, authType)
override fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
@@ -163,6 +239,18 @@ internal suspend fun probeGatewayTlsFingerprint(
throw CertificateException("gateway TLS probe captured fingerprint")
}
override fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
socket: Socket,
) = checkServerTrusted(chain, authType)
override fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
engine: SSLEngine,
) = checkServerTrusted(chain, authType)
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,336 @@
package ai.openclaw.app.i18n
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.Configuration
import android.os.Build
import android.util.Xml
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.core.app.LocaleManagerCompat
import androidx.core.os.ConfigurationCompat
import androidx.core.os.LocaleListCompat
import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.update
import org.xmlpull.v1.XmlPullParser
sealed interface NativeText {
data class Resource(
val source: String,
val formatArgs: List<Any>,
) : NativeText
data class Verbatim(
val value: String,
) : NativeText
data class Composite(
val parts: List<NativeText>,
val separator: String,
) : NativeText
}
private val nativeLocaleRevision = MutableStateFlow(0L)
internal val nativeLocaleChanges: StateFlow<Long> = nativeLocaleRevision.asStateFlow()
internal fun nativeText(
source: String,
vararg formatArgs: Any,
): NativeText.Resource = NativeText.Resource(source = source, formatArgs = formatArgs.toList())
internal fun verbatimText(value: String): NativeText = NativeText.Verbatim(value)
internal fun joinedNativeText(
separator: String,
parts: List<NativeText>,
): NativeText = NativeText.Composite(parts = parts, separator = separator)
internal fun NativeText.resolveNativeText(): String =
when (this) {
is NativeText.Resource -> nativeString(source, *formatArgs.map(::resolveNativeFormatArg).toTypedArray())
is NativeText.Verbatim -> value
is NativeText.Composite -> parts.joinToString(separator, transform = NativeText::resolveNativeText)
}
@Composable
internal fun NativeText.resolveNativeTextResource(): String =
when (this) {
is NativeText.Resource -> {
val resolvedArgs = mutableListOf<Any>()
for (formatArg in formatArgs) {
resolvedArgs += if (formatArg is NativeText) formatArg.resolveNativeTextResource() else formatArg
}
nativeStringResource(source, *resolvedArgs.toTypedArray())
}
is NativeText.Verbatim -> value
is NativeText.Composite -> {
val resolvedParts = mutableListOf<String>()
for (part in parts) {
resolvedParts += part.resolveNativeTextResource()
}
resolvedParts.joinToString(separator)
}
}
private fun resolveNativeFormatArg(value: Any): Any = if (value is NativeText) value.resolveNativeText() else value
internal fun notifyNativeLocaleChanged() {
NativeStringResources.invalidateLocalizedContext()
nativeLocaleRevision.update { it + 1 }
}
@OptIn(ExperimentalForInheritanceCoroutinesApi::class)
private class LocaleResolvingStateFlow<T, R>(
private val source: StateFlow<T>,
private val transform: (T) -> R,
) : StateFlow<R> {
override val value: R
get() = transform(source.value)
override val replayCache: List<R>
get() = listOf(value)
override suspend fun collect(collector: FlowCollector<R>): Nothing {
combine(source, nativeLocaleRevision) { value, _ -> transform(value) }
.distinctUntilChanged()
.collect(collector)
error("locale-resolving state flow completed unexpectedly")
}
}
internal fun StateFlow<NativeText>.resolveNativeText(): StateFlow<String> = LocaleResolvingStateFlow(this, NativeText::resolveNativeText)
internal fun StateFlow<NativeText?>.resolveOptionalNativeText(): StateFlow<String?> = LocaleResolvingStateFlow(this) { text -> text?.resolveNativeText() }
@SuppressLint("StaticFieldLeak")
internal object NativeStringResources {
private sealed interface ApplicationLocaleMode {
val locales: LocaleListCompat
data class Pinned(
override val locales: LocaleListCompat,
) : ApplicationLocaleMode
data class System(
override val locales: LocaleListCompat,
) : ApplicationLocaleMode
}
@Volatile
private var applicationContext: Context? = null
@Volatile
private var applicationLocaleMode: ApplicationLocaleMode? = null
@Volatile
private var localizedContext: Context? = null
@Synchronized
fun install(context: Context) {
val appContext = context.applicationContext
applicationContext = appContext
val liveLocales =
if (Build.VERSION.SDK_INT >= 33) {
LocaleManagerCompat.getApplicationLocales(appContext)
} else {
AppCompatDelegate.getApplicationLocales()
}
val requestedLocales = liveLocales.takeUnless { it.isEmpty } ?: appContext.readStoredAppLocales()
applicationLocaleMode =
if (requestedLocales.isEmpty) {
ApplicationLocaleMode.System(ConfigurationCompat.getLocales(appContext.resources.configuration))
} else {
ApplicationLocaleMode.Pinned(requestedLocales)
}
localizedContext = null
}
@Synchronized
fun setApplicationLocales(locales: LocaleListCompat) {
applicationLocaleMode =
if (locales.isEmpty) {
val context = applicationContext
ApplicationLocaleMode.System(
context?.let { ConfigurationCompat.getLocales(it.resources.configuration) }
?: LocaleListCompat.getEmptyLocaleList(),
)
} else {
ApplicationLocaleMode.Pinned(locales)
}
localizedContext = null
}
@Synchronized
fun setConfigurationLocales(configuration: Configuration) {
val previousMode = applicationLocaleMode
val context = applicationContext
val liveLocales =
when {
context == null -> LocaleListCompat.getEmptyLocaleList()
Build.VERSION.SDK_INT >= 33 -> LocaleManagerCompat.getApplicationLocales(context)
else -> AppCompatDelegate.getApplicationLocales()
}
applicationLocaleMode =
when {
!liveLocales.isEmpty -> ApplicationLocaleMode.Pinned(liveLocales)
Build.VERSION.SDK_INT < 33 && previousMode is ApplicationLocaleMode.Pinned -> previousMode
else -> ApplicationLocaleMode.System(ConfigurationCompat.getLocales(configuration))
}
localizedContext = null
}
@Synchronized
fun invalidateLocalizedContext() {
localizedContext = null
}
fun resolve(
source: String,
vararg formatArgs: Any,
): String {
val context = applicationContext ?: return formatNativeSource(source, formatArgs)
val localized =
localizedContext
?: synchronized(this) {
localizedContext
?: context
.localizedContext(
applicationLocaleMode
?.locales
?: LocaleManagerCompat
.getApplicationLocales(context)
.takeUnless { it.isEmpty }
?: context.readStoredAppLocales(),
).also { localizedContext = it }
}
return localized.nativeString(source, *formatArgs)
}
}
private fun Context.localizedContext(locales: LocaleListCompat): Context =
if (locales.isEmpty) {
this
} else {
val configuration = Configuration(resources.configuration)
ConfigurationCompat.setLocales(configuration, locales)
createConfigurationContext(configuration)
}
private fun Context.readStoredAppLocales(): LocaleListCompat {
if (Build.VERSION.SDK_INT >= 33) return LocaleListCompat.getEmptyLocaleList()
// AppCompat only hydrates auto-stored locales when a delegate attaches. A cold service
// has no delegate, so mirror AndroidX's XML read until the platform owns app locales.
val languageTags =
runCatching {
openFileInput(APP_LOCALES_FILE).use { input ->
val parser = Xml.newPullParser()
parser.setInput(input, "UTF-8")
while (parser.next() != XmlPullParser.END_DOCUMENT) {
if (parser.eventType == XmlPullParser.START_TAG && parser.name == APP_LOCALES_TAG) {
return@use parser.getAttributeValue(null, APP_LOCALES_ATTRIBUTE).orEmpty()
}
}
""
}
}.getOrDefault("")
return LocaleListCompat.forLanguageTags(languageTags)
}
internal fun nativeString(
source: String,
vararg formatArgs: Any,
): String = NativeStringResources.resolve(source, *formatArgs)
@Composable
internal fun nativeStringResource(
source: String,
vararg formatArgs: Any,
): String {
val resourceId = nativeStringResourceIds[source] ?: return formatNativeSource(source, formatArgs)
return if (formatArgs.isEmpty()) stringResource(resourceId) else stringResource(resourceId, *formatArgs)
}
internal fun Context.nativeString(
source: String,
vararg formatArgs: Any,
): String {
val resourceId = nativeStringResourceIds[source] ?: return formatNativeSource(source, formatArgs)
return if (formatArgs.isEmpty()) getString(resourceId) else getString(resourceId, *formatArgs)
}
private fun formatNativeSource(
source: String,
formatArgs: Array<out Any>,
): String {
if (formatArgs.isEmpty()) return source
val rendered = StringBuilder(source.length)
var argumentIndex = 0
var cursor = 0
while (cursor < source.length) {
val start = source.indexOf('$', startIndex = cursor)
if (start < 0) {
rendered.append(source, cursor, source.length)
break
}
rendered.append(source, cursor, start)
val end = source.kotlinInterpolationEnd(start)
if (end == null) {
rendered.append('$')
cursor = start + 1
continue
}
val argument = formatArgs.getOrNull(argumentIndex++)
if (argument == null) {
rendered.append(source, start, end)
} else {
rendered.append(argument)
}
cursor = end
}
return rendered.toString()
}
private fun String.kotlinInterpolationEnd(start: Int): Int? {
val next = getOrNull(start + 1) ?: return null
if (next != '{') {
if (next != '_' && !next.isLetter()) return null
var end = start + 2
while (getOrNull(end)?.let { it == '_' || it.isLetterOrDigit() } == true) {
end += 1
}
return end
}
var depth = 1
var quote: Char? = null
var escaped = false
var end = start + 2
while (end < length) {
val character = this[end]
when {
escaped -> escaped = false
quote != null && character == '\\' -> escaped = true
character == quote -> quote = null
quote == null && (character == '"' || character == '\'') -> quote = character
quote == null && character == '{' -> depth += 1
quote == null && character == '}' -> {
depth -= 1
if (depth == 0) return end + 1
}
}
end += 1
}
return null
}
private const val APP_LOCALES_FILE =
"androidx.appcompat.app.AppCompatDelegate.application_locales_record_file"
private const val APP_LOCALES_TAG = "locales"
private const val APP_LOCALES_ATTRIBUTE = "application_locales"
@@ -1,6 +1,5 @@
package ai.openclaw.app.node
import ai.openclaw.app.PermissionRequester
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
@@ -116,20 +115,12 @@ class CameraCaptureManager(
@Volatile private var lifecycleOwner: LifecycleOwner? = null
@Volatile private var permissionRequester: PermissionRequester? = null
/** Supplies the foreground Activity lifecycle required by CameraX use-case binding. */
fun attachLifecycleOwner(owner: LifecycleOwner) {
// CameraX binds use cases to an Activity lifecycle; background services cannot capture alone.
lifecycleOwner = owner
}
/** Supplies the Activity-owned permission requester used by camera and microphone commands. */
fun attachPermissionRequester(requester: PermissionRequester) {
// Runtime permission callbacks belong to the foreground Activity, not the background node service.
permissionRequester = requester
}
/** Lists CameraX devices with stable Camera2 ids where available. */
suspend fun listDevices(): List<CameraDeviceInfo> =
withContext(Dispatchers.Main) {
@@ -139,30 +130,16 @@ class CameraCaptureManager(
.sortedBy { it.id }
}
private suspend fun ensureCameraPermission() {
private fun ensureCameraPermission() {
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 results = requester.requestIfMissing(listOf(Manifest.permission.CAMERA))
if (results[Manifest.permission.CAMERA] != true) {
throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
}
throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
}
private suspend fun ensureMicPermission() {
private fun ensureMicPermission() {
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 results = requester.requestIfMissing(listOf(Manifest.permission.RECORD_AUDIO))
if (results[Manifest.permission.RECORD_AUDIO] != true) {
throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission")
}
throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission")
}
/** Captures one still image and returns a gateway-sized JPEG payload. */
@@ -250,13 +227,13 @@ class CameraCaptureManager(
suspend fun clip(paramsJson: String?): FilePayload =
withContext(Dispatchers.Main) {
ensureCameraPermission()
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
val params = parseJsonParamsObject(paramsJson)
val facing = parseFacing(params) ?: "front"
val durationMs = (parseDurationMs(params) ?: 3_000).coerceIn(200, 60_000)
val includeAudio = parseIncludeAudio(params) ?: true
val deviceId = parseDeviceId(params)
if (includeAudio) ensureMicPermission()
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
val provider = context.cameraProvider()
@@ -6,7 +6,6 @@ import ai.openclaw.app.gateway.GatewaySession
import android.content.Context
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
@@ -26,7 +25,7 @@ internal fun isCameraClipWithinPayloadLimit(rawBytes: Long): Boolean = rawBytes
class CameraHandler(
private val appContext: Context,
private val camera: CameraCaptureManager,
private val externalAudioCaptureActive: MutableStateFlow<Boolean>,
private val setCameraAudioCaptureActive: (Boolean) -> Boolean,
private val showCameraHud: (message: String, kind: CameraHudKind, autoHideMs: Long?) -> Unit,
private val invokeErrorFromThrowable: (err: Throwable) -> Pair<String, String>,
) {
@@ -113,7 +112,13 @@ class CameraHandler(
android.util.Log.w("openclaw", "camera.clip: $msg")
}
val includeAudio = parseIncludeAudio(paramsJson) ?: true
if (includeAudio) externalAudioCaptureActive.value = true
val ownsAudioCapture = includeAudio && setCameraAudioCaptureActive(true)
if (includeAudio && !ownsAudioCapture) {
return GatewaySession.InvokeResult.error(
code = "MIC_BUSY",
message = "MIC_BUSY: another audio capture is active",
)
}
try {
clipLogFile?.writeText("") // clear
clipLog("starting, params=$paramsJson includeAudio=$includeAudio")
@@ -169,7 +174,7 @@ class CameraHandler(
return GatewaySession.InvokeResult.error(code = "UNAVAILABLE", message = err.message ?: "camera clip failed")
} finally {
// Prevent talk/transcription capture from competing with camera audio after every exit path.
if (includeAudio) externalAudioCaptureActive.value = false
if (ownsAudioCapture) setCameraAudioCaptureActive(false)
}
}
@@ -26,6 +26,7 @@ class ConnectionManager(
private val callLogAvailable: () -> Boolean,
private val photosAvailable: () -> Boolean,
private val installedAppsSharingEnabled: () -> Boolean,
private val voiceWakeAvailable: () -> Boolean,
private val manualTls: (GatewayEndpoint) -> Boolean,
) {
companion object {
@@ -141,6 +142,7 @@ class ConnectionManager(
motionPedometerAvailable = motionPedometerAvailable(),
installedAppsSharingEnabled = installedAppsSharingEnabled(),
debugBuild = BuildConfig.DEBUG,
voiceWakeEnabled = prefs.voiceWakeEnabled.value && voiceWakeAvailable(),
)
/** Builds the gateway-advertised node.invoke command list from current permission and feature state. */
@@ -3,6 +3,7 @@ package ai.openclaw.app.node
import ai.openclaw.app.BuildConfig
import ai.openclaw.app.gateway.DeviceIdentityStore
import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.i18n.nativeString
import android.content.Context
import kotlinx.serialization.json.JsonPrimitive
@@ -33,11 +34,11 @@ class DebugHandler(
// Public-key URL encoding must match the gateway device-auth payload contract.
val pubKeyUrl = identityStore.publicKeyBase64Url(identity)
results.add("publicKeyBase64Url: ${pubKeyUrl ?: "NULL (FAILED)"}")
results.add("publicKeyBase64Url: ${pubKeyUrl ?: nativeString("NULL (FAILED)")}")
// Sign/verify through DeviceIdentityStore to catch provider and key-format failures together.
val signature = identityStore.signPayload(testPayload, identity)
results.add("signPayload: ${if (signature != null) "${signature.take(20)}... (OK)" else "NULL (FAILED)"}")
results.add("signPayload: ${if (signature != null) nativeString("\${signature.take(20)}... (OK)", signature.take(20)) else nativeString("NULL (FAILED)")}")
if (signature != null) {
val verifyOk = identityStore.verifySelfSignature(testPayload, signature, identity)
@@ -29,6 +29,7 @@ data class NodeRuntimeFlags(
val motionPedometerAvailable: Boolean,
val installedAppsSharingEnabled: Boolean,
val debugBuild: Boolean,
val voiceWakeEnabled: Boolean = false,
)
/** Per-command availability gates checked before advertising invoke methods. */
@@ -56,6 +57,7 @@ enum class NodeCapabilityAvailability {
CallLogAvailable,
PhotosAvailable,
MotionAvailable,
VoiceWakeEnabled,
}
/** Capability entry reported to the gateway when its availability gate passes. */
@@ -106,6 +108,10 @@ object InvokeCommandRegistry {
name = OpenClawCapability.CallLog.rawValue,
availability = NodeCapabilityAvailability.CallLogAvailable,
),
NodeCapabilitySpec(
name = OpenClawCapability.VoiceWake.rawValue,
availability = NodeCapabilityAvailability.VoiceWakeEnabled,
),
)
/** Complete Android node command catalog before runtime availability filtering. */
@@ -263,6 +269,7 @@ object InvokeCommandRegistry {
NodeCapabilityAvailability.CallLogAvailable -> flags.callLogAvailable
NodeCapabilityAvailability.PhotosAvailable -> flags.photosAvailable
NodeCapabilityAvailability.MotionAvailable -> flags.motionActivityAvailable || flags.motionPedometerAvailable
NodeCapabilityAvailability.VoiceWakeEnabled -> flags.voiceWakeEnabled
}
}.map { it.name }

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