mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-21 01:51:39 -06:00
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:
@@ -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`
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
;;
|
||||
*.*.*)
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.`,
|
||||
"",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/*
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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
@@ -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}.`,
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
+1
-1
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+16310
-4318
File diff suppressed because it is too large
Load Diff
+9263
-1228
File diff suppressed because it is too large
Load Diff
+9265
-1230
File diff suppressed because it is too large
Load Diff
+9255
-1220
File diff suppressed because it is too large
Load Diff
+9256
-1221
File diff suppressed because it is too large
Load Diff
+9256
-1221
File diff suppressed because it is too large
Load Diff
+9274
-1239
File diff suppressed because it is too large
Load Diff
+9273
-1238
File diff suppressed because it is too large
Load Diff
+9254
-1219
File diff suppressed because it is too large
Load Diff
+9272
-1237
File diff suppressed because it is too large
Load Diff
+9252
-1217
File diff suppressed because it is too large
Load Diff
+9257
-1222
File diff suppressed because it is too large
Load Diff
+9260
-1225
File diff suppressed because it is too large
Load Diff
+9253
-1218
File diff suppressed because it is too large
Load Diff
+9265
-1230
File diff suppressed because it is too large
Load Diff
+9264
-1229
File diff suppressed because it is too large
Load Diff
+9268
-1233
File diff suppressed because it is too large
Load Diff
+9268
-1233
File diff suppressed because it is too large
Load Diff
+9267
-1232
File diff suppressed because it is too large
Load Diff
+9260
-1225
File diff suppressed because it is too large
Load Diff
+9265
-1230
File diff suppressed because it is too large
Load Diff
+9277
-1242
File diff suppressed because it is too large
Load Diff
@@ -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,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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user