Compare commits

..

1 Commits

Author SHA1 Message Date
Mason Huang
93fc591af9 ci: add process exec codeql security shard 2026-06-13 20:38:21 +08:00
3496 changed files with 52257 additions and 256697 deletions

View File

@@ -24,7 +24,7 @@ Use when:
- Prefer small fixes at the right ownership boundary; no refactor unless it clearly improves the bug class.
- When an accepted finding shows a bug class or repeated pattern, inspect the current PR scope for sibling instances before fixing.
- Fix the scoped bug class at once when practical; stop at touched surfaces, owner boundaries, and clear follow-up territory.
- Keep going until structured review returns no accepted/actionable findings only while the work remains inside the original task scope.
- Keep going until structured review returns no accepted/actionable findings.
- If a review-triggered fix changes code, rerun focused tests and rerun the structured review helper.
- For security-audit suppression changes, verify accepted findings remain auditable: suppressed findings stay in structured output, active output keeps an unsuppressible suppression notice, and aggregate findings cannot hide unrelated active risk.
- Never switch or override the requested review engine/model. If the review hits model capacity, retry the same command a few times with the same engine/model.
@@ -43,42 +43,6 @@ Use when:
- If Gitcrawl reports a portable manifest mismatch, source/runtime DB health error, or stale portable-store checkout, run `gitcrawl doctor --json` and inspect `source_db_health`, `runtime_db_health`, and `portable_store_status` before falling back to live GitHub.
- Do not push just to review. Push only when the user requested push/ship/PR update.
## Scope Governor
Autoreview is a closeout gate, not permission to rewrite the task.
Before the first review, freeze a scope baseline: original request or issue, target branch, intended behavior, owner boundary, changed files, and non-test LOC. For inherited or already-bloated branches, use the intended PR diff as the baseline rather than accepting all existing branch drift.
Before patching a finding, classify it:
- **In-scope blocker**: the finding is introduced by the current diff, affects the same owner boundary, and can be fixed without changing the task's contract.
- **Follow-up**: the finding is real but belongs to an adjacent bug class, sibling surface, cleanup, or broader hardening track.
- **Stop-and-escalate**: the finding requires a new protocol/config/storage/public API contract, a different owner boundary, a release-process change, or a design choice outside the original request.
Stop patching and report the scope break instead of continuing when:
- a narrow PR turns into an architecture change, protocol change, migration, or release-process change;
- the diff grows past 2x the original files or non-test LOC without explicit approval to expand scope;
- two review-triggered patch cycles have not converged; pause and reclassify every remaining finding before another edit;
- the best fix is "define the canonical contract first" rather than another local inference layer;
- fixing the accepted finding would make the PR no longer describe the same behavior, issue, or owner boundary.
After the two-cycle pause, continue only when every remaining accepted finding is still an in-scope blocker. Otherwise preserve the useful analysis, identify the smallest safe landed subset if one exists, and open or request a follow-up for the larger fix. Do not keep committing speculative fixes just to satisfy the reviewer.
Do not stack or push review-triggered fix commits while scope classification or focused proof is unresolved. Keep exploratory edits local until the cycle is proven in scope; if scope breaks, remove them from the landing lane instead of preserving them as branch history.
Critical exceptions must be explicit: active data loss, crash, broken install/upgrade, release blocker, or concrete security exposure. If the exception is not one of those, it is not critical enough to blow up scope.
## Release Branches And Release Process
On release, beta, stable, hotfix, signing, notarization, appcast, package-publish, or release-check work, use freeze discipline even when the branch name is not release-like:
- Fix only release blockers, failed release infrastructure, exact backports, install/upgrade breakage, data loss, crashes, or concrete security exposure.
- Treat non-blocking autoreview findings as follow-ups for `main`, not reasons to broaden the release branch.
- Do not introduce new product behavior, config surface, protocol shape, migration, plugin ownership, docs narrative, or process policy unless it directly unblocks the release.
- Keep proof tied to the release target: exact branch/ref, failing check or shipped-risk reason, smallest command/proof, and whether the fix must also forward-port to `main`.
- If review discovers a real but non-critical design problem during release closeout, stop with a follow-up issue/PR plan; do not use the release branch as the refactor lane.
## Pick Target
Dirty local work:

View File

@@ -440,36 +440,8 @@ def load_datasets(args: argparse.Namespace) -> str:
return "\n\n".join(chunks)
def review_scope_policy() -> str:
return textwrap.dedent(
"""
Review scope discipline:
- This helper is a closeout gate. Do not turn a narrow patch into a broad
redesign request.
- Report a finding only when this diff introduces or exposes a concrete
defect that must be fixed before this target can land.
- If the best fix requires a new protocol, config, storage, public API,
release process, migration, owner-boundary move, or canonical contract,
say that directly in the finding and keep the finding tied to the
smallest changed line that proves the current patch is not landable.
- Do not ask for sibling-surface hardening, cleanup, refactors, or
follow-up architecture work unless the current diff is incorrect
without that work.
- Prefer the smallest correct pre-merge fix. A broader ideal design is
not an actionable finding unless the current patch cannot safely land.
- If this is release-branch or release-process work, apply freeze
discipline. Report only release blockers, exact backport regressions,
install/upgrade breakage, crashes, data loss, concrete security
exposure, or release-infrastructure failures. Non-blocking design,
cleanup, and hardening concerns belong on main as follow-ups.
"""
).strip()
def build_prompt(repo: Path, target: str, target_ref: str | None, bundle: str, extra_prompt: str, datasets: str) -> str:
target_line = f"{target} {target_ref}" if target_ref else target
branch = current_branch(repo)
scope_policy = review_scope_policy()
return textwrap.dedent(
f"""
You are a senior code reviewer. Review the provided git change bundle only.
@@ -491,11 +463,8 @@ def build_prompt(repo: Path, target: str, target_ref: str | None, bundle: str, e
- If there are no actionable findings, return an empty findings array and mark the patch correct.
Review target: {target_line}
Current branch: {branch}
Repository: {repo}
{scope_policy}
{extra_prompt}
{datasets}

View File

@@ -3,7 +3,6 @@ from __future__ import annotations
import argparse
import os
import runpy
import shutil
import stat
import subprocess
@@ -146,23 +145,8 @@ def create_fixture_repo(repo: Path, fixture: str) -> None:
write_fixture_file(repo, MALICIOUS_CHANGED if fixture == "malicious" else BENIGN_CHANGED)
def validate_prompt_policy(repo: Path, autoreview: Path) -> None:
namespace = runpy.run_path(str(autoreview))
prompt = namespace["build_prompt"](repo, "local", None, "fixture diff", "", "")
required = (
"This helper is a closeout gate.",
"Do not turn a narrow patch into a broad",
"If this is release-branch or release-process work",
"Non-blocking design,",
)
missing = [needle for needle in required if needle not in prompt]
if missing:
raise RuntimeError(f"autoreview prompt missing scope policy: {missing}")
def run_reviews(repo: Path, script_dir: Path, fixture: str, engines: list[str]) -> None:
autoreview = script_dir / "autoreview"
validate_prompt_policy(repo, autoreview)
for engine in engines:
print(f"== {engine} ==", flush=True)
command = [

View File

@@ -54,13 +54,6 @@ pnpm crabbox:run -- --help | sed -n '1,120p'
- For broad OpenClaw maintainer `pnpm` gates, prefer the repo wrapper with
`--provider blacksmith-testbox` or the repo Testbox helpers when the standing
Testbox policy applies.
- Cold Testbox acquisition and hydration often take tens of seconds. When broad
remote proof is likely, immediately start
`node scripts/crabbox-wrapper.mjs warmup --provider blacksmith-testbox --keep --timing-json`
in a background command session while inspecting, editing, and running
focused local tests. Poll later, reuse the returned `tbx_...` with
`--provider blacksmith-testbox --id <tbx_id>`, and stop it before handoff.
Do not warm speculatively when remote proof is unlikely.
- Always report the actual provider and id. `cbx_...` means AWS Crabbox;
`tbx_...` means Blacksmith Testbox through Crabbox. If the output only says
`blacksmith testbox list`, use `blacksmith testbox list --all` before

View File

@@ -1,51 +0,0 @@
---
name: discord-user-post
description: Post an approved message as the logged-in Discord user through the Discord desktop app. Use for release announcements or other direct user-authored Discord posts; not for OpenClaw channel sends, bots, webhooks, relays, agent sessions, or archive search.
---
# Discord User Post
Use `$computer-use` to operate `/Applications/Discord.app` in the user's
existing logged-in session. This workflow represents the user directly.
## Prepare
1. Draft the complete final message outside Discord.
2. Confirm the intended server and channel with the user when either is
ambiguous.
3. Open Discord and navigate to the exact destination without entering the
message.
4. Verify the visible server name, channel header, and logged-in account.
Do not infer the target from unrelated Discord content. Stop if Discord is not
logged in, the account is wrong, or the exact destination cannot be verified.
## Confirm and Post
Posting is representational communication. Follow the `$computer-use`
confirmation policy even when the user previously asked for an announcement:
1. Show the user the exact final body and verified destination.
2. Request action-time confirmation before typing into Discord.
3. After confirmation, enter the approved body unchanged.
4. Visually inspect the composed message and destination again.
5. Send once.
If the body or destination changes after confirmation, request confirmation
again before sending.
## Verify
- Confirm the message appears once, from the user's account, in the intended
channel.
- Report the server, channel, and visible send result.
- Do not edit, delete, react, or send a follow-up without the corresponding
user instruction and confirmation.
## Guardrails
- Never use `openclaw message`, an OpenClaw agent, a Discord bot, webhook, relay,
or token for this workflow.
- Never expose private Discord content or account details in public output.
- Never send a draft, partial message, duplicate, or unreviewed attachment.
- For Discord archive/history/search, use `$discrawl` instead.

View File

@@ -1,4 +0,0 @@
interface:
display_name: "Discord User Post"
short_description: "Post approved messages through the logged-in Discord app"
default_prompt: "Post this approved message as me through the logged-in Discord desktop app."

View File

@@ -91,32 +91,6 @@ attribution.
- if any compatibility `removeAfter` is on/before release date, resolve it
or explicitly record the blocker before shipping
10. Validate and ship:
- generate and verify the complete contribution ledger before committing:
```bash
node .agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs \
--base <base-tag> \
--target <target-ref> \
--version <YYYY.M.PATCH> \
--write-ledger
```
- the command fails when any `#NNN` reference in release history or the
rendered release section is absent from the ledger, when reverted work is
presented as shipped, or when an eligible PR author, issue reporter, or
known co-author is missing from that entry's `Thanks @...` credit
- after the GitHub release or prerelease is published, verify every matching
release page against the same source section:
```bash
node .agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs \
--base <base-tag> \
--target <target-ref> \
--version <YYYY.M.PATCH> \
--release-tag v<YYYY.M.PATCH> \
--check-github
```
- add one `--release-tag` for every beta and stable page in the train; a
`### Release verification` tail is permitted, but any other body drift
fails the check; the GitHub body must begin with the complete
`## YYYY.M.PATCH` changelog section, including its heading
- `git diff --check`
- for docs/changelog-only changes, no broad tests are required
- commit with `scripts/committer "docs(changelog): refresh YYYY.M.PATCH notes" CHANGELOG.md`

View File

@@ -1,443 +0,0 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
const repo = "openclaw/openclaw";
const excludedHandles = new Set(["openclaw", "clawsweeper", "codex", "steipete"]);
function fail(message) {
throw new Error(message);
}
function parseArgs(argv) {
const options = {
releaseTags: [],
checkGithub: false,
json: false,
writeLedger: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--check-github" || arg === "--json" || arg === "--write-ledger") {
options[
arg === "--check-github"
? "checkGithub"
: arg === "--write-ledger"
? "writeLedger"
: "json"
] = true;
continue;
}
if (arg === "--base" || arg === "--target" || arg === "--version" || arg === "--release-tag") {
const value = argv[index + 1];
if (!value || value.startsWith("--")) {
fail(`missing value for ${arg}`);
}
if (arg === "--release-tag") {
options.releaseTags.push(value);
} else {
options[arg.slice(2)] = value;
}
index += 1;
continue;
}
fail(`unknown argument: ${arg}`);
}
for (const name of ["base", "target", "version"]) {
if (!options[name]) {
fail(`--${name} is required`);
}
}
if (options.checkGithub && options.releaseTags.length === 0) {
fail("--check-github requires at least one --release-tag");
}
return options;
}
function run(command, args) {
return execFileSync(command, args, {
encoding: "utf8",
env: { ...process.env, NO_COLOR: "1" },
stdio: ["ignore", "pipe", "pipe"],
});
}
function git(args) {
return run("git", args).trimEnd();
}
function githubApi(args) {
try {
return JSON.parse(run("ghx", ["api", ...args]).replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, ""));
} catch (error) {
if (typeof error.stdout === "string" && error.stdout.trim() !== "") {
return JSON.parse(error.stdout.replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, ""));
}
throw error;
}
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function isEligibleHandle(handle) {
return Boolean(handle) && !handle.endsWith("[bot]") && !excludedHandles.has(handle.toLowerCase());
}
function sectionFor(changelog, version) {
const heading = new RegExp(`^## ${escapeRegExp(version)}\\r?$`, "m").exec(changelog);
if (!heading || heading.index === undefined) {
fail(`CHANGELOG.md does not contain ## ${version}`);
}
const start = heading.index;
const bodyStart = changelog.indexOf("\n", start) + 1;
const next = /^## /gm;
next.lastIndex = bodyStart;
const nextHeading = next.exec(changelog);
const end = nextHeading?.index ?? changelog.length;
return {
start,
end,
source: changelog.slice(start, end).trimEnd(),
body: changelog.slice(bodyStart, end).trim(),
};
}
function referencesIn(text) {
return [...text.matchAll(/#(\d+)/g)].map((match) => Number(match[1]));
}
function appendReferences(references, additions) {
const seen = new Set(references);
for (const number of additions) {
if (!seen.has(number)) {
references.push(number);
seen.add(number);
}
}
}
function sourceCommits(base, target) {
const mergeBase = git(["merge-base", base, target]);
const output = git([
"log",
"--first-parent",
"--reverse",
"--format=%H%x1f%s%x1f%B%x1e",
`${mergeBase}..${target}`,
]);
const commits = new Map();
const revertsByTarget = new Map();
for (const record of output.split("\x1e")) {
if (!record) {
continue;
}
const [rawHash, subject, ...bodyParts] = record.split("\x1f");
const hash = rawHash.trim();
const body = bodyParts.join("\x1f");
const revertedHash = body.match(/This reverts commit ([0-9a-f]{7,40})\./i)?.[1];
const isRevert = subject.startsWith('Revert "') || Boolean(revertedHash);
commits.set(hash, { body, hash, isRevert, revertedHash, subject });
}
for (const commit of commits.values()) {
if (!commit.revertedHash) {
continue;
}
const targetHash = [...commits.keys()].find((candidate) => candidate.startsWith(commit.revertedHash));
if (targetHash) {
const reverts = revertsByTarget.get(targetHash) ?? [];
reverts.push(commit.hash);
revertsByTarget.set(targetHash, reverts);
}
}
const active = new Map();
function isActive(hash) {
if (active.has(hash)) {
return active.get(hash);
}
const cancellingReverts = revertsByTarget.get(hash) ?? [];
const value = !cancellingReverts.some((revertHash) => isActive(revertHash));
active.set(hash, value);
return value;
}
const references = [];
const revertedReferences = new Set();
const coauthorsByReference = new Map();
for (const commit of commits.values()) {
if (commit.isRevert) {
continue;
}
const uniqueReferences = [...new Set(referencesIn(`${commit.subject}\n${commit.body}`))];
if (!isActive(commit.hash)) {
for (const number of uniqueReferences) {
revertedReferences.add(number);
}
continue;
}
appendReferences(references, uniqueReferences);
const coauthors = [...commit.body.matchAll(/<(?:(?:\d+)\+)?([^@<>\s]+)@users\.noreply\.github\.com>/gi)]
.map((match) => match[1])
.filter(isEligibleHandle);
for (const number of uniqueReferences) {
if (coauthors.length > 0) {
const handles = coauthorsByReference.get(number) ?? new Set();
for (const handle of coauthors) {
handles.add(handle);
}
coauthorsByReference.set(number, handles);
}
}
}
return { mergeBase, references, revertedReferences, coauthorsByReference };
}
function graphql(query) {
return githubApi(["graphql", "-f", `query=${query}`]).data;
}
function resolveReferences(numbers) {
const nodes = new Map();
for (let index = 0; index < numbers.length; index += 40) {
const chunk = numbers.slice(index, index + 40);
const fields = chunk
.map(
(number) => `n${number}: repository(owner: "openclaw", name: "openclaw") {
issueOrPullRequest(number: ${number}) {
__typename
... on Issue { number title author { __typename login } }
... on PullRequest { number title author { __typename login } }
}
}`,
)
.join("\n");
const data = graphql(`query { ${fields} }`);
for (const number of chunk) {
const node = data[`n${number}`]?.issueOrPullRequest;
if (node) {
nodes.set(number, node);
}
}
}
return nodes;
}
function resolveCoauthors(handles) {
const resolved = new Map();
const uniqueHandles = [...new Set(handles)];
for (let index = 0; index < uniqueHandles.length; index += 80) {
const chunk = uniqueHandles.slice(index, index + 80);
const fields = chunk
.map(
(handle, offset) =>
`u${index + offset}: user(login: ${JSON.stringify(handle)}) { __typename login }`,
)
.join("\n");
const data = graphql(`query { ${fields} }`);
for (let offset = 0; offset < chunk.length; offset += 1) {
const user = data[`u${index + offset}`];
if (user?.__typename === "User" && isEligibleHandle(user.login)) {
resolved.set(chunk[offset].toLowerCase(), user.login);
}
}
}
return resolved;
}
function thanksFor(node, coauthorHandles) {
const handles = [];
if (node.author?.__typename === "User" && isEligibleHandle(node.author.login)) {
handles.push(node.author.login);
}
for (const handle of coauthorHandles) {
if (!handles.some((candidate) => candidate.toLowerCase() === handle.toLowerCase())) {
handles.push(handle);
}
}
return handles;
}
function ledgerFor(base, target, references, nodes, coauthorsByReference, resolvedCoauthors) {
const missing = references.filter((number) => !nodes.has(number));
if (missing.length > 0) {
fail(`GitHub could not resolve source references: ${missing.map((number) => `#${number}`).join(", ")}`);
}
const entries = references.map((number) => {
const node = nodes.get(number);
const rawCoauthors = coauthorsByReference.get(number) ?? new Set();
const coauthors = [...rawCoauthors]
.map((handle) => resolvedCoauthors.get(handle.toLowerCase()))
.filter(Boolean);
return {
number,
title: node.title.replace(/#(\d+)/g, "issue $1").replace(/\s+/g, " ").trim(),
type: node.__typename,
thanks: thanksFor(node, coauthors),
};
});
const pullRequests = entries.filter((entry) => entry.type === "PullRequest");
const issues = entries.filter((entry) => entry.type === "Issue");
const renderEntry = (entry, issue = false) => {
const attribution = entry.thanks.length > 0 ? ` Thanks ${entry.thanks.map((handle) => `@${handle}`).join(" and ")}.` : "";
return `- ${issue ? "Reported: " : ""}${entry.title} (#${entry.number}).${attribution}`;
};
const ledger = [
"### Complete contribution ledger",
"",
`This audited record covers the complete ${base}..${target} history: ${pullRequests.length} PRs and ${issues.length} linked issues. The grouped notes above prioritize user impact; this ledger preserves every contribution reference and eligible human credit.`,
"",
"#### Pull requests",
"",
...pullRequests.map((entry) => renderEntry(entry)),
"",
"#### Linked issues",
"",
...issues.map((entry) => renderEntry(entry, true)),
].join("\n");
return { entries, issues, ledger, pullRequests };
}
function replaceLedger(changelog, section, ledger) {
const beforeLedger = section.source.replace(/\n+### Complete contribution ledger[\s\S]*$/m, "").trimEnd();
const replacement = `${beforeLedger}\n\n${ledger}\n`;
return `${changelog.slice(0, section.start)}${replacement}${changelog.slice(section.end)}`;
}
function ledgerChecks(section, entries) {
const errors = [];
if (!section.source.includes("### Highlights")) {
errors.push("missing ### Highlights");
}
if (!section.source.includes("### Changes")) {
errors.push("missing ### Changes");
}
if (!section.source.includes("### Fixes")) {
errors.push("missing ### Fixes");
}
const ledgerStart = section.source.indexOf("### Complete contribution ledger");
if (ledgerStart < 0) {
errors.push("missing ### Complete contribution ledger");
return errors;
}
const ledger = section.source.slice(ledgerStart);
const entryNumbers = new Set(entries.map((entry) => entry.number));
for (const number of new Set(referencesIn(section.source))) {
if (!entryNumbers.has(number)) {
errors.push(`missing ledger entry for #${number}`);
}
}
for (const entry of entries) {
const prefix = entry.type === "Issue" ? "- Reported: " : "- ";
const line = ledger
.split("\n")
.find((candidate) => candidate.startsWith(prefix) && candidate.includes(`(#${entry.number})`));
if (!line) {
errors.push(`missing ledger entry for #${entry.number}`);
continue;
}
for (const handle of entry.thanks) {
if (!line.toLowerCase().includes(`@${handle.toLowerCase()}`)) {
errors.push(`missing Thanks @${handle} for #${entry.number}`);
}
}
}
return errors;
}
function releaseChecks(section, releaseTags) {
const expected = section.source;
const checks = [];
for (const tag of releaseTags) {
const release = githubApi([`repos/${repo}/releases/tags/${encodeURIComponent(tag)}`]);
const suffix = release.body.slice(expected.length).trimStart();
const matches =
release.body === expected ||
(release.body.startsWith(expected) && (suffix === "" || suffix.startsWith("### Release verification")));
checks.push({
tag,
releaseId: release.id,
matches,
bodyLength: release.body.length,
});
}
return checks;
}
function main() {
const options = parseArgs(process.argv.slice(2));
let changelog = readFileSync("CHANGELOG.md", "utf8");
let section = sectionFor(changelog, options.version);
const source = sourceCommits(options.base, options.target);
const preexistingNotes = section.source.replace(/\n+### Complete contribution ledger[\s\S]*$/m, "");
const noteReferences = referencesIn(preexistingNotes);
const revertedNoteReferences = noteReferences.filter((number) => source.revertedReferences.has(number));
if (revertedNoteReferences.length > 0) {
fail(
`release notes reference reverted work: ${[
...new Set(revertedNoteReferences),
]
.map((number) => `#${number}`)
.join(", ")}`,
);
}
const references = [...source.references];
appendReferences(references, noteReferences);
const nodes = resolveReferences(references);
const coauthorHandles = [...source.coauthorsByReference.values()].flatMap((handles) => [...handles]);
const resolvedCoauthors = resolveCoauthors(coauthorHandles);
const ledger = ledgerFor(
options.base,
options.target,
references,
nodes,
source.coauthorsByReference,
resolvedCoauthors,
);
if (options.writeLedger) {
changelog = replaceLedger(changelog, section, ledger.ledger);
writeFileSync("CHANGELOG.md", changelog);
section = sectionFor(changelog, options.version);
}
const errors = ledgerChecks(section, ledger.entries);
const github = options.checkGithub ? releaseChecks(section, options.releaseTags) : [];
for (const check of github) {
if (!check.matches) {
errors.push(`GitHub release ${check.tag} does not match the ${options.version} CHANGELOG section`);
}
}
const result = {
base: options.base,
target: options.target,
mergeBase: source.mergeBase,
version: options.version,
source: {
references: references.length,
pullRequests: ledger.pullRequests.length,
issues: ledger.issues.length,
},
github,
errors,
};
if (options.json) {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
} else {
process.stdout.write(
`${options.version}: ${ledger.pullRequests.length} PRs, ${ledger.issues.length} issues, ${errors.length === 0 ? "verified" : `${errors.length} errors`}\n`,
);
}
if (errors.length > 0) {
process.exitCode = 1;
}
}
main();

View File

@@ -284,7 +284,7 @@ gh search issues --repo openclaw/openclaw --match title,body --limit 50 \
- If bot review conversations exist on your PR, address them and resolve them yourself once fixed.
- Leave a review conversation unresolved only when reviewer or maintainer judgment is still needed.
- Before landing any PR with non-trivial code changes, run `$autoreview` until no accepted/actionable findings remain, unless equivalent manual review already covered it, the change is trivial/docs-only, or the user opts out.
- When an agent is landing or merging a PR targeting `main`, use only the repo-native `scripts/pr` wrapper: run `scripts/pr review-init <PR>`, follow its emitted checkout/guard guidance, initialize and complete review artifacts with `scripts/pr review-artifacts-init <PR>`, validate them with `scripts/pr review-validate-artifacts <PR>`, then run `scripts/pr prepare-run <PR>` and `scripts/pr merge-run <PR>`.
- When landing or merging any PR, follow the global `/landpr` process.
- Use `scripts/committer "<msg>" <file...>` for scoped commits instead of manual `git add` and `git commit`.
- Keep commit messages concise and action-oriented.
- Group related changes; avoid bundling unrelated refactors.

View File

@@ -13,7 +13,7 @@ Use this skill for `qa-lab` / `qa-channel` work. Repo-local QA only.
- `docs/help/testing.md`
- `docs/channels/qa-channel.md`
- `qa/README.md`
- `qa/scenarios/index.yaml`
- `qa/scenarios/index.md`
- `extensions/qa-lab/src/suite.ts`
- `extensions/qa-lab/src/character-eval.ts`
@@ -198,9 +198,7 @@ pnpm openclaw qa character-eval \
- Judges default to `openai/gpt-5.4,thinking=xhigh,fast` and `anthropic/claude-opus-4-6,thinking=high`.
- Report includes judge ranking, run stats, durations, and full transcripts; do not include raw judge replies. Duration is benchmark context, not a grading signal.
- Candidate and judge concurrency default to 16. Use `--concurrency <n>` and `--judge-concurrency <n>` to override when local gateways or provider limits need a gentler lane.
- Scenario source is YAML-only under `qa/scenarios/`: use `index.yaml` and
per-scenario `*.yaml` files with top-level `title`, `scenario`, and optional
`flow`. Never add fenced `qa-scenario` / `qa-flow` Markdown files.
- Scenario source should stay markdown-driven under `qa/scenarios/`.
- For isolated character/persona evals, write the persona into `SOUL.md` and blank `IDENTITY.md` in the scenario flow. Use `SOUL.md + IDENTITY.md` only when intentionally testing how the normal OpenClaw identity combines with the character.
- Keep prompts natural and task-shaped. The candidate model should receive character setup through `SOUL.md`, then normal user turns such as chat, workspace help, and small file tasks; do not ask "how would you react?" or tell the model it is in an eval.
- Prefer at least one real task, such as creating or editing a tiny workspace artifact, so the transcript captures character under normal tool use instead of pure roleplay.
@@ -236,8 +234,7 @@ pnpm openclaw qa manual \
## Repo facts
- Seed scenarios live in `qa/scenarios/index.yaml` and
`qa/scenarios/<theme>/*.yaml`.
- Seed scenarios live in `qa/`.
- Main live runner: `extensions/qa-lab/src/suite.ts`
- QA lab server: `extensions/qa-lab/src/lab-server.ts`
- Child gateway harness: `extensions/qa-lab/src/gateway-child.ts`
@@ -265,9 +262,8 @@ pnpm openclaw qa manual \
## When adding scenarios
- Add or update scenario YAML under `qa/scenarios/`; do not add `.md` scenario
files or fenced YAML blocks.
- Keep kickoff expectations in `qa/scenarios/index.yaml` aligned
- Add or update scenario markdown under `qa/scenarios/`
- Keep kickoff expectations in `qa/scenarios/index.md` aligned
- Add executable coverage in `extensions/qa-lab/src/suite.ts`
- Prefer end-to-end assertions over mock-only checks
- Save outputs under `.artifacts/qa-e2e/`

View File

@@ -6,8 +6,7 @@ description: "Draft or post OpenClaw beta/stable Discord release announcements f
# OpenClaw Release Announcement
Use with `release-openclaw-maintainer` after a beta or stable release is live.
Use with `$discord-user-post` when actually posting to Discord as the logged-in
user.
Use with `openclaw-discord` when actually posting to Discord.
## Evidence First
@@ -81,7 +80,6 @@ Fresh installs still point to `https://openclaw.ai`.
## Posting
When asked to post, use `$discord-user-post` to operate the logged-in Discord
desktop app as the user. Resolve and visibly verify the exact server/channel,
inspect the final body, and request action-time confirmation before entering or
sending it. Never use OpenClaw channel sends, bots, webhooks, relays, or tokens.
When asked to post, use the configured Discord workflow from
`openclaw-discord` or the approved OpenClaw relay. Never print tokens.
For public channels, inspect the final body before sending.

View File

@@ -16,10 +16,6 @@ Use this with `$release-openclaw-maintainer` and `$openclaw-testing` when a rele
- Watch one parent run plus compact child summaries. Avoid broad `gh run view` polling loops; REST quota is easy to burn.
- Fetch logs only for failed or currently-blocking jobs. If quota is low, stop polling and wait for reset.
- Treat live-provider flakes separately from code failures: prove key validity, provider HTTP status, retry evidence, and exact failing lane before editing code.
- A model-list response proves authentication, not billing or inference
entitlement. Mandatory live providers must pass a real completion probe
before release dispatch. Fix the credential first; do not add an alternate
auth path merely to bypass a failed release credential.
- Full Release Validation parent monitors fail fast: once a required child job
fails, the parent cancels the remaining child matrix and prints the failed
job summary. Inspect that first red job instead of waiting for unrelated
@@ -40,8 +36,6 @@ git rev-parse HEAD
preflight. Inject those exact targeted keys first, then run the verifier; use
ambient env only when it was already intentionally injected for this release.
The script prints only provider status and HTTP class, never tokens.
The Anthropic check performs a tiny message completion so exhausted or
non-billable credentials fail before the expensive release matrix.
## Dispatch
@@ -71,13 +65,6 @@ gh workflow run openclaw-performance.yml \
Prefer the trusted workflow on `main`, target the exact release 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.
```bash
gh workflow run full-release-validation.yml \
--repo openclaw/openclaw \
@@ -119,10 +106,7 @@ Stop watchers before ending the turn or switching strategy.
--jq '.jobs[] | select(.conclusion=="failure" or .conclusion=="timed_out" or .conclusion=="cancelled") | [.databaseId,.name,.conclusion,.url] | @tsv'
```
3. Fetch one failed job log. If rate-limited, note reset time and avoid more REST calls.
4. For secret-looking failures, validate a real completion from the same secret source before editing code. A successful model-list request is insufficient.
Claude CLI subscription credentials are a separate native auth path; prove
them in a clean-home CLI probe, never as a substitute for a required
Anthropic API-key lane.
4. For secret-looking failures, validate the provider endpoint from the same secret source before editing code.
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.

View File

@@ -1,22 +1,17 @@
#!/usr/bin/env node
/**
* Release preflight helper that verifies required provider API keys without
* printing secret values. Anthropic must complete a prompt because model-list
* access does not prove billing or inference entitlement.
* Release preflight helper that verifies required provider API keys can reach
* their model-list endpoints without printing secret values.
*/
import process from "node:process";
const args = new Map();
for (let index = 2; index < process.argv.length; index += 1) {
const arg = process.argv[index];
if (!arg.startsWith("--")) {
continue;
}
if (!arg.startsWith("--")) continue;
const [key, inlineValue] = arg.slice(2).split("=", 2);
const value = inlineValue ?? process.argv[index + 1];
if (inlineValue === undefined) {
index += 1;
}
if (inlineValue === undefined) index += 1;
args.set(key, value);
}
@@ -33,9 +28,7 @@ const timeoutMs = Number(args.get("timeout-ms") ?? 10_000);
function envFirst(names) {
for (const name of names) {
const value = process.env[name]?.trim();
if (value) {
return { name, value };
}
if (value) return { name, value };
}
return undefined;
}
@@ -51,19 +44,13 @@ async function checkProvider(id, config) {
try {
const headers = config.headers(secret.value);
const response = await fetch(config.url, {
body: config.body,
headers,
method: config.method,
signal: controller.signal,
});
const responseBody = config.validateResponse
? await response.json().catch(() => undefined)
: undefined;
const ok = response.ok && (!config.validateResponse || config.validateResponse(responseBody));
return {
id,
ok,
status: response.ok ? (ok ? "ok" : "invalid_response") : `http_${response.status}`,
ok: response.ok,
status: response.ok ? "ok" : `http_${response.status}`,
env: secret.name,
};
} catch (error) {
@@ -86,21 +73,11 @@ const providers = {
},
anthropic: {
env: ["ANTHROPIC_API_KEY", "ANTHROPIC_API_TOKEN"],
url: "https://api.anthropic.com/v1/messages",
method: "POST",
body: JSON.stringify({
max_tokens: 8,
messages: [{ role: "user", content: "Reply with OK." }],
model: "claude-haiku-4-5",
}),
url: "https://api.anthropic.com/v1/models",
headers: (token) => ({
"anthropic-version": "2023-06-01",
"content-type": "application/json",
"x-api-key": token,
}),
validateResponse: (body) =>
Array.isArray(body?.content) &&
body.content.some((part) => typeof part?.text === "string" && part.text.trim()),
},
fireworks: {
env: ["FIREWORKS_API_KEY"],
@@ -131,9 +108,7 @@ let failed = false;
for (const result of results) {
const requiredLabel = required.has(result.id) ? "required" : "optional";
console.log(`${result.id}: ${result.status} env=${result.env} ${requiredLabel}`);
if (required.has(result.id) && !result.ok) {
failed = true;
}
if (required.has(result.id) && !result.ok) failed = true;
}
if (failed) {

View File

@@ -100,26 +100,6 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
- `dev`: moving head on `main`
- When using a beta Git tag, publish npm with the matching beta version suffix so the plain version is not consumed or blocked
## Close stable releases on 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
release-only compatibility, test, or validation adapters into newer `main`.
2. Set `main` to the shipped stable version, not a speculative next train. Run
`pnpm release:prep` after the root version change, then
`pnpm deps:shrinkwrap:generate`.
3. Make `CHANGELOG.md`'s `## YYYY.M.PATCH` section on `main` exactly match the
tagged release branch. Include the stable `appcast.xml` update when the mac
release published one.
4. Do not add `YYYY.M.PATCH+1`, a beta version, or an empty future changelog
section to `main` until the operator explicitly starts that release train.
5. Run `pnpm release:generated:check`, `pnpm deps:shrinkwrap:check`, and
`OPENCLAW_TESTBOX=1 pnpm check:changed`. Push, then verify `origin/main`
contains the shipped version and changelog before calling the stable release
done.
## Handle versions and release files consistently
- Version locations include:
@@ -170,21 +150,9 @@ Stable publication is not complete until `main` carries the actual shipped relea
- Stable Windows Hub release closeout requires the signed
`OpenClawCompanion-Setup-x64.exe`, `OpenClawCompanion-Setup-arm64.exe`, and
`OpenClawCompanion-SHA256SUMS.txt` assets on the canonical
`openclaw/openclaw` GitHub Release. Pass the exact signed
`openclaw/openclaw-windows-node` release tag as `windows_node_tag` to
`OpenClaw Release Publish`, together with the candidate-approved
`windows_node_installer_digests` map; it prevalidates the published source
release and required installers against that map before any publish child,
dispatches the public `Windows Node Release` workflow while the OpenClaw
release is still a draft, carries those pinned source asset digests
unchanged, verifies the expected OpenClaw Foundation Authenticode signer on
Windows, re-downloads and checksum-verifies the promoted asset contract, and
blocks publication until the canonical asset contract is present. Use direct
`Windows Node Release` dispatch only for recovery, always with an exact tag,
never `latest`, and the explicit `expected_installer_digests` JSON map from
the approved source release. Recovery rejects unexpected
`OpenClawCompanion-*` target asset names, then replaces the expected contract
assets with the pinned source bytes.
`openclaw/openclaw` GitHub Release. Use the public `Windows Node Release`
workflow after the matching `openclaw/openclaw-windows-node` release exists;
it verifies Authenticode signatures on Windows before uploading assets.
- Website Windows Hub download links should target exact canonical
`openclaw/openclaw/releases/download/vYYYY.M.PATCH/...` assets for the current
stable release, or `releases/latest/download/...` only after verifying the
@@ -225,11 +193,6 @@ Stable publication is not complete until `main` carries the actual shipped relea
`CHANGELOG.md` version section, not highlights or an excerpt. When creating
or editing a release, extract from `## YYYY.M.PATCH` through the line before the
next level-2 heading and use that complete block as the release notes.
- Before publishing or closing a release, run
`$openclaw-changelog-update`'s `verify-release-notes.mjs` with every stable
and beta release tag in the train. Do not publish or leave a page live when
it is missing a source-history reference, eligible human credit, or the
complete matching changelog body.
- To update an existing GitHub Release body, resolve the numeric release id and
patch that resource with the notes file as the `body` field:
`gh api repos/openclaw/openclaw/releases/tags/vYYYY.M.PATCH --jq .id`, then
@@ -346,7 +309,6 @@ Upgrade with the beta channel.
Before tagging or publishing, run:
```bash
pnpm release:fast-pretag-check
pnpm check:architecture
pnpm build
pnpm ui:build
@@ -355,21 +317,6 @@ pnpm release:check
pnpm test:install:smoke
```
- Treat `pnpm release:fast-pretag-check` as a hard packaging gate. Every
publishable plugin must have a non-empty package-root `README.md`, build its
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 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
@@ -577,16 +524,6 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
- `preflight_only=true` on the npm workflow is also the right way to validate an
existing tag after publish; it should keep running the build checks even when
the npm version is already published.
- npm registry metadata is eventually consistent immediately after trusted
publishing. Keep postpublish `npm view` checks on bounded `--prefer-online`
retries, and carry that verified tarball/integrity metadata into later proof
steps instead of reading the registry again. If the OpenClaw npm child
succeeded but the parent publish workflow failed on an immediate exact-version
`E404`, verify the exact version with a cache-bypassed registry read, run the
standalone postpublish verifier and the full beta verifier with the original
successful child run IDs, then finalize the draft, dependency evidence asset,
and release proof manually. Never rerun the publish workflow for that
already-published version.
- npm validation-only preflight may still be dispatched from ordinary branches
when testing workflow changes before merge. Release checks and real publish
use only `main` or `release/YYYY.M.PATCH`.
@@ -695,10 +632,9 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
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.
preflight or publish. Keep 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
release blocker specifically requires it. For stable releases, include the
mac app, signing, notarization, and appcast path.
@@ -739,29 +675,20 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
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 the same branch with
22. Start `.github/workflows/openclaw-npm-release.yml` from the same branch with
the same tag for the real 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
`preflight_run_id` plus the successful `full_release_validation_run_id`.
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`.
`preflight_run_id`.
23. Wait for `npm-release` approval from `@openclaw/openclaw-release-managers`.
24. 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
waited plugin publish or Windows Hub promotion fails after OpenClaw npm
succeeds, the workflow keeps the release draft with OpenClaw npm evidence
and exits red; do not undraft until the gap is repaired. The standalone
verifier command remains the first recovery probe:
waited plugin publish fails after OpenClaw npm succeeds, the workflow keeps
the release draft with OpenClaw npm evidence and exits red; do not undraft
until the plugin publish gap is repaired. The standalone verifier command
remains the recovery probe:
`node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>`.
For a failed postpublish parent after successful publish children, also run
`pnpm release:verify-beta -- <published-version> ... --skip-github-release`
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
@@ -798,13 +725,13 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
and `.dSYM.zip` artifacts to the existing GitHub release in
`openclaw/openclaw`.
32. For stable releases, download `macos-appcast-<tag>` from the successful
private mac run, update `appcast.xml` on `main`, verify the feed, then
complete the **Close stable releases on main** gate.
private mac run, update `appcast.xml` on `main`, and verify the feed. Merge
or cherry-pick release branch changes back to `main` after stable succeeds.
33. 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.
34. After publish, verify npm and the attached release artifacts.
## GHSA advisory work

5
.github/CODEOWNERS vendored
View File

@@ -12,14 +12,9 @@
/.github/workflows/codeql-android-critical-security.yml @openclaw/openclaw-secops
/.github/workflows/codeql-critical-quality.yml @openclaw/openclaw-secops
/.github/workflows/dependency-guard.yml @openclaw/openclaw-secops
/.github/workflows/security-sensitive-guard.yml @openclaw/openclaw-secops
/test/scripts/dependency-guard-workflow.test.ts @openclaw/openclaw-secops
/test/scripts/dependency-guard-script.test.ts @openclaw/openclaw-secops
/test/scripts/security-sensitive-guard-workflow.test.ts @openclaw/openclaw-secops
/test/scripts/security-sensitive-guard-script.test.ts @openclaw/openclaw-secops
/scripts/github/dependency-guard.mjs @openclaw/openclaw-secops
/scripts/github/security-sensitive-guard.mjs @openclaw/openclaw-secops
/.gitignore @openclaw/openclaw-secops
/package-lock.json @openclaw/openclaw-secops
/npm-shrinkwrap.json @openclaw/openclaw-secops
/extensions/*/package-lock.json @openclaw/openclaw-secops

View File

@@ -0,0 +1,61 @@
name: openclaw-codeql-process-exec-boundary-critical-security
disable-default-queries: true
queries:
- uses: security-extended
query-filters:
- include:
precision:
- high
- very-high
tags contain: security
security-severity: /([7-9]|10)\.(\d)+/
paths:
- src/process
- src/tui/tui-local-shell.ts
- src/tui/tui.ts
- src/plugin-sdk/windows-spawn.ts
- packages/agent-core/src/harness/env
- packages/memory-host-sdk/src/host
- extensions/acpx/src
- extensions/bonjour/src/advertiser.ts
- extensions/browser/src/browser/chrome-mcp.ts
- extensions/browser/src/browser/chrome.executables.ts
- extensions/browser/src/browser/chrome.ts
- extensions/codex/src/app-server/sandbox-exec-server
- extensions/codex/src/app-server/transport-stdio.ts
- extensions/codex/src/node-cli-sessions.ts
- extensions/codex-supervisor/src/json-rpc-client.ts
- extensions/file-transfer/src
- extensions/google-meet/src
- extensions/imessage/src
- extensions/memory-core/src/memory/qmd-manager.ts
- extensions/memory-wiki/src/obsidian.ts
- extensions/microsoft-foundry/cli.ts
- extensions/ollama/src/wsl2-crash-loop-check.ts
- extensions/qa-lab/src
- extensions/signal/src/daemon.ts
- extensions/tts-local-cli/speech-provider.ts
- extensions/voice-call/src
- scripts
paths-ignore:
- "**/node_modules"
- "**/coverage"
- "**/*.generated.ts"
- "**/*.bundle.js"
- "**/*-runtime.js"
- "**/*.test.ts"
- "**/*.test.tsx"
- "**/*.spec.ts"
- "**/*.spec.tsx"
- "**/*.e2e.test.ts"
- "**/*.e2e.test.tsx"
- "**/*test-support*"
- "**/*test-helper*"
- "**/*mock*"
- "**/*fixture*"
- "**/*bench*"

View File

@@ -61,7 +61,7 @@ jobs:
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
git -C "$workdir" config gc.auto 0
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
-c protocol.version=2 \
-c "http.extraheader=AUTHORIZATION: basic ${auth_header}" \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
@@ -188,7 +188,7 @@ jobs:
run: |
set -euo pipefail
timeout --signal=TERM --kill-after=10s 120s git \
timeout --signal=TERM --kill-after=10s 30s git \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
"+refs/heads/main:refs/remotes/origin/main"

View File

@@ -76,7 +76,7 @@ jobs:
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
git -C "$workdir" config gc.auto 0
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
-c protocol.version=2 \
-c "http.extraheader=AUTHORIZATION: basic ${auth_header}" \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
@@ -106,7 +106,7 @@ jobs:
run: |
set -euo pipefail
timeout --signal=TERM --kill-after=10s 120s git \
timeout --signal=TERM --kill-after=10s 30s git \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
"+refs/heads/main:refs/remotes/origin/main"

View File

@@ -6,10 +6,6 @@ on:
type: string
description: "Testbox session ID"
required: true
timeout_minutes:
type: number
description: "Maximum GitHub job runtime for long Testbox commands"
default: 120
pull_request:
paths:
- ".github/workflows/**"
@@ -29,7 +25,7 @@ jobs:
contents: read
name: "check"
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: ${{ fromJSON(inputs.timeout_minutes || '30') }}
timeout-minutes: 30
steps:
- name: Begin Testbox
uses: useblacksmith/begin-testbox@233448af4bfdc6fca509a7f0974411ac6d8a8043
@@ -65,7 +61,7 @@ jobs:
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
git -C "$workdir" config gc.auto 0
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
-c protocol.version=2 \
-c "http.extraheader=AUTHORIZATION: basic ${auth_header}" \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
@@ -95,7 +91,7 @@ jobs:
run: |
set -euo pipefail
timeout --signal=TERM --kill-after=10s 120s git \
timeout --signal=TERM --kill-after=10s 30s git \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
"+refs/heads/main:refs/remotes/origin/main"

View File

@@ -90,7 +90,7 @@ jobs:
local ref="$1"
local fetch_status
for attempt in 1 2 3; do
timeout --signal=TERM --kill-after=10s 120s git -C "$GITHUB_WORKSPACE" \
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=2 origin \
"+${ref}:refs/remotes/origin/checkout" && return 0
@@ -351,7 +351,7 @@ jobs:
local ref="$1"
local fetch_status
for attempt in 1 2 3; do
timeout --signal=TERM --kill-after=10s 120s git -C "$GITHUB_WORKSPACE" \
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${ref}:refs/remotes/origin/checkout" && return 0
@@ -499,7 +499,7 @@ jobs:
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
git -C "$workdir" config gc.auto 0
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
@@ -564,7 +564,7 @@ jobs:
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
git -C "$workdir" config gc.auto 0
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
@@ -810,7 +810,7 @@ jobs:
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
git -C "$workdir" config gc.auto 0
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
@@ -850,10 +850,10 @@ 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/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/run-vitest.test.ts test/scripts/test-projects.test.ts
;;
bun-launcher)
OPENCLAW_TEST_BUN_LAUNCHER=1 pnpm test test/openclaw-launcher.e2e.test.ts
@@ -899,7 +899,7 @@ jobs:
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
git -C "$workdir" config gc.auto 0
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
@@ -979,7 +979,7 @@ jobs:
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
git -C "$workdir" config gc.auto 0
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
@@ -1056,7 +1056,7 @@ jobs:
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
git -C "$workdir" config gc.auto 0
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
@@ -1131,7 +1131,7 @@ jobs:
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
git -C "$workdir" config gc.auto 0
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
@@ -1258,7 +1258,7 @@ jobs:
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
git -C "$workdir" config gc.auto 0
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
@@ -1288,7 +1288,6 @@ jobs:
env:
OPENCLAW_LOCAL_CHECK: "0"
TASK: ${{ matrix.task }}
PR_BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }}
shell: bash
run: |
set -euo pipefail
@@ -1298,10 +1297,6 @@ jobs:
pnpm tool-display:check
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
fi
pnpm deps:patches:check
pnpm lint:webhook:no-low-level-body-read
pnpm lint:auth:no-pairing-store-group
@@ -1363,10 +1358,6 @@ jobs:
- check_name: check-additional-boundaries-bcd
group: boundaries
boundary_shard: 2/4,3/4,4/4
- check_name: check-session-accessor-boundary
group: session-accessor-boundary
- check_name: check-session-transcript-reader-boundary
group: session-transcript-reader-boundary
- check_name: check-additional-extension-channels
group: extension-channels
- check_name: check-additional-extension-bundled
@@ -1399,7 +1390,7 @@ jobs:
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
git -C "$workdir" config gc.auto 0
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
@@ -1513,24 +1504,6 @@ jobs:
boundaries)
node scripts/run-additional-boundary-checks.mjs
;;
session-accessor-boundary)
if [ ! -f scripts/check-session-accessor-boundary.mjs ]; then
echo "[skip] session accessor boundary check is not present in this checkout"
elif ! node -e 'const pkg = require("./package.json"); process.exit(pkg.scripts?.["lint:tmp:session-accessor-boundary"] ? 0 : 1);'; then
echo "[skip] session accessor boundary script is not present in package.json"
else
run_check "lint:tmp:session-accessor-boundary" pnpm run lint:tmp:session-accessor-boundary
fi
;;
session-transcript-reader-boundary)
if [ ! -f scripts/check-session-transcript-reader-boundary.mjs ]; then
echo "[skip] session transcript reader boundary check is not present in this checkout"
elif ! node -e 'const pkg = require("./package.json"); process.exit(pkg.scripts?.["lint:tmp:session-transcript-reader-boundary"] ? 0 : 1);'; then
echo "[skip] session transcript reader boundary script is not present in package.json"
else
run_check "lint:tmp:session-transcript-reader-boundary" pnpm run lint:tmp:session-transcript-reader-boundary
fi
;;
extension-channels)
run_check "lint:extensions:channels" pnpm run lint:extensions:channels
;;
@@ -1584,7 +1557,7 @@ jobs:
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
git -C "$workdir" config gc.auto 0
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
@@ -1630,7 +1603,7 @@ jobs:
git -C "$workdir" config gc.auto 0
git -C "$workdir" remote add origin "https://github.com/openclaw/clawhub.git"
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+refs/heads/main:refs/remotes/origin/checkout" || return 1
@@ -1677,7 +1650,7 @@ jobs:
fetch_checkout_ref() {
local fetch_status
for attempt in 1 2 3; do
timeout --signal=TERM --kill-after=10s 120s git -C "$GITHUB_WORKSPACE" \
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout" && return 0
@@ -2083,7 +2056,7 @@ jobs:
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
git -C "$workdir" config gc.auto 0
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1

View File

@@ -17,7 +17,28 @@ on:
- ".github/actions/**"
- ".github/codeql/**"
- ".github/workflows/**"
- "extensions/acpx/src/**"
- "extensions/bonjour/src/advertiser.ts"
- "extensions/browser/src/browser/chrome-mcp.ts"
- "extensions/browser/src/browser/chrome.executables.ts"
- "extensions/browser/src/browser/chrome.ts"
- "extensions/codex/src/app-server/sandbox-exec-server/**"
- "extensions/codex/src/app-server/transport-stdio.ts"
- "extensions/codex/src/node-cli-sessions.ts"
- "extensions/codex-supervisor/src/json-rpc-client.ts"
- "extensions/file-transfer/src/**"
- "extensions/google-meet/src/**"
- "extensions/imessage/src/**"
- "extensions/memory-core/src/memory/qmd-manager.ts"
- "extensions/memory-wiki/src/obsidian.ts"
- "extensions/microsoft-foundry/cli.ts"
- "extensions/ollama/src/wsl2-crash-loop-check.ts"
- "extensions/qa-lab/src/**"
- "extensions/signal/src/daemon.ts"
- "extensions/tts-local-cli/speech-provider.ts"
- "extensions/voice-call/src/**"
- "packages/**"
- "scripts/**"
- "src/**"
push:
branches:
@@ -26,7 +47,28 @@ on:
- ".github/actions/**"
- ".github/codeql/**"
- ".github/workflows/**"
- "extensions/acpx/src/**"
- "extensions/bonjour/src/advertiser.ts"
- "extensions/browser/src/browser/chrome-mcp.ts"
- "extensions/browser/src/browser/chrome.executables.ts"
- "extensions/browser/src/browser/chrome.ts"
- "extensions/codex/src/app-server/sandbox-exec-server/**"
- "extensions/codex/src/app-server/transport-stdio.ts"
- "extensions/codex/src/node-cli-sessions.ts"
- "extensions/codex-supervisor/src/json-rpc-client.ts"
- "extensions/file-transfer/src/**"
- "extensions/google-meet/src/**"
- "extensions/imessage/src/**"
- "extensions/memory-core/src/memory/qmd-manager.ts"
- "extensions/memory-wiki/src/obsidian.ts"
- "extensions/microsoft-foundry/cli.ts"
- "extensions/ollama/src/wsl2-crash-loop-check.ts"
- "extensions/qa-lab/src/**"
- "extensions/signal/src/daemon.ts"
- "extensions/tts-local-cli/speech-provider.ts"
- "extensions/voice-call/src/**"
- "packages/**"
- "scripts/**"
- "src/**"
schedule:
- cron: "0 6 * * *"
@@ -73,6 +115,11 @@ jobs:
runs_on: blacksmith-4vcpu-ubuntu-2404
timeout_minutes: 25
config_file: ./.github/codeql/codeql-mcp-process-tool-boundary-critical-security.yml
- language: javascript-typescript
category: process-exec-boundary
runs_on: blacksmith-4vcpu-ubuntu-2404
timeout_minutes: 25
config_file: ./.github/codeql/codeql-process-exec-boundary-critical-security.yml
- language: javascript-typescript
category: plugin-trust-boundary
runs_on: blacksmith-4vcpu-ubuntu-2404

View File

@@ -275,7 +275,7 @@ jobs:
local workflow="$1"
shift
local dispatch_output run_id status conclusion url poll_count
local before_json dispatch_output run_id status conclusion url poll_count
gh_with_retry() {
local output status attempt
for attempt in 1 2 3 4 5 6; do
@@ -298,6 +298,8 @@ jobs:
printf '%s\n' "$output" >&2
return "$status"
}
before_json="$(gh_with_retry run list --workflow "$workflow" --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
dispatch_output="$(gh_with_retry workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@")"
printf '%s\n' "$dispatch_output"
run_id="$(
@@ -307,7 +309,20 @@ jobs:
)"
if [[ -z "$run_id" ]]; then
echo "::error::gh workflow run ${workflow} did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs." >&2
for _ in $(seq 1 60); do
run_id="$(
BEFORE_IDS="$before_json" gh_with_retry run list --workflow "$workflow" --event workflow_dispatch --limit 50 --json databaseId,createdAt \
--jq 'map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
)"
if [[ -n "$run_id" ]]; then
break
fi
sleep 5
done
fi
if [[ -z "${run_id:-}" ]]; then
echo "Could not find dispatched run for ${workflow}." >&2
exit 1
fi
@@ -408,7 +423,7 @@ jobs:
local workflow="$1"
shift
local dispatch_output run_id status conclusion url poll_count
local before_json dispatch_output run_id status conclusion url poll_count
gh_with_retry() {
local output status attempt
for attempt in 1 2 3 4 5 6; do
@@ -431,6 +446,8 @@ jobs:
printf '%s\n' "$output" >&2
return "$status"
}
before_json="$(gh_with_retry run list --workflow "$workflow" --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
dispatch_output="$(gh_with_retry workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@")"
printf '%s\n' "$dispatch_output"
run_id="$(
@@ -440,7 +457,20 @@ jobs:
)"
if [[ -z "$run_id" ]]; then
echo "::error::gh workflow run ${workflow} did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs." >&2
for _ in $(seq 1 60); do
run_id="$(
BEFORE_IDS="$before_json" gh_with_retry run list --workflow "$workflow" --event workflow_dispatch --limit 50 --json databaseId,createdAt \
--jq 'map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
)"
if [[ -n "$run_id" ]]; then
break
fi
sleep 5
done
fi
if [[ -z "${run_id:-}" ]]; then
echo "Could not find dispatched run for ${workflow}." >&2
exit 1
fi
@@ -551,7 +581,7 @@ jobs:
local workflow="$1"
shift
local dispatch_output run_id status conclusion url poll_count run_json
local before_json dispatch_output run_id status conclusion url poll_count run_json
gh_with_retry() {
local output status attempt
for attempt in 1 2 3 4 5 6; do
@@ -574,6 +604,8 @@ jobs:
printf '%s\n' "$output" >&2
return "$status"
}
before_json="$(gh_with_retry run list --workflow "$workflow" --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
dispatch_output="$(gh_with_retry workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@")"
printf '%s\n' "$dispatch_output"
run_id="$(
@@ -583,7 +615,20 @@ jobs:
)"
if [[ -z "$run_id" ]]; then
echo "::error::gh workflow run ${workflow} did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs." >&2
for _ in $(seq 1 60); do
run_id="$(
BEFORE_IDS="$before_json" gh_with_retry run list --workflow "$workflow" --event workflow_dispatch --limit 50 --json databaseId,createdAt \
--jq 'map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
)"
if [[ -n "$run_id" ]]; then
break
fi
sleep 5
done
fi
if [[ -z "${run_id:-}" ]]; then
echo "Could not find dispatched run for ${workflow}." >&2
exit 1
fi
@@ -738,7 +783,7 @@ jobs:
fi
args=(
-f ref="$TARGET_REF"
-f ref="$TARGET_SHA"
-f expected_sha="$TARGET_SHA"
-f provider="$PROVIDER"
-f mode="$MODE"
@@ -883,6 +928,8 @@ jobs:
return "$status"
}
before_json="$(gh_with_retry run list --workflow npm-telegram-beta-e2e.yml --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
args=(-f package_spec="${PACKAGE_SPEC:-openclaw@beta}" -f harness_ref="$TARGET_SHA" -f provider_mode="$PROVIDER_MODE")
if [[ -z "${PACKAGE_SPEC// }" ]]; then
if [[ "$PREPARE_PACKAGE_RESULT" != "success" || -z "${PACKAGE_ARTIFACT_NAME// }" ]]; then
@@ -899,16 +946,22 @@ jobs:
args+=(-f scenario="$SCENARIO")
fi
dispatch_output="$(gh_with_retry workflow run npm-telegram-beta-e2e.yml --ref "$CHILD_WORKFLOW_REF" "${args[@]}")"
printf '%s\n' "$dispatch_output"
run_id="$(
printf '%s\n' "$dispatch_output" |
sed -nE 's#.*actions/runs/([0-9]+).*#\1#p' |
tail -n 1
)"
gh_with_retry workflow run npm-telegram-beta-e2e.yml --ref "$CHILD_WORKFLOW_REF" "${args[@]}"
run_id=""
for _ in $(seq 1 60); do
run_id="$(
BEFORE_IDS="$before_json" gh_with_retry run list --workflow npm-telegram-beta-e2e.yml --event workflow_dispatch --limit 50 --json databaseId,createdAt \
--jq 'map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
)"
if [[ -n "$run_id" ]]; then
break
fi
sleep 5
done
if [[ -z "$run_id" ]]; then
echo "::error::gh workflow run npm-telegram-beta-e2e.yml did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs." >&2
echo "Could not find dispatched run for npm-telegram-beta-e2e.yml." >&2
exit 1
fi
@@ -1020,23 +1073,31 @@ jobs:
echo "- Release impact: advisory"
} >> "$GITHUB_STEP_SUMMARY"
dispatch_output="$(gh_with_retry workflow run openclaw-performance.yml \
before_json="$(gh_with_retry run list --workflow openclaw-performance.yml --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
gh_with_retry workflow run openclaw-performance.yml \
--ref "$CHILD_WORKFLOW_REF" \
-f target_ref="$TARGET_SHA" \
-f profile=release \
-f repeat=3 \
-f deep_profile=false \
-f live_openai_candidate=false \
-f fail_on_regression=false)"
printf '%s\n' "$dispatch_output"
run_id="$(
printf '%s\n' "$dispatch_output" |
sed -nE 's#.*actions/runs/([0-9]+).*#\1#p' |
tail -n 1
)"
-f fail_on_regression=false
run_id=""
for _ in $(seq 1 60); do
run_id="$(
BEFORE_IDS="$before_json" gh_with_retry run list --workflow openclaw-performance.yml --event workflow_dispatch --limit 50 --json databaseId,createdAt \
--jq 'map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
)"
if [[ -n "$run_id" ]]; then
break
fi
sleep 5
done
if [[ -z "$run_id" ]]; then
echo "::warning::gh workflow run openclaw-performance.yml did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs."
echo "::warning::Could not find dispatched run for openclaw-performance.yml."
exit 0
fi

View File

@@ -476,21 +476,19 @@ jobs:
- name: Run Rocky Linux installer smoke
run: |
timeout --kill-after=30s 20m docker run --rm \
--platform linux/amd64 \
-e OPENCLAW_NO_ONBOARD=1 \
-e OPENCLAW_NO_PROMPT=1 \
-v "$PWD/scripts/install.sh:/tmp/install.sh:ro" \
rockylinux:9@sha256:d644d203142cd5b54ad2a83a203e1dee68af2229f8fe32f52a30c6e1d3c3a9e0 \
rockylinux:9@sha256:d7be1c094cc5845ee815d4632fe377514ee6ebcf8efaed6892889657e5ddaaa6 \
bash -lc 'dnf install -y -q ca-certificates tar gzip xz findutils which sudo >/dev/null && bash /tmp/install.sh --install-method npm --version latest --no-onboard --no-prompt --verify && openclaw --version'
- name: Run Rocky Linux CLI installer smoke
run: |
timeout --kill-after=30s 20m docker run --rm \
--platform linux/amd64 \
-e OPENCLAW_NO_ONBOARD=1 \
-e OPENCLAW_NO_PROMPT=1 \
-v "$PWD/scripts/install-cli.sh:/tmp/install-cli.sh:ro" \
rockylinux:9@sha256:d644d203142cd5b54ad2a83a203e1dee68af2229f8fe32f52a30c6e1d3c3a9e0 \
rockylinux:9@sha256:d7be1c094cc5845ee815d4632fe377514ee6ebcf8efaed6892889657e5ddaaa6 \
bash -lc 'dnf install -y -q ca-certificates tar gzip xz findutils which sudo >/dev/null && bash /tmp/install-cli.sh --prefix /tmp/openclaw-cli --version latest --no-onboard && /tmp/openclaw-cli/bin/openclaw --version'
bun_global_install_smoke:

View File

@@ -1,447 +0,0 @@
name: iOS 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"]
types: [completed]
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
permissions:
actions: read
contents: read
issues: write
pull-requests: read
jobs:
comment:
name: Comment on PR
runs-on: ubuntu-24.04
if: >
github.repository == 'openclaw/openclaw' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.name == 'iOS Periphery Dead Code'
steps:
- name: Upsert Periphery PR comment
uses: actions/github-script@v9
with:
script: |
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const childProcess = require("node:child_process");
const marker = "<!-- openclaw-ios-periphery-dead-code -->";
const run = context.payload.workflow_run;
const pr = run.pull_requests?.[0];
if (!pr) {
core.info("No pull request attached to workflow_run.");
return;
}
const { owner, repo } = context.repo;
const repository = `${owner}/${repo}`;
if (run.repository?.full_name !== repository) {
core.info(`Skipping workflow_run from ${run.repository?.full_name ?? "unknown repository"}.`);
return;
}
if (run.event !== "pull_request") {
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,
pull_number: pr.number,
});
if (livePull.data.state !== "open") {
core.info(`Skipping closed PR #${pr.number}.`);
return;
}
if (livePull.data.base?.repo?.full_name !== repository) {
core.info(`Skipping PR #${pr.number} targeting ${livePull.data.base?.repo?.full_name ?? "unknown repository"}.`);
return;
}
if (livePull.data.head?.sha !== run.head_sha) {
core.info(`Skipping stale run ${run.id}; PR #${pr.number} is now at ${livePull.data.head?.sha}.`);
return;
}
const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, {
owner,
repo,
run_id: run.id,
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 scanSkipped =
scopeJob?.conclusion === "success" && scanJob?.conclusion === "skipped";
if (scanSkipped) {
core.info(`Skipping intentionally omitted Periphery scan for PR #${pr.number}.`);
}
const artifacts = scanSkipped
? []
: await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
owner,
repo,
run_id: run.id,
per_page: 100,
});
const readReport = async () => {
if (scanSkipped) {
return;
}
const artifactName = `ios-periphery-dead-code-${run.id}-${run.run_attempt}`;
const artifact = artifacts.find((item) => item.name === artifactName);
if (!artifact) {
core.warning(`No ${artifactName} artifact found.`);
return;
}
if (artifact.expired) {
core.warning(`${artifactName} artifact expired.`);
return;
}
const maxArchiveBytes = 1024 * 1024;
const archiveSize = Number(artifact.size_in_bytes);
if (!Number.isSafeInteger(archiveSize) || archiveSize < 0 || archiveSize > maxArchiveBytes) {
core.warning(`Skipping ${artifactName}; compressed artifact size ${artifact.size_in_bytes ?? "unknown"} exceeds the ${maxArchiveBytes} byte limit.`);
return;
}
const archive = await github.rest.actions.downloadArtifact({
owner,
repo,
artifact_id: artifact.id,
archive_format: "zip",
});
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ios-periphery-"));
const archivePath = path.join(dir, "artifact.zip");
const archiveBuffer = Buffer.from(archive.data);
fs.writeFileSync(archivePath, archiveBuffer);
const allowedArtifactFiles = new Set([
"periphery.json",
"periphery.status",
"periphery.stderr.log",
"periphery.stdout.json",
"should-fail.txt",
]);
const maxEntries = allowedArtifactFiles.size;
const maxEntryBytes = 2 * 1024 * 1024;
const maxTotalBytes = 4 * 1024 * 1024;
const readUInt16 = (offset) => archiveBuffer.readUInt16LE(offset);
const readUInt32 = (offset) => archiveBuffer.readUInt32LE(offset);
const findEndOfCentralDirectoryOffset = () => {
const minimumOffset = Math.max(0, archiveBuffer.length - 0xffff - 22);
for (let offset = archiveBuffer.length - 22; offset >= minimumOffset; offset -= 1) {
if (readUInt32(offset) === 0x06054b50) {
return offset;
}
}
return -1;
};
const endOfCentralDirectoryOffset = findEndOfCentralDirectoryOffset();
if (endOfCentralDirectoryOffset < 0) {
core.warning(`Skipping ${artifactName}; ZIP end-of-central-directory record was not found.`);
return;
}
const entryCount = readUInt16(endOfCentralDirectoryOffset + 10);
const centralDirectorySize = readUInt32(endOfCentralDirectoryOffset + 12);
const centralDirectoryOffset = readUInt32(endOfCentralDirectoryOffset + 16);
if (entryCount < 1 || entryCount > maxEntries) {
core.warning(`Skipping ${artifactName}; artifact has ${entryCount} entries.`);
return;
}
if (
centralDirectoryOffset + centralDirectorySize > archiveBuffer.length ||
readUInt32(centralDirectoryOffset) !== 0x02014b50
) {
core.warning(`Skipping ${artifactName}; invalid ZIP central directory.`);
return;
}
const entries = new Map();
let totalUncompressedSize = 0;
let offset = centralDirectoryOffset;
for (let index = 0; index < entryCount; index += 1) {
if (offset + 46 > archiveBuffer.length || readUInt32(offset) !== 0x02014b50) {
core.warning(`Skipping ${artifactName}; invalid central directory entry.`);
return;
}
const compressionMethod = readUInt16(offset + 10);
const generalPurposeBitFlag = readUInt16(offset + 8);
const compressedSize = readUInt32(offset + 20);
const uncompressedSize = readUInt32(offset + 24);
const fileNameLength = readUInt16(offset + 28);
const extraLength = readUInt16(offset + 30);
const commentLength = readUInt16(offset + 32);
const externalAttributes = readUInt32(offset + 38);
const nameStart = offset + 46;
const nameEnd = nameStart + fileNameLength;
const nextOffset = nameEnd + extraLength + commentLength;
if (nextOffset > archiveBuffer.length) {
core.warning(`Skipping ${artifactName}; central directory entry exceeds archive bounds.`);
return;
}
const name = archiveBuffer.toString("utf8", nameStart, nameEnd);
const mode = externalAttributes >>> 16;
const fileType = mode & 0o170000;
const isRegularFile = fileType === 0 || fileType === 0o100000;
const invalidName =
!allowedArtifactFiles.has(name) ||
name.includes("/") ||
name.includes("\\") ||
name.includes("..") ||
path.isAbsolute(name);
if (invalidName) {
core.warning(`Skipping ${artifactName}; unexpected artifact entry ${name}.`);
return;
}
if (!isRegularFile || name.endsWith("/")) {
core.warning(`Skipping ${artifactName}; ${name} is not a regular file.`);
return;
}
if (entries.has(name)) {
core.warning(`Skipping ${artifactName}; duplicate artifact entry ${name}.`);
return;
}
if (![0, 8].includes(compressionMethod)) {
core.warning(`Skipping ${artifactName}; ${name} uses unsupported ZIP compression method ${compressionMethod}.`);
return;
}
if ((generalPurposeBitFlag & 0x1) !== 0) {
core.warning(`Skipping ${artifactName}; ${name} is encrypted.`);
return;
}
if (compressedSize > maxEntryBytes || uncompressedSize > maxEntryBytes) {
core.warning(`Skipping ${artifactName}; ${name} exceeds the per-file size limit.`);
return;
}
totalUncompressedSize += uncompressedSize;
if (totalUncompressedSize > maxTotalBytes) {
core.warning(`Skipping ${artifactName}; artifact exceeds the aggregate size limit.`);
return;
}
entries.set(name, { uncompressedSize });
offset = nextOffset;
}
const files = new Map();
for (const [name, entry] of entries) {
const contents = childProcess.execFileSync("unzip", ["-p", archivePath, name], {
encoding: "utf8",
maxBuffer: Math.max(1, entry.uncompressedSize + 1024),
timeout: 5000,
});
if (Buffer.byteLength(contents, "utf8") > maxEntryBytes) {
core.warning(`Skipping ${artifactName}; ${name} exceeded the per-file size limit while reading.`);
return;
}
files.set(name, contents);
}
const read = (name) => {
return files.get(name) ?? "";
};
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));
const validFindings =
Array.isArray(parsed) &&
parsed.every(
(finding) =>
finding !== null &&
typeof finding === "object" &&
!Array.isArray(finding),
);
if (validFindings) {
findings = parsed;
break;
}
} catch {}
}
return { findings, status };
};
const report = await readReport();
const status = report?.status ?? 1;
const findings = report?.findings ?? null;
const sanitizeCell = (value) => {
const normalized = String(value ?? "")
.replace(/[\u0000-\u001f\u007f-\u009f]/gu, " ")
.replace(/[\u200b-\u200f\u202a-\u202e\u2060\u2066-\u2069\ufeff]/gu, "")
.replace(/\s+/gu, " ")
.trim();
const maxEncodedLength = 180;
let escaped = "";
for (const character of normalized) {
const encoded =
character === "`"
? "'"
: character === "|"
? "\\|"
: character;
if (escaped.length + encoded.length > maxEncodedLength) {
break;
}
escaped += encoded;
}
return `\`${escaped || "-"}\``;
};
const rows = (findings ?? []).map((finding) => {
const location = String(finding.location ?? "");
const [file, line] = location.split(":");
return {
file: file ? `apps/ios/${file}` : "",
line: line || "",
kind: String(finding.kind ?? ""),
name: String(finding.name ?? ""),
};
});
let mode = "failure";
let body = `${marker}\n`;
if (scanSkipped) {
mode = "skipped";
body += [
"### iOS Periphery",
"",
"Periphery scan skipped because the pull request is a draft or no longer touches iOS scan scope.",
].join("\n");
} else if (findings === null) {
body += [
"### iOS Periphery",
"",
"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",
"",
"No dead Swift code found.",
].join("\n");
} else if (rows.length > 0) {
const shown = rows.slice(0, 50);
body += [
"### iOS Periphery",
"",
`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.`,
"",
"| File | Line | Kind | Name |",
"| --- | ---: | --- | --- |",
...shown.map((row) => `| ${sanitizeCell(row.file)} | ${sanitizeCell(row.line)} | ${sanitizeCell(row.kind)} | ${sanitizeCell(row.name)} |`),
rows.length > shown.length ? "" : null,
rows.length > shown.length ? `Showing first ${shown.length}; full JSON is in the workflow artifact.` : null,
].filter(Boolean).join("\n");
} else {
body += [
"### iOS Periphery",
"",
"Periphery exited with a non-zero status before producing findings. Check the workflow artifact for stdout/stderr.",
].join("\n");
}
body += "\n";
const maxCommentChars = 60_000;
if (body.length > maxCommentChars) {
body = [
marker,
"### iOS Periphery",
"",
`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.`,
"",
].join("\n");
}
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: livePull.data.number,
per_page: 100,
});
const existing = comments.find(
(comment) =>
comment.user?.login === "github-actions[bot]" &&
comment.body?.includes(marker),
);
if (!existing && ["skipped", "success"].includes(mode)) {
core.info(`No existing Periphery comment and scan ${mode}; skipping comment.`);
return;
}
const currentPull = await github.rest.pulls.get({
owner,
repo,
pull_number: pr.number,
});
if (
currentPull.data.state !== "open" ||
currentPull.data.base?.repo?.full_name !== repository ||
currentPull.data.head?.sha !== run.head_sha
) {
core.info(`Skipping stale run ${run.id}; PR #${pr.number} changed before comment update.`);
return;
}
const workflowRuns = await github.paginate(github.rest.actions.listWorkflowRuns, {
owner,
repo,
workflow_id: run.workflow_id,
event: "pull_request",
head_sha: run.head_sha,
per_page: 100,
});
const supersedingRun = workflowRuns.find(
(candidate) =>
(candidate.id === run.id ||
candidate.pull_requests?.some(
(candidatePull) => candidatePull.number === pr.number,
)) &&
(candidate.run_number > run.run_number ||
(candidate.run_number === run.run_number &&
candidate.run_attempt > run.run_attempt)),
);
if (supersedingRun) {
core.info(`Skipping superseded run ${run.id} attempt ${run.run_attempt}; run ${supersedingRun.id} attempt ${supersedingRun.run_attempt} is newer.`);
return;
}
if (existing) {
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body,
});
return;
}
await github.rest.issues.createComment({
owner,
repo,
issue_number: livePull.data.number,
body,
});

View File

@@ -1,229 +0,0 @@
name: iOS Periphery Dead Code
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review, converted_to_draft]
workflow_dispatch:
concurrency:
group: ios-periphery-${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
permissions:
contents: read
pull-requests: read
jobs:
scope:
name: Detect iOS scan scope
runs-on: ubuntu-24.04
outputs:
should-scan: ${{ steps.scope.outputs.should-scan }}
steps:
- name: Detect changed paths
id: scope
uses: actions/github-script@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 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 === ".github/workflows/ios-periphery.yml" ||
filename === ".github/workflows/ios-periphery-comment.yml" ||
filename === "config/swiftformat" ||
filename === "config/swiftlint.yml"
);
const shouldScan = files.some(
({ filename, previous_filename: previousFilename }) =>
isScanPath(filename) || isScanPath(previousFilename)
);
core.setOutput("should-scan", String(shouldScan));
scan:
name: Scan iOS 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@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 Swift tooling
run: brew install xcodegen swiftformat swiftlint periphery
- name: Generate iOS project
run: |
set -euo pipefail
./scripts/ios-configure-signing.sh
./scripts/ios-write-version-xcconfig.sh
cd apps/ios
xcodegen generate
- name: Run Periphery
run: |
set -euo pipefail
output_dir="$RUNNER_TEMP/ios-periphery"
mkdir -p "$output_dir"
cd apps/ios
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, "ios-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/ios/${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 = [
"### iOS Periphery",
"",
"Periphery did not complete. Check the workflow artifact for stdout/stderr.",
].join("\n");
} else if (rows.length === 0 && status === 0) {
shouldFail = "0";
summary = [
"### iOS Periphery",
"",
"No dead Swift code found.",
].join("\n");
} else if (rows.length > 0) {
summary = [
"### iOS 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 = [
"### iOS 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@v7
with:
name: ios-periphery-dead-code-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/ios-periphery
if-no-files-found: warn
retention-days: 14
- name: Fail on dead code
run: |
set -euo pipefail
test "$(cat "$RUNNER_TEMP/ios-periphery/should-fail.txt")" = "0"

View File

@@ -379,6 +379,7 @@ jobs:
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1"
CRABBOX_COORDINATOR: ${{ secrets.CRABBOX_COORDINATOR }}
CRABBOX_COORDINATOR_TOKEN: ${{ secrets.CRABBOX_COORDINATOR_TOKEN }}
OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR: ${{ secrets.OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR }}

View File

@@ -220,6 +220,7 @@ jobs:
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1"
INPUT_SCENARIO: ${{ inputs.scenario }}
PACKAGE_ARTIFACT_NAME: ${{ inputs.package_artifact_name || '' }}
run: |

View File

@@ -407,28 +407,12 @@ jobs:
const path = require("node:path");
const packageDir = process.env.PACKAGE_DIR;
function resolveTarballFileName(value, label) {
const fileName = typeof value === "string" ? value.trim() : "";
if (
!fileName.endsWith(".tgz") ||
fileName.includes("\0") ||
fileName !== path.basename(fileName) ||
fileName !== path.win32.basename(fileName)
) {
throw new Error(`${label} must be a local .tgz filename.`);
}
return fileName;
}
const requestedFileName = process.env.INPUT_CANDIDATE_FILE_NAME.trim();
const files = fs.readdirSync(packageDir).filter((file) => file.endsWith(".tgz"));
const selectedCandidateFileName = requestedFileName || (files.length === 1 ? files[0] : "");
if (!selectedCandidateFileName) {
const candidateFileName = requestedFileName || (files.length === 1 ? files[0] : "");
if (!candidateFileName) {
throw new Error(`Expected exactly one candidate .tgz in ${packageDir}; found ${files.length}.`);
}
const candidateFileName = resolveTarballFileName(
selectedCandidateFileName,
"candidate_file_name",
);
if (!fs.existsSync(path.join(packageDir, candidateFileName))) {
throw new Error(`Provided candidate artifact does not contain ${candidateFileName}.`);
}
@@ -490,23 +474,12 @@ jobs:
run: |
node <<'NODE' >>"$GITHUB_OUTPUT"
const fs = require("node:fs");
const path = require("node:path");
function resolveTarballFileName(value, label) {
const fileName = typeof value === "string" ? value.trim() : "";
if (
!fileName.endsWith(".tgz") ||
fileName.includes("\0") ||
fileName !== path.basename(fileName) ||
fileName !== path.win32.basename(fileName)
) {
throw new Error(`${label} must be a local .tgz filename.`);
}
return fileName;
}
const payload = JSON.parse(fs.readFileSync(process.env.BASELINE_PACK_JSON, "utf8"));
const entry = Array.isArray(payload) ? payload.at(-1) : null;
const fileName = resolveTarballFileName(entry?.filename, "Baseline npm pack filename");
process.stdout.write(`file_name=${fileName}\n`);
if (!entry?.filename) {
throw new Error("Baseline npm pack did not produce a filename.");
}
process.stdout.write(`file_name=${entry.filename}\n`);
NODE
- name: Upload candidate artifact

View File

@@ -420,7 +420,6 @@ jobs:
add_suite live-cache
add_profile_suite native-live-src-agents "stable full"
add_profile_suite native-live-src-agents-zai-coding "stable full"
add_profile_suite native-live-src-gateway-core "beta minimum stable full"
add_profile_suite native-live-src-gateway-profiles-anthropic "stable full"
add_profile_suite native-live-src-gateway-profiles-anthropic-smoke "stable"
@@ -1957,12 +1956,6 @@ jobs:
timeout_minutes: 60
profile_env_only: false
profiles: stable full
- suite_id: native-live-src-agents-zai-coding
label: Native live Z.AI Coding Plan
command: ZAI_CODING_LIVE_TEST=1 node .release-harness/scripts/test-live-shard.mjs native-live-src-agents-zai-coding
timeout_minutes: 15
profile_env_only: false
profiles: stable full
- suite_id: native-live-src-gateway-core
label: Native live gateway core
command: OPENCLAW_LIVE_CODEX_HARNESS=1 OPENCLAW_LIVE_CODEX_HARNESS_AUTH=api-key node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-core
@@ -2222,11 +2215,7 @@ jobs:
case "${{ matrix.suite_id }}" in
live-cli-backend-docker)
echo "OPENCLAW_LIVE_CLI_BACKEND_MODEL=claude-cli/claude-sonnet-4-6" >> "$GITHUB_ENV"
if [[ -n "${OPENCLAW_CLAUDE_CREDENTIALS_JSON:-}" || -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]]; then
echo "OPENCLAW_LIVE_CLI_BACKEND_AUTH=subscription" >> "$GITHUB_ENV"
else
echo "OPENCLAW_LIVE_CLI_BACKEND_AUTH=api-key" >> "$GITHUB_ENV"
fi
echo "OPENCLAW_LIVE_CLI_BACKEND_AUTH=api-key" >> "$GITHUB_ENV"
echo "OPENCLAW_LIVE_CLI_BACKEND_DEBUG=1" >> "$GITHUB_ENV"
echo "OPENCLAW_CLI_BACKEND_LOG_OUTPUT=1" >> "$GITHUB_ENV"
echo "OPENCLAW_TEST_CONSOLE=1" >> "$GITHUB_ENV"
@@ -2451,11 +2440,7 @@ jobs:
case "${{ matrix.suite_id }}" in
live-cli-backend-docker)
echo "OPENCLAW_LIVE_CLI_BACKEND_MODEL=claude-cli/claude-sonnet-4-6" >> "$GITHUB_ENV"
if [[ -n "${OPENCLAW_CLAUDE_CREDENTIALS_JSON:-}" || -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]]; then
echo "OPENCLAW_LIVE_CLI_BACKEND_AUTH=subscription" >> "$GITHUB_ENV"
else
echo "OPENCLAW_LIVE_CLI_BACKEND_AUTH=api-key" >> "$GITHUB_ENV"
fi
echo "OPENCLAW_LIVE_CLI_BACKEND_AUTH=api-key" >> "$GITHUB_ENV"
echo "OPENCLAW_LIVE_CLI_BACKEND_DEBUG=1" >> "$GITHUB_ENV"
echo "OPENCLAW_CLI_BACKEND_LOG_OUTPUT=1" >> "$GITHUB_ENV"
echo "OPENCLAW_TEST_CONSOLE=1" >> "$GITHUB_ENV"

View File

@@ -223,25 +223,10 @@ jobs:
set -euo pipefail
PACK_OUTPUT="$RUNNER_TEMP/npm-pack-output.txt"
npm pack --json 2>&1 | tee "$PACK_OUTPUT"
PACK_NAME="$(node - "$PACK_OUTPUT" <<'NODE'
PACK_PATH="$(node - "$PACK_OUTPUT" <<'NODE'
const fs = require("node:fs");
const path = require("node:path");
const input = fs.readFileSync(process.argv[2], "utf8");
function resolveTarballFileName(value) {
const fileName = typeof value === "string" ? value.trim() : "";
if (
!fileName.endsWith(".tgz") ||
fileName.includes("\0") ||
fileName !== path.basename(fileName) ||
fileName !== path.win32.basename(fileName)
) {
console.error(`npm pack reported unsafe tarball filename ${JSON.stringify(fileName)}.`);
process.exit(1);
}
return fileName;
}
function arrayEndFrom(start) {
let depth = 0;
let inString = false;
@@ -281,8 +266,8 @@ jobs:
try {
const parsed = JSON.parse(input.slice(start, end));
const first = Array.isArray(parsed) ? parsed[0] : null;
if (first && Object.prototype.hasOwnProperty.call(first, "filename")) {
process.stdout.write(resolveTarballFileName(first.filename));
if (first && typeof first.filename === "string" && first.filename) {
process.stdout.write(first.filename);
process.exit(0);
}
} catch {
@@ -294,7 +279,6 @@ jobs:
process.exit(1);
NODE
)"
PACK_PATH="$PWD/$PACK_NAME"
if [[ -z "$PACK_PATH" || ! -f "$PACK_PATH" ]]; then
echo "npm pack did not produce a tarball file." >&2
exit 1
@@ -306,7 +290,7 @@ jobs:
else
RELEASE_TAG="${RELEASE_REF}"
fi
TARBALL_NAME="$PACK_NAME"
TARBALL_NAME="$(basename "$PACK_PATH")"
TARBALL_SHA256="$(sha256sum "$PACK_PATH" | awk '{print $1}')"
ARTIFACT_DIR="$RUNNER_TEMP/openclaw-npm-preflight"
rm -rf "$ARTIFACT_DIR"

View File

@@ -56,7 +56,6 @@ concurrency:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
OCM_VERSION: v0.2.15
OCM_LINUX_X64_SHA256: b849b8de5d77e97e0df9319703254ae95e29d7f26a7552ea79bf173ff110ea0a
KOVA_REPOSITORY: openclaw/Kova
PERFORMANCE_MODEL_ID: gpt-5.5
@@ -188,20 +187,11 @@ jobs:
set -euo pipefail
KOVA_SRC="${RUNNER_TEMP}/kova-src"
echo "KOVA_SRC=$KOVA_SRC" >> "$GITHUB_ENV"
mkdir -p "$HOME/.local/bin" "$(dirname "$KOVA_SRC")" "${RUNNER_TEMP}/ocm-install"
ocm_archive="${RUNNER_TEMP}/ocm-${OCM_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
curl -fsSL --proto '=https' --tlsv1.2 --retry 3 --retry-delay 1 --retry-connrefused \
-o "$ocm_archive" \
"https://github.com/shakkernerd/ocm/releases/download/${OCM_VERSION}/ocm-x86_64-unknown-linux-gnu.tar.gz"
echo "${OCM_LINUX_X64_SHA256} ${ocm_archive}" | sha256sum -c -
tar -xzf "$ocm_archive" -C "${RUNNER_TEMP}/ocm-install"
install -m 0755 "${RUNNER_TEMP}/ocm-install/ocm" "$HOME/.local/bin/ocm"
git init -b main "$KOVA_SRC"
git -C "$KOVA_SRC" remote add origin "https://github.com/${KOVA_REPOSITORY}.git"
git -C "$KOVA_SRC" fetch --filter=blob:none --depth 1 origin "$KOVA_REF"
git -C "$KOVA_SRC" checkout --detach FETCH_HEAD
mkdir -p "$HOME/.local/bin" "$(dirname "$KOVA_SRC")"
curl -fsSL https://raw.githubusercontent.com/shakkernerd/ocm/main/install.sh \
| bash -s -- --version "$OCM_VERSION" --prefix "$HOME/.local" --force
git clone --filter=blob:none "https://github.com/${KOVA_REPOSITORY}.git" "$KOVA_SRC"
git -C "$KOVA_SRC" checkout "$KOVA_REF"
cat > "$HOME/.local/bin/kova" <<EOF
#!/usr/bin/env bash
export KOVA_HOME="${KOVA_HOME}"

View File

@@ -1181,7 +1181,7 @@ jobs:
runtime_tool_coverage_release_checks:
name: Enforce QA Lab runtime tool coverage
needs: [resolve_target, qa_lab_runtime_parity_release_checks]
if: contains(fromJSON('["all","qa","qa-parity"]'), needs.resolve_target.outputs.rerun_group)
if: always() && contains(fromJSON('["all","qa","qa-parity"]'), needs.resolve_target.outputs.rerun_group)
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
@@ -1204,35 +1204,13 @@ jobs:
node-version: ${{ env.NODE_VERSION }}
install-bun: "true"
- name: Download runtime parity status
uses: actions/download-artifact@v8
with:
name: release-check-status-qa-runtime-parity-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/release-check-status/
- name: Verify runtime parity producer status
id: verify_runtime_parity_status
shell: bash
run: |
set -euo pipefail
status_path=".artifacts/release-check-status/qa_lab_runtime_parity_release_checks.env"
status="$(sed -n 's/^status=//p' "$status_path" | tail -n 1)"
if [[ "$status" != "success" ]]; then
echo "Runtime parity producer status is ${status:-missing}; skipping coverage artifact consumer."
echo "ready=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "ready=true" >> "$GITHUB_OUTPUT"
- name: Download runtime parity artifacts
if: steps.verify_runtime_parity_status.outputs.ready == 'true'
uses: actions/download-artifact@v8
with:
name: release-qa-runtime-parity-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
- name: Enforce standard runtime tool coverage
if: steps.verify_runtime_parity_status.outputs.ready == 'true'
run: |
set -euo pipefail
pnpm openclaw qa coverage \
@@ -1434,6 +1412,7 @@ jobs:
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1"
run: |
set -euo pipefail

View File

@@ -15,14 +15,6 @@ on:
description: Successful Full Release Validation run id for this tag/SHA, required when publish_openclaw_npm=true
required: false
type: string
windows_node_tag:
description: Exact openclaw-windows-node release tag, required for stable OpenClaw publish
required: false
type: string
windows_node_installer_digests:
description: Candidate-approved compact JSON map of Windows installer names to pinned sha256 digests
required: false
type: string
npm_telegram_run_id:
description: Optional successful NPM Telegram Beta E2E run id to include in final release evidence
required: false
@@ -89,15 +81,12 @@ jobs:
outputs:
sha: ${{ steps.manifest.outputs.sha || steps.ref.outputs.sha }}
preflight_artifact_name: ${{ steps.preflight_artifact.outputs.name }}
windows_node_installer_digests: ${{ steps.windows_source.outputs.installer_digests }}
steps:
- name: Validate inputs
env:
RELEASE_TAG: ${{ inputs.tag }}
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }}
WINDOWS_NODE_INSTALLER_DIGESTS: ${{ inputs.windows_node_installer_digests }}
PUBLISH_OPENCLAW_NPM: ${{ inputs.publish_openclaw_npm && 'true' || 'false' }}
PLUGIN_PUBLISH_SCOPE: ${{ inputs.plugin_publish_scope }}
PLUGINS: ${{ inputs.plugins }}
@@ -126,22 +115,6 @@ jobs:
echo "publish_openclaw_npm=true requires full_release_validation_run_id." >&2
exit 1
fi
stable_release=true
if [[ "${RELEASE_TAG}" == *"-alpha."* || "${RELEASE_TAG}" == *"-beta."* ]]; then
stable_release=false
fi
if [[ -n "${WINDOWS_NODE_TAG}" && ! "${WINDOWS_NODE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$ ]]; then
echo "windows_node_tag must be an explicit openclaw-windows-node release tag, not latest: ${WINDOWS_NODE_TAG}" >&2
exit 1
fi
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && "${stable_release}" == "true" && -z "${WINDOWS_NODE_TAG}" ]]; then
echo "Stable OpenClaw publish requires an explicit windows_node_tag." >&2
exit 1
fi
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && "${stable_release}" == "true" && -z "${WINDOWS_NODE_INSTALLER_DIGESTS}" ]]; then
echo "Stable OpenClaw publish requires candidate-approved windows_node_installer_digests." >&2
exit 1
fi
tideclaw_alpha_publish=false
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
@@ -170,73 +143,6 @@ jobs:
;;
esac
- name: Validate stable Windows source release
id: windows_source
if: ${{ inputs.publish_openclaw_npm }}
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.tag }}
WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }}
APPROVED_INSTALLER_DIGESTS: ${{ inputs.windows_node_installer_digests }}
run: |
set -euo pipefail
if [[ "${RELEASE_TAG}" == *"-alpha."* || "${RELEASE_TAG}" == *"-beta."* ]]; then
exit 0
fi
source_json="$(gh release view "${WINDOWS_NODE_TAG}" \
--repo openclaw/openclaw-windows-node \
--json tagName,isDraft,isPrerelease,assets,url)"
if [[ "$(printf '%s' "${source_json}" | jq -r '.tagName')" != "${WINDOWS_NODE_TAG}" ]]; then
echo "Windows source release tag does not match ${WINDOWS_NODE_TAG}." >&2
exit 1
fi
if [[ "$(printf '%s' "${source_json}" | jq -r '.isDraft')" == "true" ]]; then
echo "Stable OpenClaw publish requires a published Windows source release." >&2
exit 1
fi
if [[ "$(printf '%s' "${source_json}" | jq -r '.isPrerelease')" == "true" ]]; then
echo "Stable OpenClaw publish requires a non-prerelease Windows source release." >&2
exit 1
fi
required_assets=(
"OpenClawCompanion-Setup-x64.exe"
"OpenClawCompanion-Setup-arm64.exe"
)
required_assets_json="$(printf '%s\n' "${required_assets[@]}" | jq -R . | jq -sc .)"
if ! approved_installer_digests="$(printf '%s' "${APPROVED_INSTALLER_DIGESTS}" | jq -ce --argjson names "${required_assets_json}" '
if type == "object" and
(keys | sort) == ($names | sort) and
all(.[]; type == "string" and test("^sha256:[a-f0-9]{64}$"))
then .
else error("invalid candidate-approved Windows installer digest map")
end
')"; then
echo "windows_node_installer_digests must contain exactly the candidate-approved current installer asset contract." >&2
exit 1
fi
for asset_name in "${required_assets[@]}"; do
asset_matches="$(printf '%s' "${source_json}" | jq -c --arg name "${asset_name}" '[.assets[]? | select(.name == $name)]')"
asset_match_count="$(printf '%s' "${asset_matches}" | jq 'length')"
if [[ "${asset_match_count}" != "1" ]]; then
echo "Windows source release ${WINDOWS_NODE_TAG} must contain exactly one required asset ${asset_name}; found ${asset_match_count}." >&2
exit 1
fi
asset_digest="$(printf '%s' "${asset_matches}" | jq -r '.[0].digest // empty')"
if [[ ! "${asset_digest}" =~ ^sha256:[a-f0-9]{64}$ ]]; then
echo "Windows source release ${WINDOWS_NODE_TAG} asset ${asset_name} is missing its immutable SHA-256 digest." >&2
exit 1
fi
approved_digest="$(printf '%s' "${approved_installer_digests}" | jq -r --arg name "${asset_name}" '.[$name]')"
if [[ "${asset_digest}" != "${approved_digest}" ]]; then
echo "Windows source release ${WINDOWS_NODE_TAG} asset ${asset_name} no longer matches its candidate-approved digest." >&2
exit 1
fi
done
echo "installer_digests=${approved_installer_digests}" >> "$GITHUB_OUTPUT"
echo "- Windows Node source release: prevalidated \`${WINDOWS_NODE_TAG}\`" >> "$GITHUB_STEP_SUMMARY"
- name: Download OpenClaw npm preflight manifest
id: preflight_artifact
if: ${{ inputs.publish_openclaw_npm }}
@@ -431,7 +337,6 @@ jobs:
TARGET_SHA: ${{ steps.manifest.outputs.sha || steps.ref.outputs.sha }}
RELEASE_PROFILE: ${{ steps.full_manifest.outputs.release_profile || inputs.release_profile }}
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }}
run: |
{
echo "### Release target"
@@ -442,16 +347,13 @@ jobs:
if [[ -n "${FULL_RELEASE_VALIDATION_RUN_ID// }" ]]; then
echo "- Full release validation: \`${FULL_RELEASE_VALIDATION_RUN_ID}\`"
fi
if [[ -n "${WINDOWS_NODE_TAG// }" ]]; then
echo "- Windows Node source release: \`${WINDOWS_NODE_TAG}\`"
fi
} >> "$GITHUB_STEP_SUMMARY"
publish:
name: Publish plugins, then OpenClaw
needs: [resolve_release_target]
runs-on: ubuntu-latest
timeout-minutes: 120
timeout-minutes: 60
environment: npm-release
steps:
- name: Checkout release SHA
@@ -481,16 +383,10 @@ jobs:
WAIT_FOR_CLAWHUB: ${{ inputs.wait_for_clawhub && 'true' || 'false' }}
PREFLIGHT_ARTIFACT_NAME: ${{ needs.resolve_release_target.outputs.preflight_artifact_name }}
NPM_TELEGRAM_RUN_ID: ${{ inputs.npm_telegram_run_id }}
WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }}
WINDOWS_NODE_INSTALLER_DIGESTS: ${{ needs.resolve_release_target.outputs.windows_node_installer_digests }}
POSTPUBLISH_EVIDENCE_DIR: ${{ runner.temp }}/openclaw-release-postpublish-evidence
run: |
set -euo pipefail
is_stable_release() {
[[ "${RELEASE_TAG}" != *"-alpha."* && "${RELEASE_TAG}" != *"-beta."* ]]
}
dispatch_workflow_at_ref() {
local workflow_ref="$1"
shift
@@ -940,105 +836,10 @@ jobs:
}
publish_github_release() {
if is_stable_release; then
verify_windows_release_asset_contract
fi
gh release edit "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --draft=false
echo "- GitHub release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY"
}
verify_windows_release_asset_contract() {
local actual_companion_assets actual_digest asset_name expected_companion_assets expected_digest expected_hash expected_installer_names manifest_dir manifest_json manifest_path release_json
# Add future promoted installer names, such as MSIX x64/ARM64, here.
local -a installer_assets=(
"OpenClawCompanion-Setup-x64.exe"
"OpenClawCompanion-Setup-arm64.exe"
)
local -a required_assets=(
"${installer_assets[@]}"
"OpenClawCompanion-SHA256SUMS.txt"
)
release_json="$(gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --json assets,url)"
expected_companion_assets="$(printf '%s\n' "${required_assets[@]}" | jq -R . | jq -sc 'sort')"
actual_companion_assets="$(printf '%s' "${release_json}" | jq -c '
[.assets[]? | select(.name | startswith("OpenClawCompanion-")) | .name] | sort
')"
if [[ "${actual_companion_assets}" != "${expected_companion_assets}" ]]; then
echo "Stable release OpenClawCompanion asset names do not exactly match the current contract." >&2
return 1
fi
for asset_name in "${required_assets[@]}"; do
if ! printf '%s' "${release_json}" | jq -e --arg name "${asset_name}" 'any(.assets[]?; .name == $name)' >/dev/null; then
echo "Stable release is missing required Windows asset ${asset_name}." >&2
return 1
fi
done
manifest_dir="${RUNNER_TEMP}/openclaw-windows-release-contract"
manifest_path="${manifest_dir}/OpenClawCompanion-SHA256SUMS.txt"
rm -rf "${manifest_dir}"
mkdir -p "${manifest_dir}"
gh release download "${RELEASE_TAG}" \
--repo "$GITHUB_REPOSITORY" \
--pattern "OpenClawCompanion-SHA256SUMS.txt" \
--dir "${manifest_dir}"
if ! manifest_json="$(jq -Rsc '
split("\n") as $lines |
(if $lines[-1] == "" then $lines[0:-1] else $lines end) |
map(sub("\r$"; "")) |
if all(.[]; test("^(?<hash>[a-f0-9]{64}) (?<name>[^/\\\\]+)$"))
then map(capture("^(?<hash>[a-f0-9]{64}) (?<name>[^/\\\\]+)$"))
else error("malformed Windows checksum manifest entry")
end
' "${manifest_path}")"; then
echo "Stable release Windows checksum manifest contains malformed entries." >&2
return 1
fi
expected_installer_names="$(printf '%s\n' "${installer_assets[@]}" | jq -R . | jq -sc 'sort')"
if ! printf '%s' "${manifest_json}" | jq -e --argjson expected "${expected_installer_names}" '
length == ($expected | length) and
([.[].name] | sort) == $expected and
([.[].name] | unique | length) == length
' >/dev/null; then
echo "Stable release Windows checksum manifest does not exactly match the installer asset contract." >&2
return 1
fi
for asset_name in "${installer_assets[@]}"; do
expected_digest="$(printf '%s' "${WINDOWS_NODE_INSTALLER_DIGESTS}" | jq -r --arg name "${asset_name}" '.[$name] // empty')"
actual_digest="$(printf '%s' "${release_json}" | jq -r --arg name "${asset_name}" '.assets[]? | select(.name == $name) | .digest // empty')"
if [[ -z "${expected_digest}" || "${actual_digest}" != "${expected_digest}" ]]; then
echo "Stable release Windows asset ${asset_name} does not match its pinned digest." >&2
return 1
fi
expected_hash="${expected_digest#sha256:}"
if ! printf '%s' "${manifest_json}" | jq -e --arg name "${asset_name}" --arg hash "${expected_hash}" '
any(.[]; .name == $name and .hash == $hash)
' >/dev/null; then
echo "Stable release Windows checksum manifest does not match pinned digest for ${asset_name}." >&2
return 1
fi
done
echo "- Windows Hub asset contract: verified" >> "$GITHUB_STEP_SUMMARY"
}
promote_windows_release_assets() {
if ! is_stable_release; then
return 0
fi
if [[ -z "${WINDOWS_NODE_INSTALLER_DIGESTS// }" ]]; then
echo "Stable release is missing prevalidated Windows installer digests." >&2
return 1
fi
windows_node_run_id="$(dispatch_workflow windows-node-release.yml \
-f tag="${RELEASE_TAG}" \
-f windows_node_tag="${WINDOWS_NODE_TAG}" \
-f expected_installer_digests="${WINDOWS_NODE_INSTALLER_DIGESTS}")"
echo "- Windows Node release run ID: \`${windows_node_run_id}\`" >> "$GITHUB_STEP_SUMMARY"
wait_for_run windows-node-release.yml "${windows_node_run_id}"
}
upload_dependency_evidence_release_asset() {
local release_version download_dir asset_path asset_name artifact_name
release_version="${RELEASE_TAG#v}"
@@ -1112,14 +913,13 @@ jobs:
}
append_release_proof_to_github_release() {
local release_version body_file notes_file evidence_path tarball integrity telegram_line clawhub_line clawhub_bootstrap_line clawhub_runtime_state_path windows_line
local release_version body_file notes_file tarball integrity telegram_line clawhub_line clawhub_bootstrap_line clawhub_runtime_state_path
release_version="${RELEASE_TAG#v}"
body_file="${RUNNER_TEMP}/release-body.md"
notes_file="${RUNNER_TEMP}/release-notes-with-proof.md"
evidence_path="${POSTPUBLISH_EVIDENCE_DIR}/release-postpublish-evidence.json"
tarball="$(jq -er '.openclawNpmTarball | select(type == "string" and length > 0)' "${evidence_path}")"
integrity="$(jq -er '.openclawNpmIntegrity | select(type == "string" and length > 0)' "${evidence_path}")"
tarball="$(npm view "openclaw@${release_version}" dist.tarball --json | jq -r '.')"
integrity="$(npm view "openclaw@${release_version}" dist.integrity --json | jq -r '.')"
gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --json body --jq .body > "${body_file}"
if [[ -n "${NPM_TELEGRAM_RUN_ID// }" ]]; then
@@ -1131,10 +931,6 @@ jobs:
write_clawhub_runtime_state false "${clawhub_runtime_state_path}"
clawhub_line="$(jq -r '.proofLines.normal' "${clawhub_runtime_state_path}")"
clawhub_bootstrap_line="$(jq -r '.proofLines.bootstrap' "${clawhub_runtime_state_path}")"
windows_line=""
if [[ -n "${windows_node_run_id// }" ]]; then
windows_line="- Windows Hub promotion: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${windows_node_run_id} from openclaw/openclaw-windows-node@${WINDOWS_NODE_TAG}"
fi
RELEASE_BODY_FILE="${body_file}" \
RELEASE_NOTES_FILE="${notes_file}" \
@@ -1152,7 +948,6 @@ jobs:
CLAWHUB_LINE="${clawhub_line}" \
CLAWHUB_BOOTSTRAP_LINE="${clawhub_bootstrap_line}" \
TELEGRAM_LINE="${telegram_line}" \
WINDOWS_LINE="${windows_line}" \
node --input-type=module <<'NODE'
import { readFileSync, writeFileSync } from "node:fs";
@@ -1179,7 +974,6 @@ jobs:
process.env.CLAWHUB_BOOTSTRAP_LINE,
`- OpenClaw npm publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.OPENCLAW_NPM_RUN_ID}`,
process.env.TELEGRAM_LINE,
...(process.env.WINDOWS_LINE ? [process.env.WINDOWS_LINE] : []),
].join("\n");
const withoutOldProof = body.replace(/\n?### Release verification\n[\s\S]*?(?=\n### |\n## |$)/, "");
@@ -1204,9 +998,6 @@ jobs:
else
echo "- OpenClaw npm publish: skipped by input"
fi
if is_stable_release && [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
echo "- Windows Hub promotion: required before the GitHub release can be published"
fi
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
echo "- Workflow completion waits for ClawHub"
else
@@ -1351,7 +1142,6 @@ jobs:
failed=0
openclaw_failed=0
windows_node_run_id=""
if [[ -n "${openclaw_pid}" ]] && ! wait "${openclaw_pid}"; then
failed=1
openclaw_failed=1
@@ -1382,9 +1172,6 @@ jobs:
fi
create_or_update_github_release
upload_dependency_evidence_release_asset
if ! promote_windows_release_assets; then
failed=1
fi
append_release_proof_to_github_release
if [[ "${failed}" == "0" ]]; then
publish_github_release

View File

@@ -344,11 +344,7 @@ jobs:
OPENCLAW_EXTENSION_BATCH_PARALLEL: 2
OPENCLAW_VITEST_MAX_WORKERS: 1
OPENCLAW_EXTENSION_BATCH: ${{ matrix.extensions_csv }}
run: |
pnpm test:extensions:batch "$OPENCLAW_EXTENSION_BATCH" -- \
--exclude extensions/codex/src/app-server/run-attempt.test.ts \
--exclude extensions/codex/src/app-server/run-attempt.dynamic-tools.test.ts \
--exclude extensions/codex/src/app-server/run-attempt.turn-watches.test.ts
run: pnpm test:extensions:batch "$OPENCLAW_EXTENSION_BATCH" -- --exclude extensions/codex/src/app-server/run-attempt.test.ts
plugin-prerelease-inspector:
permissions:

View File

@@ -532,6 +532,7 @@ jobs:
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1"
INPUT_SCENARIO: ${{ github.event_name == 'workflow_dispatch' && inputs.scenario || '' }}
run: |
set -euo pipefail

View File

@@ -1,114 +0,0 @@
name: Security Sensitive Guard
on:
pull_request_target: # zizmor: ignore[dangerous-triggers] checks trusted base script only; never checks out PR head
types: [opened, reopened, synchronize, ready_for_review]
permissions:
contents: read
pull-requests: write
issues: write
env:
# Temporary rollout bridge for PRs opened before this workflow's script landed.
# Remove once the pre-rollout PR set has drained.
OPENCLAW_SECURITY_SENSITIVE_GUARD_ROLLOUT_SHA: 5d9c010628ea4de3492a12e32f9be5b8c5dfa9ed
concurrency:
group: security-sensitive-guard-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
security-sensitive-guard-detect:
if: ${{ !github.event.pull_request.draft }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Check security-sensitive guard rollout eligibility
id: rollout
env:
GH_TOKEN: ${{ github.token }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
status="$(
gh api \
"repos/${GITHUB_REPOSITORY}/compare/${OPENCLAW_SECURITY_SENSITIVE_GUARD_ROLLOUT_SHA}...${PR_BASE_SHA}" \
--jq '.status'
)"
case "$status" in
ahead|identical)
echo "ready=true" >> "$GITHUB_OUTPUT"
;;
behind|diverged)
echo "ready=false" >> "$GITHUB_OUTPUT"
echo "::notice::Skipping security-sensitive guard for a PR base that predates rollout commit ${OPENCLAW_SECURITY_SENSITIVE_GUARD_ROLLOUT_SHA}."
;;
*)
echo "Unexpected compare status for security-sensitive guard rollout: $status" >&2
exit 1
;;
esac
- name: Check out trusted base workflow scripts
if: steps.rollout.outputs.ready == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ github.workflow_sha }}
persist-credentials: false
- name: Detect security-sensitive changes
if: steps.rollout.outputs.ready == 'true'
env:
GITHUB_TOKEN: ${{ github.token }}
OPENCLAW_SECURITY_APPROVERS: vincentkoc,steipete,joshavant
OPENCLAW_SECURITY_SENSITIVE_GUARD_MODE: detect
OPENCLAW_SECURITY_TEAM_SLUG: openclaw-secops
run: node scripts/github/security-sensitive-guard.mjs
security-sensitive-guard:
if: ${{ !github.event.pull_request.draft && always() }}
needs:
- security-sensitive-guard-detect
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Check security-sensitive guard rollout eligibility
id: rollout
env:
GH_TOKEN: ${{ github.token }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
status="$(
gh api \
"repos/${GITHUB_REPOSITORY}/compare/${OPENCLAW_SECURITY_SENSITIVE_GUARD_ROLLOUT_SHA}...${PR_BASE_SHA}" \
--jq '.status'
)"
case "$status" in
ahead|identical)
echo "ready=true" >> "$GITHUB_OUTPUT"
;;
behind|diverged)
echo "ready=false" >> "$GITHUB_OUTPUT"
echo "::notice::Skipping security-sensitive guard for a PR base that predates rollout commit ${OPENCLAW_SECURITY_SENSITIVE_GUARD_ROLLOUT_SHA}."
;;
*)
echo "Unexpected compare status for security-sensitive guard rollout: $status" >&2
exit 1
;;
esac
- name: Check out trusted base workflow scripts
if: steps.rollout.outputs.ready == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ github.workflow_sha }}
persist-credentials: false
- name: Enforce security-sensitive guard
if: steps.rollout.outputs.ready == 'true'
env:
GITHUB_TOKEN: ${{ github.token }}
OPENCLAW_SECURITY_APPROVERS: vincentkoc,steipete,joshavant
OPENCLAW_SECURITY_SENSITIVE_GUARD_MODE: enforce
OPENCLAW_SECURITY_TEAM_SLUG: openclaw-secops
run: node scripts/github/security-sensitive-guard.mjs

View File

@@ -68,7 +68,7 @@ jobs:
days-before-pr-close: 7
stale-issue-label: stale
stale-pr-label: stale
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle,clawsweeper:queueable-fix,clawsweeper:source-repro,clawsweeper:fix-shape-clear
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle
exempt-pr-labels: maintainer,no-stale,bad-barnacle
operations-per-run: 2000
ascending: true
@@ -100,7 +100,7 @@ jobs:
days-before-pr-stale: -1
days-before-pr-close: -1
stale-issue-label: stale
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle,clawsweeper:queueable-fix,clawsweeper:source-repro,clawsweeper:fix-shape-clear
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle
operations-per-run: 2000
ascending: true
include-only-assigned: true
@@ -172,7 +172,7 @@ jobs:
days-before-pr-close: 7
stale-issue-label: stale
stale-pr-label: stale
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle,clawsweeper:queueable-fix,clawsweeper:source-repro,clawsweeper:fix-shape-clear
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle
exempt-pr-labels: maintainer,no-stale,bad-barnacle
operations-per-run: 2000
ascending: true
@@ -203,7 +203,7 @@ jobs:
days-before-pr-stale: -1
days-before-pr-close: -1
stale-issue-label: stale
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle,clawsweeper:queueable-fix,clawsweeper:source-repro,clawsweeper:fix-shape-clear
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle
operations-per-run: 2000
ascending: true
include-only-assigned: true
@@ -277,9 +277,6 @@ jobs:
"security",
"no-stale",
"bad-barnacle",
"clawsweeper:queueable-fix",
"clawsweeper:source-repro",
"clawsweeper:fix-shape-clear",
]);
const prExemptLabels = new Set(["maintainer", "no-stale", "bad-barnacle"]);
const maintainerAssociations = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);

View File

@@ -65,9 +65,7 @@ jobs:
fi
runner_ssh_port="${BLACKSMITH_SSH_PORT:-22}"
hydrating_response="$RUNNER_TEMP/testbox-hydrating.response"
hydrating_http_code="$(curl -sS -L --post302 --post303 -o "$hydrating_response" -w '%{http_code}' \
-X POST "${api_url}/api/testbox/phone-home" \
response="$(curl -s -f -L --post302 --post303 -X POST "${api_url}/api/testbox/phone-home" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${auth_token}" \
-d "{
@@ -79,15 +77,7 @@ jobs:
\"working_directory\": \"${GITHUB_WORKSPACE}\",
\"adopted_run_id\": \"${GITHUB_RUN_ID}\",
\"metadata\": {}
}" || true)"
echo "phone_home_hydrating_http=${hydrating_http_code}"
if [[ ! "$hydrating_http_code" =~ ^2 ]]; then
echo "Blacksmith phone-home hydrating failed; response body:" >&2
cat "$hydrating_response" >&2 || true
exit 1
fi
response="$(cat "$hydrating_response")"
}" 2>/dev/null || true)"
echo "$TESTBOX_ID" > "$state/testbox_id"
echo "$installation_model_id" > "$state/installation_model_id"
@@ -110,14 +100,12 @@ jobs:
fi
ssh_public_key="$(cat "$state/ssh_public_key" 2>/dev/null || true)"
if [ -z "$ssh_public_key" ]; then
echo "Blacksmith phone-home did not return an SSH public key; testbox cannot accept CLI connections." >&2
exit 1
if [ -n "$ssh_public_key" ]; then
mkdir -p ~/.ssh
printf '%s\n' "$ssh_public_key" >> ~/.ssh/authorized_keys
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
fi
mkdir -p ~/.ssh
printf '%s\n' "$ssh_public_key" >> ~/.ssh/authorized_keys
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
- name: Checkout
uses: actions/checkout@v6
@@ -173,11 +161,6 @@ jobs:
-H "Authorization: Bearer ${auth_token}" \
--data-binary @"$ready_body" || true)"
echo "phone_home_ready_http=${http_code}"
if [[ ! "$http_code" =~ ^2 ]]; then
echo "Blacksmith phone-home ready failed; response body:" >&2
cat "$RUNNER_TEMP/testbox-ready.response" >&2 || true
exit 1
fi
echo "============================================"
echo "Testbox ready!"

View File

@@ -8,12 +8,9 @@ on:
required: true
type: string
windows_node_tag:
description: Exact openclaw-windows-node release tag to promote, for example v0.6.3
required: true
type: string
expected_installer_digests:
description: Compact JSON map of installer asset names to pinned source sha256 digests
description: openclaw-windows-node release tag to promote, or latest
required: true
default: latest
type: string
permissions:
@@ -34,129 +31,46 @@ jobs:
env:
RELEASE_TAG: ${{ inputs.tag }}
WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }}
EXPECTED_INSTALLER_DIGESTS: ${{ inputs.expected_installer_digests }}
GH_TOKEN: ${{ github.token }}
run: |
if ($env:RELEASE_TAG -notmatch '^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-(alpha|beta)\.[1-9][0-9]*)|(-[1-9][0-9]*))?$') {
throw "Invalid OpenClaw release tag: $env:RELEASE_TAG"
}
$stableRelease = -not (
$env:RELEASE_TAG.Contains("-alpha.") -or
$env:RELEASE_TAG.Contains("-beta.")
)
if ($env:WINDOWS_NODE_TAG -notmatch '^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$') {
throw "windows_node_tag must be an explicit openclaw-windows-node release tag, not latest: $env:WINDOWS_NODE_TAG"
}
try {
$expectedDigests = $env:EXPECTED_INSTALLER_DIGESTS | ConvertFrom-Json -AsHashtable
} catch {
throw "expected_installer_digests must be a JSON object: $_"
}
# Add future signed installer names, such as MSIX x64/ARM64, here.
$requiredInstallerNames = @(
"OpenClawCompanion-Setup-x64.exe",
"OpenClawCompanion-Setup-arm64.exe"
)
$allowedTargetCompanionAssetNames = @(
$requiredInstallerNames
"OpenClawCompanion-SHA256SUMS.txt"
)
if ($expectedDigests.Count -ne $requiredInstallerNames.Count) {
throw "expected_installer_digests must contain exactly the current installer asset contract."
}
foreach ($name in $requiredInstallerNames) {
$digest = [string]$expectedDigests[$name]
if ($digest -notmatch '^sha256:[A-Fa-f0-9]{64}$') {
throw "expected_installer_digests is missing a valid pinned digest for $name."
}
}
$targetRelease = gh release view $env:RELEASE_TAG --repo $env:GITHUB_REPOSITORY --json tagName,isDraft,isPrerelease,assets,url | ConvertFrom-Json
if ($targetRelease.tagName -ne $env:RELEASE_TAG) {
throw "OpenClaw release tag mismatch: expected $env:RELEASE_TAG, got $($targetRelease.tagName)"
}
$unexpectedTargetCompanionAssets = @(
$targetRelease.assets |
Where-Object {
$_.name.StartsWith("OpenClawCompanion-") -and
$_.name -notin $allowedTargetCompanionAssetNames
} |
ForEach-Object name |
Sort-Object
)
if ($unexpectedTargetCompanionAssets.Count -ne 0) {
throw "Target OpenClaw release contains unexpected OpenClawCompanion assets before upload: $($unexpectedTargetCompanionAssets -join ', ')"
}
$sourceRelease = gh release view $env:WINDOWS_NODE_TAG --repo openclaw/openclaw-windows-node --json tagName,isDraft,isPrerelease,assets,url | ConvertFrom-Json
if ($sourceRelease.tagName -ne $env:WINDOWS_NODE_TAG) {
throw "Windows source release tag mismatch: expected $env:WINDOWS_NODE_TAG, got $($sourceRelease.tagName)"
}
if ($sourceRelease.isDraft) {
throw "Windows source release must be published: $($sourceRelease.url)"
}
if ($stableRelease -and $sourceRelease.isPrerelease) {
throw "Stable OpenClaw releases require a non-prerelease Windows source release: $($sourceRelease.url)"
}
foreach ($name in $requiredInstallerNames) {
$sourceAssets = @($sourceRelease.assets | Where-Object name -eq $name)
if ($sourceAssets.Count -ne 1) {
throw "Windows source release must contain exactly one required asset $name; found $($sourceAssets.Count)."
}
if ([string]$sourceAssets[0].digest -ne [string]$expectedDigests[$name]) {
throw "Windows source release asset digest does not match the pinned digest: $name"
}
if ($env:WINDOWS_NODE_TAG -ne "latest" -and $env:WINDOWS_NODE_TAG -notmatch '^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$') {
throw "Invalid openclaw-windows-node release tag: $env:WINDOWS_NODE_TAG"
}
gh release view $env:RELEASE_TAG --repo $env:GITHUB_REPOSITORY | Out-Null
- name: Download Windows Hub release installers
shell: pwsh
env:
WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }}
EXPECTED_INSTALLER_DIGESTS: ${{ inputs.expected_installer_digests }}
GH_TOKEN: ${{ github.token }}
run: |
New-Item -ItemType Directory -Force -Path dist | Out-Null
# Add future signed installer patterns, such as MSIX x64/ARM64, here.
# Every matched installer is signature-checked, checksummed, and promoted.
$installerPatterns = @(
"OpenClawCompanion-Setup-x64.exe",
"OpenClawCompanion-Setup-arm64.exe"
)
$downloadArgs = @(
$env:WINDOWS_NODE_TAG,
"--repo", "openclaw/openclaw-windows-node",
"--dir", "dist"
)
foreach ($pattern in $installerPatterns) {
$downloadArgs += @("--pattern", $pattern)
}
gh release download @downloadArgs
if ($LASTEXITCODE -ne 0) {
throw "Failed to download Windows release assets from $env:WINDOWS_NODE_TAG."
$tagArgs = @()
if ($env:WINDOWS_NODE_TAG -ne "latest") {
$tagArgs += $env:WINDOWS_NODE_TAG
}
gh release download @tagArgs `
--repo openclaw/openclaw-windows-node `
--pattern "OpenClawCompanion-Setup-*.exe" `
--dir dist
foreach ($pattern in $installerPatterns) {
$patternMatches = @(Get-ChildItem -LiteralPath dist -File | Where-Object Name -Like $pattern)
if ($patternMatches.Count -ne 1) {
throw "Expected exactly one Windows installer matching '$pattern', found $($patternMatches.Count)."
}
}
$expectedDigests = $env:EXPECTED_INSTALLER_DIGESTS | ConvertFrom-Json -AsHashtable
foreach ($file in Get-ChildItem -LiteralPath dist -File) {
$expectedHash = ([string]$expectedDigests[$file.Name]) -replace '^sha256:', ''
$actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $file.FullName).Hash
if ($actualHash -ne $expectedHash) {
throw "Downloaded Windows source asset does not match pinned digest: $($file.Name)"
$expected = @(
"dist/OpenClawCompanion-Setup-x64.exe",
"dist/OpenClawCompanion-Setup-arm64.exe"
)
foreach ($file in $expected) {
if (-not (Test-Path -LiteralPath $file)) {
throw "Missing expected Windows installer: $file"
}
}
- name: Verify Authenticode signatures
shell: pwsh
run: |
$expectedSignerSubject = "CN=OpenClaw Foundation, O=OpenClaw Foundation, L=Mill Valley, S=California, C=US"
Get-ChildItem -LiteralPath dist -File | ForEach-Object {
Get-ChildItem -LiteralPath dist -Filter "OpenClawCompanion-Setup-*.exe" | ForEach-Object {
$signature = Get-AuthenticodeSignature -LiteralPath $_.FullName
if ($signature.Status -ne "Valid") {
throw "$($_.Name) Authenticode signature was $($signature.Status)."
@@ -164,9 +78,6 @@ jobs:
if (-not $signature.SignerCertificate) {
throw "$($_.Name) has no signer certificate."
}
if ($signature.SignerCertificate.Subject -ne $expectedSignerSubject) {
throw "$($_.Name) has unexpected signer subject $($signature.SignerCertificate.Subject)."
}
[pscustomobject]@{
File = $_.Name
Signer = $signature.SignerCertificate.Subject
@@ -177,7 +88,7 @@ jobs:
- name: Write SHA-256 manifest
shell: pwsh
run: |
Get-ChildItem -LiteralPath dist -File |
Get-ChildItem -LiteralPath dist -Filter "OpenClawCompanion-Setup-*.exe" |
Sort-Object Name |
ForEach-Object {
$hash = Get-FileHash -Algorithm SHA256 -LiteralPath $_.FullName
@@ -190,81 +101,12 @@ jobs:
RELEASE_TAG: ${{ inputs.tag }}
GH_TOKEN: ${{ github.token }}
run: |
$releaseAssets = @(Get-ChildItem -LiteralPath dist -File | Sort-Object Name | ForEach-Object FullName)
gh release upload $env:RELEASE_TAG @releaseAssets --repo $env:GITHUB_REPOSITORY --clobber
if ($LASTEXITCODE -ne 0) {
throw "Failed to upload Windows release assets to $env:RELEASE_TAG."
}
- name: Verify promoted release asset contract
shell: pwsh
env:
RELEASE_TAG: ${{ inputs.tag }}
GH_TOKEN: ${{ github.token }}
run: |
New-Item -ItemType Directory -Force -Path verified | Out-Null
$expectedAssets = @(Get-ChildItem -LiteralPath dist -File | Sort-Object Name)
$expectedCompanionAssetNames = @($expectedAssets | ForEach-Object Name | Sort-Object)
$targetRelease = gh release view $env:RELEASE_TAG --repo $env:GITHUB_REPOSITORY --json assets | ConvertFrom-Json
$actualCompanionAssetNames = @(
$targetRelease.assets |
Where-Object { $_.name.StartsWith("OpenClawCompanion-") } |
ForEach-Object name |
Sort-Object
)
$assetContractDiff = @(
Compare-Object `
-ReferenceObject $expectedCompanionAssetNames `
-DifferenceObject $actualCompanionAssetNames
)
if (
$actualCompanionAssetNames.Count -ne $expectedCompanionAssetNames.Count -or
$assetContractDiff.Count -ne 0
) {
throw "Promoted OpenClawCompanion asset names do not exactly match the current contract."
}
foreach ($asset in $expectedAssets) {
gh release download $env:RELEASE_TAG `
--repo $env:GITHUB_REPOSITORY `
--pattern $asset.Name `
--dir verified
if ($LASTEXITCODE -ne 0) {
throw "Failed to download promoted Windows release asset $($asset.Name)."
}
}
$manifestPath = "verified/OpenClawCompanion-SHA256SUMS.txt"
$manifestEntries = @(Get-Content -LiteralPath $manifestPath | ForEach-Object {
if ($_ -notmatch '^([A-Fa-f0-9]{64}) ([^\\/]+)$') {
throw "Invalid Windows SHA-256 manifest entry: $_"
}
[PSCustomObject]@{
Hash = $Matches[1]
Name = $Matches[2]
}
})
$expectedInstallerNames = @(
$expectedAssets |
Where-Object Name -ne "OpenClawCompanion-SHA256SUMS.txt" |
ForEach-Object Name
)
$manifestInstallerNames = @($manifestEntries | ForEach-Object Name | Sort-Object)
$contractDiff = @(
Compare-Object `
-ReferenceObject $expectedInstallerNames `
-DifferenceObject $manifestInstallerNames
)
if ($contractDiff.Count -ne 0) {
throw "Promoted Windows SHA-256 manifest does not match the installer asset contract."
}
foreach ($entry in $manifestEntries) {
$hash = (Get-FileHash -Algorithm SHA256 -LiteralPath "verified/$($entry.Name)").Hash
if ($hash -ne $entry.Hash) {
throw "Promoted Windows release asset checksum mismatch: $($entry.Name)"
}
}
gh release upload $env:RELEASE_TAG `
dist/OpenClawCompanion-Setup-x64.exe `
dist/OpenClawCompanion-Setup-arm64.exe `
dist/OpenClawCompanion-SHA256SUMS.txt `
--repo $env:GITHUB_REPOSITORY `
--clobber
- name: Summary
shell: pwsh
@@ -277,9 +119,8 @@ jobs:
OpenClaw release: $env:RELEASE_TAG
Source release: openclaw/openclaw-windows-node@$env:WINDOWS_NODE_TAG
- https://github.com/openclaw/openclaw/releases/download/$env:RELEASE_TAG/OpenClawCompanion-Setup-x64.exe
- https://github.com/openclaw/openclaw/releases/download/$env:RELEASE_TAG/OpenClawCompanion-Setup-arm64.exe
- https://github.com/openclaw/openclaw/releases/download/$env:RELEASE_TAG/OpenClawCompanion-SHA256SUMS.txt
"@ >> $env:GITHUB_STEP_SUMMARY
Get-ChildItem -LiteralPath dist -File |
Sort-Object Name |
ForEach-Object {
"- https://github.com/openclaw/openclaw/releases/download/$env:RELEASE_TAG/$($_.Name)"
} >> $env:GITHUB_STEP_SUMMARY

View File

@@ -133,9 +133,8 @@ jobs:
$rootfs = "C:\wsl\ubuntu-noble-wsl.rootfs.tar.gz"
New-Item -ItemType Directory -Force -Path @((Split-Path -Parent $rootfs), $wslRoot) | Out-Null
Invoke-WebRequest -Uri $env:UBUNTU_WSL_ROOTFS_URL -OutFile $rootfs -UseBasicParsing
$import = Invoke-WslText -Arguments @("--import", "UbuntuProbe", $wslRoot, $rootfs, "--version", "2")
Write-Host $import.Text
Write-Host "wsl_import_exit=$($import.Code)"
wsl.exe --import UbuntuProbe $wslRoot $rootfs --version 2
Write-Host "wsl_import_exit=$LASTEXITCODE"
$list = Invoke-WslText -Arguments @("--list", "--verbose")
Write-Host $list.Text
Write-Host "wsl_list_after_import_exit=$($list.Code)"
@@ -145,15 +144,14 @@ jobs:
if ($distros.Count -gt 0) {
$distro = $distros[0]
Write-Host "wsl_probe_distro=$distro"
$exec = Invoke-WslText -Arguments @("-d", $distro, "--exec", "bash", "-lc", 'set -euo pipefail; uname -a; if [ -f /etc/os-release ]; then sed -n "1,8p" /etc/os-release; fi')
wsl.exe -d $distro --exec bash -lc 'set -euo pipefail; uname -a; if [ -f /etc/os-release ]; then sed -n "1,8p" /etc/os-release; fi'
} else {
$exec = Invoke-WslText -Arguments @("--exec", "bash", "-lc", 'set -euo pipefail; uname -a; if [ -f /etc/os-release ]; then sed -n "1,8p" /etc/os-release; fi')
wsl.exe --exec bash -lc 'set -euo pipefail; uname -a; if [ -f /etc/os-release ]; then sed -n "1,8p" /etc/os-release; fi'
}
Write-Host $exec.Text
if ($exec.Code -eq 0) {
if ($LASTEXITCODE -eq 0) {
$ok = $true
}
Write-Host "wsl_exec_exit=$($exec.Code)"
Write-Host "wsl_exec_exit=$LASTEXITCODE"
}
if ($ok) {

View File

@@ -251,6 +251,3 @@ jobs:
- name: Check plugin SDK API baseline drift
run: pnpm plugin-sdk:api:check
- name: Check plugin SDK surface budget
run: pnpm plugin-sdk:surface:check

9
.gitignore vendored
View File

@@ -77,19 +77,12 @@ extensions/canvas/src/host/a2ui/*.map
# fastlane (iOS)
apps/ios/fastlane/README.md
apps/android/fastlane/README.md
apps/ios/fastlane/report.xml
apps/ios/fastlane/Preview.html
apps/ios/fastlane/screenshots/
apps/ios/fastlane/test_output/
apps/ios/fastlane/logs/
apps/ios/fastlane/.env
apps/android/fastlane/report.xml
apps/android/fastlane/Preview.html
apps/android/fastlane/test_output/
apps/android/fastlane/logs/
apps/android/fastlane/.env
apps/android/fastlane/metadata/android/**/images/
# fastlane build artifacts (local)
apps/ios/*.ipa
@@ -134,8 +127,6 @@ mantis/
!.agents/skills/clawdtributor/**
!.agents/skills/control-ui-e2e/
!.agents/skills/control-ui-e2e/**
!.agents/skills/discord-user-post/
!.agents/skills/discord-user-post/**
!.agents/skills/gitcrawl/
!.agents/skills/gitcrawl/**
!.agents/skills/technical-documentation/

View File

@@ -172,7 +172,7 @@ Skills own workflows; root owns hard policy and routing.
- PR artifacts/screenshots: attach to PR/comment/external artifact store. Never push screenshots, videos, proof images, or proof assets to OpenClaw or any product repo branch, including temp artifact branches. Use Crabbox artifact publishing plus the manifest URL. Do not commit `.github/pr-assets`.
- CI polling: exact SHA, relevant checks only, minimal fields. Skip routine noise (`Auto response`, `Labeler`, docs agents, performance/stale). Logs only after failure/completion or concrete need.
- OpenClaw write-access maintainers may skip `Real behavior proof` when local tests or Crabbox verified behavior; record proof in PR verification.
- Agent PR landing to `main`: use only the repo-native `scripts/pr` wrapper: run `scripts/pr review-init <PR>`, follow its emitted checkout/guard guidance, initialize and complete review artifacts with `scripts/pr review-artifacts-init <PR>`, validate them with `scripts/pr review-validate-artifacts <PR>`, then run `scripts/pr prepare-run <PR>` and `scripts/pr merge-run <PR>`; do not idle on `auto-response` or `check-docs`.
- `/landpr`: use `~/.codex/prompts/landpr.md`; do not idle on `auto-response` or `check-docs`.
## Code
@@ -214,7 +214,6 @@ Skills own workflows; root owns hard policy and routing.
- Vitest. Colocated `*.test.ts`; e2e `*.e2e.test.ts`; example models `sonnet-4.6`, `gpt-5.5`; test GPT with 5.5 preferred, 5.4 ok; no GPT-4.x agent-smoke defaults.
- Prefer behavior tests over workflow/docs string greps. Put operator policy reminders in AGENTS/docs.
- QA scenario sources are YAML only: `qa/scenarios/index.yaml` and `qa/scenarios/<theme>/*.yaml`. Do not add fenced `qa-scenario`/`qa-flow` Markdown files under `qa/scenarios/`.
- Clean timers/env/globals/mocks/sockets/temp dirs/module state; `--isolate=false` safe.
- 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.

File diff suppressed because it is too large Load Diff

View File

@@ -138,7 +138,7 @@ ARG OPENCLAW_BUNDLED_PLUGIN_DIR
# BuildKit cache mounts are not part of cached layers; seed tarballs for the
# installed prod graph in the same step that runs offline prune.
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \
node scripts/list-prod-store-packages.mjs | xargs -r pnpm store add && \
pnpm list --prod --depth Infinity --json | node scripts/list-prod-store-packages.mjs | xargs -r pnpm store add && \
CI=true pnpm prune --prod \
--config.offline=true \
--config.supportedArchitectures.os=linux \
@@ -147,10 +147,6 @@ RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/sto
OPENCLAW_EXTENSIONS="$OPENCLAW_EXTENSIONS" OPENCLAW_BUNDLED_PLUGIN_DIR="$OPENCLAW_BUNDLED_PLUGIN_DIR" node scripts/prune-docker-plugin-dist.mjs && \
node scripts/postinstall-bundled-plugins.mjs && \
find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete && \
rm -rf \
/app/node_modules/openclaw \
/app/node_modules/.bin/openclaw \
/app/node_modules/.pnpm/openclaw@*/node_modules/openclaw && \
node scripts/check-package-dist-imports.mjs /app
# ── Runtime base image ──────────────────────────────────────────

View File

@@ -2,48 +2,6 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>OpenClaw</title>
<item>
<title>2026.6.8</title>
<pubDate>Tue, 16 Jun 2026 17:17:20 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2606000890</sparkle:version>
<sparkle:shortVersionString>2026.6.8</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.6.8</h2>
<h3>Highlights</h3>
<ul>
<li>Telegram and WhatsApp channel delivery are richer and less brittle: Telegram can send structured rich text with tables, lists, expandable blockquotes, preserved intentional line breaks, prompt-preserving CLI backend delivery, retired native draft migration, and safer rich-media boundaries, while WhatsApp now honors configured ACP bindings. (#92679, #93164, #84082, #89421, #92513) Thanks @obviyus, @jzakirov, @spacegeologist, and @TurboTheTurtle.</li>
<li>Agent and Gateway recovery is sharper across account-scoped DM sends, generated media completions, auto-reply message-tool final replies, reset archive fallback reads, restart shutdown aborts, yielded subagent pauses, trusted subagent thinking override fallback, yielded cron media, heartbeat dedupe, session identity prompts, and unknown OpenAI agent selector rejection. (#92788, #91246, #92879, #91357, #92631, #92412, #92146, #91287, #92468, #92510) Thanks @yetval, @TurboTheTurtle, @masatohoshino, @CadanHu, @ooiuuii, @openperf, @IWhatsskill, @ZengWen-DT, and @zhangguiping-xydt.</li>
<li>Provider/model handling expands and tightens with GLM-5.2, Claude Haiku 4.5 catalog rows, OpenRouter and Google Vertex provider-prefix normalization, managed SecretRef auth, OAuth image-default routing through Codex, bounded model browse discovery, LM Studio binary thinking-off delivery, storeless OpenAI Responses replay gating, invalid OpenAI reasoning-signature and genericized Anthropic thinking-signature recovery, Claude 4.5 Copilot tool-streaming safety, and OpenAI/Anthropic-family payload quarantine for unreadable or post-hook tool schemas. (#92796, #90116, #92627, #91218, #90686, #92824, #92247, #92002, #90706, #92941, #92201, #92916, #75393, #92908, #92921, #92928) Thanks @arkyu2077, @liuhao1024, @bymle, @rohitjavvadi, @nxmxbbd, @bek91, @samson910022, @mmyzwl, @CarlCapital, @snowzlm, @Kailigithub, and @vincentkoc.</li>
<li><code>/usage</code> and reply payload hooks now have a native full footer renderer, default template, fixed-decimal formatting, credential-aware limits, better partial-count handling, and warnings for broken templates instead of silent bad output. (#92657, #89835, #89629) Thanks @Marvinthebored.</li>
<li>UI and mobile flows are steadier: workspace files can collapse and start collapsed, WebChat backscroll survives streaming, the sidebar session picker remains interactive above the desktop workbench, reset soft args survive UI dispatch, stale dashboard session parent lineage is preserved, and iOS reconnects stale foreground gateways. (#92779, #92622, #92705, #91353, #90658, #92552) Thanks @shakkernerd, @TurboTheTurtle, @NianJiuZst, @zhouhe-xydt, @luoyanglang, and @Solvely-Colin.</li>
<li>Memory, state, and diagnostics recover cleaner: oversized OpenAI embedding batches split before 431s, QMD memory search stays available in transient mode, SQLite avoids WAL on NFS state volumes, stuck-session recovery scheduling no longer resets warning backoff, full memory reindexes preserve rollback/cache recovery, raw Memory Wiki source pages stop looking malformed, and Infinity chunk limits stay genuinely unbounded. (#92650, #92618, #92639, #91247, #92752, #92881, #59137, #92876, #69700, #92735) Thanks @mushuiyu886, @TurboTheTurtle, @849261680, @gnanam1990, @TSHOGX, @arlen8411, and @yhterrance.</li>
</ul>
<h3>Changes</h3>
<ul>
<li>Providers/models: add GLM-5.2 support and Claude Haiku 4.5 catalog entries while keeping provider-qualified model IDs normalized across OpenRouter and Google Vertex paths. (#92796, #90116, #92627, #91218) Thanks @arkyu2077, @liuhao1024, and @bymle.</li>
<li>Web search: keep key-free providers such as Parallel Free, DuckDuckGo, Ollama, and Codex Hosted Search as explicit opt-ins instead of selecting them automatically when no API-backed provider is configured. (#93616) Thanks @davemorin and @vincentkoc.</li>
<li>Channel plugins: ship Telegram rich-message delivery and WhatsApp ACP binding support, including preserved intentional line breaks, rich prompt handoff to CLI backends, and transport fixtures for richer drafts. (#92679, #93164, #92513) Thanks @obviyus and @TurboTheTurtle.</li>
<li>Agent commands: support <code>/btw</code> in CLI-backed sessions and keep CLI usage-error exits classified as usage failures instead of successful runs. (#92669, #92162) Thanks @joshavant and @Pandah97.</li>
<li>Usage hooks: add built-in full footer rendering, default footer templates, per-turn usage state, credential-aware limits, and fixed-decimal formatting for usage-bar templates. (#92657, #89835, #89629) Thanks @Marvinthebored.</li>
<li>Docs and operator guidance: document node config examples, clarify before-install hook scope, correct agent default concurrency comments, refresh ZAI provider docs, and update channel/group docs for current Telegram and WhatsApp behavior. (#92677, #92766, #92695) Thanks @liuhao1024, @sallyom, and @ArielSmoliar.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Channels and delivery: preserve account-scoped DM channel send policy, intentional rich-message line breaks in Telegram and status output, rich Telegram final replies, rich Telegram tables and lists, Telegram thread-create CLI remapping, Feishu dynamic-agent routes after persisted binding reuse, Slack outbound <code>message_sent</code> hooks, contributed message-tool schema optionality, same-channel generated media completions, and channel chunking around surrogate pairs and Infinity limits. (#92788, #93164, #92679, #89421, #89943, #42837, #92814, #91137, #91246, #92735) Thanks @yetval, @obviyus, @spacegeologist, @rishitamrakar, @liuhao1024, @lundog, @TurboTheTurtle, and @yhterrance.</li>
<li>Discord: give generated auto-thread titles a 60-second timeout and 4,096-token reasoning-model output budget, clamped to the selected model output cap. (#64734) Thanks @hanamizuki.</li>
<li>Agent, cron, and Gateway runtime: mark active main sessions before restart shutdown aborts, pause yielded subagent runs whose terminal also signals abort, clamp trusted subagent thinking overrides through provider/model fallback, preserve yielded media completions, deliver channel message-tool final replies through auto-reply while hiding internal delivery hints, restore reset archive fallback reads when active async transcripts are missing, de-duplicate main-session heartbeat events, expose session identity in runtime prompts, reject unknown OpenAI agent selectors, keep generated media completions, slash-command block replies, and trajectory export commands in WebChat, and require admin privileges for HTTP session/model override surfaces. (#91357, #92631, #92412, #92146, #92879, #91287, #92468, #92510, #91246, #92651, #92646) Thanks @ooiuuii, @openperf, @IWhatsskill, @masatohoshino, @CadanHu, @ZengWen-DT, @zhangguiping-xydt, and @TurboTheTurtle.</li>
<li>Providers and model replay: preserve storeless OpenAI Responses replay compatibility, recover invalid OpenAI reasoning signatures and genericized Anthropic thinking-signature replay errors, route OAuth image defaults through Codex for eligible OpenAI profiles, avoid eager tool streaming for Claude 4.5 in Copilot, quarantine unreadable and post-hook OpenAI/Anthropic-family tool schemas without broadening allowed tool choices, deliver explicit thinking-off requests to LM Studio binary-thinking models, honor profile auth for SecretRef model entries, bound model browsing, strip provider prefixes where runtimes need bare IDs, and surface nested embedding fetch failures. (#90706, #92941, #92201, #92916, #92824, #75393, #92908, #92921, #92928, #92002, #90686, #92247, #92627, #91218, #92628) Thanks @snowzlm, @mmyzwl, @CarlCapital, @bek91, @Kailigithub, @vincentkoc, @rohitjavvadi, @samson910022, @nxmxbbd, @liuhao1024, @bymle, and @mushuiyu886.</li>
<li>Memory, state, diagnostics, and config: split header-too-large embedding batches, keep QMD memory search enabled in transient mode, avoid SQLite WAL on NFS volumes, preserve recovery scheduling outside stuck-session warning backoff, preserve full-reindex rollback/cache recovery, treat raw Memory Wiki source pages as source evidence, and keep shell environment fallbacks contained in config write tests. (#92650, #92618, #92639, #91247, #92752, #92881, #59137, #92876, #69700) Thanks @mushuiyu886, @TurboTheTurtle, @849261680, @gnanam1990, @TSHOGX, and @arlen8411.</li>
<li>UI/mobile/TUI: preserve dashboard session parent lineage, WebChat backscroll, reset soft command args, sidebar session picker interactivity, collapsed workspace files, resolved <code>/model</code> confirmation refs, stale foreground iOS Gateway reconnects, and paused setup-parent stdin after inherited-stdio child exit. (#90658, #92622, #91353, #92705, #92779, #92773, #92552, #93159) Thanks @luoyanglang, @TurboTheTurtle, @zhouhe-xydt, @NianJiuZst, @shakkernerd, @NarahariRaghava, @Solvely-Colin, and @fuller-stack-dev.</li>
<li>Plugins and updates: repair missing required platform packages during managed plugin installs and updates, including omitted Codex platform binaries.</li>
<li>Dependencies: update Hono to 4.12.25 so published OpenClaw and ACPX packages use the patched runtime.</li>
<li>Release and test reliability: extend slow Gateway/full-suite watchdogs, split local full-suite shards when throttled, stabilize plugin auth marker fixtures, avoid brittle provider-ref error text, fold Telegram RTT sampling into live QA evidence, simplify QA scorecard mappings around canonical coverage IDs, keep QA Lab bootstrap selection assertions aligned with flow-only scenarios, skip QA coverage artifact consumers when runtime parity producer status is not green, keep Feishu lifecycle release checks pointed at the active fixture config, isolate trajectory-export live seed turns from Codex-native shell approvals, preserve release-check child refs while pinning expected SHAs, widen live OpenAI TTS budgets for slower provider responses, and avoid false downgrade prompts for unresolved latest-tag updates. (#92652, #92550, #92558, #92911) Thanks @RomneyDa and @Andy312432.</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.6.8/OpenClaw-2026.6.8.zip" length="55815364" type="application/octet-stream" sparkle:edSignature="hLJ14xg6+DMFrXViIW3Njs++OPIGO+RWH9h+mPCSzXPAkKyYUGvtOLu1qEKvvfC8rs5FGgW/w4zDLfD2azqiBA=="/>
</item>
<item>
<title>2026.6.5</title>
<pubDate>Tue, 09 Jun 2026 19:06:49 +0000</pubDate>
@@ -251,5 +209,69 @@
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.6.1/OpenClaw-2026.6.1.zip" length="55062100" type="application/octet-stream" sparkle:edSignature="PVp8E2HBCvikB/0LCr36lFEyHPAzoFA2ScT6LW27FlzvP+m4r1AEuVN2UrtgWlpkGSsn4Eav0kPJe32u4ObNBw=="/>
</item>
<item>
<title>2026.5.28</title>
<pubDate>Sat, 30 May 2026 21:21:09 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026052890</sparkle:version>
<sparkle:shortVersionString>2026.5.28</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.5.28</h2>
<h3>Highlights</h3>
<ul>
<li>Agent and Codex runtime recovery is steadier: subagents keep cwd/workspace separation, hook context stays prompt-local, session locks release on timeout abort while live OpenClaw locks survive cleanup, stale restart continuations are avoided, and Codex app-server/helper failures no longer tear down shared runtime state. (#87218, #86875, #87409, #87399, #87375, #88129)</li>
<li>Channel delivery and session identity got safer across outbound plugin hooks, Matrix room ids, iMessage reactions/approvals, Slack final replies, Discord recovered tool warnings, runtime-config message actions, WhatsApp profile auth roots, Telegram polling, and Microsoft Teams service URL trust checks. (#73706, #75670, #87366, #87451, #87334, #84535, #82492, #83304, #87160)</li>
<li>Mobile and chat surfaces got a broader refresh: the iOS Pro UI, hosted push relay default, realtime Talk tab playback, Gateway chat transport, onboarding, Talk permissions, WebChat reconnect delivery, and session picker behavior now preserve more state across reconnects and empty searches. (#87367, #87531, #87682, #88096, #88105) Thanks @ngutman.</li>
<li>Browser, channel, and automation inputs are stricter: Browser tool timeouts, viewport/tab indices, Gateway ports, cron retry handling, Discord component ids, schema array refs, Telegram callback pages, and channel progress callbacks now reject malformed values earlier and preserve the intended delivery context. (#82887)</li>
<li>Provider, media, and document coverage expands with Claude Opus 4.8, Fal Krea image schemas, NVIDIA featured models, MiniMax streaming music responses, encrypted PDF extraction, voice model catalogs, GitHub Copilot agent runtime support, and a Codex Supervisor plugin path for delegated Codex workflows. (#87845, #87890, #80775, #84764, #87751, #87794)</li>
<li>CLI, auth, doctor, and provider paths fail faster and recover more clearly: malformed numeric/version options are rejected, workspace dotenv provider credentials are ignored, heartbeat defaults, OAuth/token lifetimes, and local service startup requests are bounded, agent auth health labels are clearer, legacy <code>api_key</code> auth profiles migrate to canonical form, and restart guidance is actionable. (#87398, #86281, #87361, #88133, #83655, #87559, #88088, #85924) Thanks @vincentkoc and @giodl73-repo.</li>
<li>Plugin and Gateway hot paths do less repeated work while preserving cache correctness for install records, config JSON parsing, tool search catalogs, session stores, manifest model rows, auto-enabled plugin config, browser tokens, viewer assets, and release-split external plugin packages. (#86699)</li>
<li>Release, QA, and E2E validation now bound more log, artifact, harness, and cross-OS waits so failing lanes produce proof instead of hanging or false-greening.</li>
</ul>
<h3>Changes</h3>
<ul>
<li>Status: show active subagent details in status output.</li>
<li>Diffs: split the default language pack and expand default Diffs language coverage while keeping the host floor aligned. (#87370, #87372) Thanks @RomneyDa.</li>
<li>ClawHub: add plugin display names plus skill verification and trust surfaces. (#87354, #86699) Thanks @thewilloftheshadow and @Patrick-Erichsen.</li>
<li>iOS: refresh the dev app with Pro Command, Chat, Agents, Settings, hosted push relay defaults, and realtime Talk playback wired to gateway sessions, diagnostics, chat, and realtime Talk. (#87367, #88096, #88105) Thanks @Solvely-Colin and @ngutman.</li>
<li>Docs: clarify Codex computer-use setup, paste-token stdin auth setup, macOS gateway sleep troubleshooting, native Codex hook relay recovery, container model auth, install deployment cards, device-token admin gating, CLI setup flow compatibility, Notte cloud browser CDP setup, and backport targets. (#87313, #63050, #87685) Thanks @bdjben, @liaoandi, and @thewilloftheshadow.</li>
<li>PDF/tools: use ClawPDF for PDF extraction, support encrypted PDF extraction, and surface MCP structured content in agent tool results. (#87670, #87751)</li>
<li>Providers: add Claude Opus 4.8 support, Fal Krea image model schemas, NVIDIA featured model catalogs, MiniMax streaming music responses, and provider-backed voice model catalogs. (#87845, #87890, #80775, #84764, #87794) Thanks @eleqtrizit and @vincentkoc.</li>
<li>Codex/GitHub: add the GitHub Copilot agent runtime and the Codex Supervisor plugin package.</li>
<li>Plugins: externalize GitHub Copilot and Tokenjuice as official install-on-demand plugins with npm and ClawHub publish metadata.</li>
<li>Workboard: add agent coordination tools for tracking and handing off active agent work.</li>
<li>Discord: show commentary in progress drafts so live Discord runs expose useful in-progress context. (#85200)</li>
<li>Plugin SDK: add a reply payload sending hook for plugins that need to deliver channel-owned replies and flatten package types for SDK declarations. (#82823, #87165) Thanks @RomneyDa.</li>
<li>Policy: add policy comparison, ingress-channel conformance, and sandbox-posture conformance checks. (#85572, #85744, #86768)</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Agents: fall back to local config pruning when the optional <code>agents delete</code> Gateway probe cannot authenticate, so offline installs can still delete agents without removing shared workspaces.</li>
<li>Tighten phone-control mutation authorization [AI]. (#87150) Thanks @pgondhi987.</li>
<li>Clarify directive persistence authorization policy [AI]. (#86369) Thanks @pgondhi987.</li>
<li>Agents/Codex: keep spawned agent cwd/workspace state separated, forward ACP spawn attachments, keep hook context prompt-local, release session locks on timeout abort and runtime teardown without deleting live OpenClaw-owned locks during cleanup, avoid session event queue self-wait, clean up exec abort listeners, stream assistant deltas incrementally, recover raw missing-thread compaction failures, preserve rotated compaction session identity, keep compaction-timeout snapshots continuable, preserve shared app-server state across startup or helper failures, keep native hook relay alive across restarts and prune stale bridge files, close native hook relay replacement races, keep Claude live tool progress visible for watchdog recovery, suppress abandoned requester completion handoff, route workspace memory through tools, resolve Codex runtime models first, report quarantined dynamic tools, format <code>skills</code> command output, bind node auto-review to prepared plans, retry Claude CLI transcript probes, and bound compaction/steering retries. (#87218, #86875, #86123, #88129, #87399, #87375, #72574, #87383, #87400, #83022, #87671, #87738, #87747, #87706, #87546, #87541, #81048) Thanks @mbelinky, @Alix-007, @luoyanglang, @yetval, @sjf, @joshavant, and @benjamin1492.</li>
<li>Codex Supervisor: keep real-home app-server MCP session listing on the loaded state path, bound stored history scans, and close WebSocket probes cleanly.</li>
<li>Channels: thread canonical session keys into outbound hooks, preserve Matrix room-id case, keep fallback tool warnings mention-inert, retain delivered Slack final replies during late cleanup, continue iMessage polling after denied reactions, suppress duplicate native exec approvals, resolve Gateway message actions against the active runtime config, preserve Telegram SecretRef prompt config and polling keepalives, preserve WhatsApp profile auth roots, QR display, document filenames, and plugin hook config, suppress Discord recovered tool warnings, preserve the Discord voice outbound helper, cap Discord/Signal/Zalo channel request and container timeouts, and block untrusted Teams service URLs while keeping TeamsSDK patterns aligned. (#73706, #75670, #87366, #87451, #87465, #87334, #84535, #76262, #83304, #82492, #87581, #77114, #86426, #85529, #87160) Thanks @zeroaltitude, @lukeboyett, @xiaotian, @funmerlin, @joshavant, @eleqtrizit, @heyitsaamir, @amittell, @liorb-mountapps, @masatohoshino, @bladin, and @giodl73-repo.</li>
<li>CLI/auth/doctor/providers: reject malformed numeric/timeout/subcommand-version inputs, ignore workspace dotenv provider credentials, wait for respawn child shutdown, bound heartbeat defaults plus Codex, GitHub Copilot, OpenAI, Anthropic, Google, Feishu, LM Studio, MiniMax, Xiaomi TTS, and local-provider OAuth/token/model requests, harden Codex auth probes, label auth health by agent, preserve explicit agentRuntime pins during Codex model migration, warm provider auth off the main thread, honor Codex response timeouts, stop migrating current Claude Haiku 4.5 profiles to Sonnet, bound local service startup, resolve GPT-5.5 without cached catalog, migrate legacy memory auto-provider config, rewrite non-canonical <code>api_key</code> auth profiles, and make doctor restart follow-ups actionable. (#87398, #86281, #87361, #88133, #83655, #87559, #87719, #88088, #85924, #84362) Thanks @Patrick-Erichsen, @samzong, @giodl73-repo, @alkor2000, @mmaps, @nxmxbbd, and @vincentkoc.</li>
<li>Gateway/security/session state: expire browser tokens after auth rotation, scope assistant idempotency dedupe, drain probe client closes, avoid stale restart continuation reuse, preserve retry-after fallbacks and stale rate-limit cooldown probes, bound webchat image and artifact transcript scans, include seconds in inbound metadata timestamps, clear completed session active runs, clear stale chat stream buffers, and evict current plugin-state namespaces at row caps. (#87810, #87833, #75089) Thanks @joshavant and @litang9.</li>
<li>Config/parsing/network: reject partial numeric parsing, parse provider/Discord retry headers and dates strictly, honor IPv6 and bare IPv6 <code>no_proxy</code> entries, preserve empty plugin allowlists, canonicalize secret target array indexes, and reject malformed media content lengths, inspected TCP ports, marketplace content lengths, cron epochs, sandbox stat fields, unsafe duration values, empty config path segments, noncanonical schema array refs, unsafe Telegram callback pages, and invalid Teams attachment-fetch DNS targets. (#87883) Thanks @zhangguiping-xydt.</li>
<li>Browser/input hardening: reject invalid tab indexes, excessive viewport resizes, explicit zero CDP ports, malformed geolocation options, unsafe screenshot or permission-grant timeouts, loose response-body limits, invalid cookie expiries, and non-finite Browser tool delays/timeouts.</li>
<li>Cron/automation: retry recurring jobs after transient model rate limits before waiting for the next scheduled slot, and preflight model fallbacks before skipping scheduled work. (#82887)</li>
<li>Auto-reply/directives: respect provider and relayed channel metadata during directive persistence so channel-originated decisions keep their intended context. (#87683)</li>
<li>WhatsApp: resolve the auth directory from the active profile so profile-scoped WhatsApp installs do not drift to the wrong credential root. (#82492)</li>
<li>Gateway/session state: clear completed session active runs, avoid cold-loading providers for MCP inventory, cache single-session child indexes, cap handshake timers, and bound preauth, auth-guard, media, transcript, readiness, and port options.</li>
<li>Channels/replies: preserve channel-owned progress callbacks when verbose output is off, keep group-room progress suppression intact, prefer external session delivery context, escape Discord component id delimiters, force final TUI chat repaints, show Slack reasoning previews, and normalize Discord/Matrix/Mattermost channel numeric options. (#87476, #87423)</li>
<li>Agents/tool args: harden smart-quoted argument repair for edit arrays and exact escaped arguments so model-produced tool calls recover without corrupting valid input. (#86611)</li>
<li>Providers/agents: preserve seeded Anthropic signatures, preserve signed thinking payloads, concatenate signature-delta chunks, preserve DeepSeek <code>reasoning_content</code> replay across tier suffixes, apply OpenRouter strict9 ids to Mistral routes, promote Ollama plain-text tool calls, load NVIDIA featured model catalogs, stream MiniMax music generation responses, and recover empty preflight compaction. (#87593, #87493, #80775, #84764) Thanks @eleqtrizit.</li>
<li>Media/images: skip CLI image cache refs when resolving generated images, allow trusted generated HTML attachments, and bound generated video downloads so stale refs and slow providers fail cleanly. (#87523, #87982)</li>
<li>File transfer: handle late tar stdin pipe errors after archive validation or unpacking has already settled.</li>
<li>Performance: trust install-record caches between reloads, prefer native JSON parsing, reuse unchanged tool-search catalogs, reuse gateway session and plugin metadata paths, skip unchanged store serialization, patch single-entry session writes, add precomputed session patch writers, reduce store clone allocations, cache manifest model catalog rows and auto-enabled plugin config, avoid full session snapshots for entry reads, defer configured Slack full startup, prefer bundled plugin dist entries, and slim current metadata identity caches. (#87760)</li>
<li>Docker/release/QA: package runtime workspace templates, stream cross-OS served artifacts, preserve sparse Crabbox run artifacts, isolate npm plugin installs per package, reject incompatible package plugin API installs, drop the leftover root Sharp dependency from package manifests after the Rastermill migration, bound OpenClaw instance logs, plugin gauntlet relay logs, MCP channel buffers, kitchen-sink scans, agent-turn assertions, QA-Lab credential broker calls, QA Matrix substrate requests, and release scenario logs, and keep release/google live guards current. (#87647, #87477) Thanks @rohitjavvadi and @vincentkoc.</li>
<li>Release/CI: bound manual git fetches, ClawHub verifier responses, ClawHub owner metadata, dependency-guard error bodies, Parallels limits, startup/test/memory budget parsing, and diffs viewer build warnings so release lanes fail with useful proof instead of hanging. (#87839)</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.28/OpenClaw-2026.5.28.zip" length="54750142" type="application/octet-stream" sparkle:edSignature="U4O55uMdPU+OqSx9QR1ApUJ8wg65wxTydzD7iyCn1GHtm1MBK9noEeiA/yoUKkqb/bx0hzi1gNhn+ye19RXnCA=="/>
</item>
</channel>
</rss>

View File

@@ -1,11 +0,0 @@
# OpenClaw Android Changelog
## Unreleased
Maintenance update for the current OpenClaw Android release.
## 2026.6.2 - 2026-06-02
OpenClaw is now available on Android.
Connect to your OpenClaw Gateway to chat with your assistant, use realtime Talk mode, review approvals, and bring Android device capabilities like camera, location, screen, and notifications into your private automation workflows.

View File

@@ -1,14 +0,0 @@
{
"signingRepo": "git@github.com:openclaw/apps-signing.git",
"signingBranch": "main",
"assetPath": "android/openclaw",
"uploadKeystoreEncryptedFile": "upload-keystore.jks.enc",
"gradlePropertiesEncryptedFile": "gradle.properties.enc",
"materializedRoot": "apps/android/build/release-signing",
"gradlePropertyNames": [
"OPENCLAW_ANDROID_STORE_FILE",
"OPENCLAW_ANDROID_STORE_PASSWORD",
"OPENCLAW_ANDROID_KEY_ALIAS",
"OPENCLAW_ANDROID_KEY_PASSWORD"
]
}

View File

@@ -1,6 +0,0 @@
# Shared Android version defaults.
# Source of truth: apps/android/version.json
# Generated by scripts/android-sync-versioning.ts.
OPENCLAW_ANDROID_VERSION_NAME=2026.6.2
OPENCLAW_ANDROID_VERSION_CODE=2026060201

View File

@@ -32,7 +32,7 @@ cd apps/android
./gradlew :app:installPlayDebug
./gradlew :app:testPlayDebugUnitTest
cd ../..
pnpm android:release:archive
bun run android:bundle:release
```
Third-party debug flavor:
@@ -44,39 +44,10 @@ cd apps/android
./gradlew :app:testThirdPartyDebugUnitTest
```
Android release archives use the pinned version in `apps/android/version.json`. Update it with:
`bun run android:bundle:release` auto-bumps Android `versionName`/`versionCode` in `apps/android/app/build.gradle.kts`, then builds two signed release bundles:
```bash
pnpm android:version
pnpm android:version:check
pnpm android:version:pin -- --from-gateway
pnpm android:version:pin -- --version 2026.6.5 --version-code 2026060501
```
Release-owner signing sync:
```bash
pnpm android:release:signing:plan
MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:sync:pull
MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:check
```
The signing sync pulls encrypted Android upload-key assets from the shared `apps-signing` repo and materializes decrypted files under `apps/android/build/release-signing/`.
Generate raw Google Play screenshots:
```bash
pnpm android:screenshots
```
`pnpm android:release:archive` builds signed release artifacts into `apps/android/build/release-artifacts/` and writes `.sha256` checksum files:
- Play build: `openclaw-<version>-play-release.aab`
- Third-party build: `openclaw-<version>-third-party-release.apk`
`pnpm android:bundle:release` is an alias for the same Fastlane archive lane.
See `apps/android/VERSIONING.md` and `apps/android/fastlane/SETUP.md` for the release workflow.
- Play build: `apps/android/build/release-bundles/openclaw-<version>-play-release.aab`
- Third-party build: `apps/android/build/release-bundles/openclaw-<version>-third-party-release.aab`
Flavor-specific direct Gradle tasks:

View File

@@ -1,65 +0,0 @@
# OpenClaw Android Versioning
Android release builds use pinned app metadata instead of auto-bumping `build.gradle.kts`.
## Version model
- `apps/android/version.json` is the source of truth.
- `version` is the Play `versionName` and uses CalVer: `YYYY.M.D`.
- `versionCode` uses `YYYYMMDDNN`, where `NN` is a two-digit build number for that pinned app version.
- `apps/android/Config/Version.properties` is generated from `version.json` and read by Gradle.
- `apps/android/CHANGELOG.md` is the Android-only changelog and release-note source.
- `apps/android/fastlane/metadata/android/en-US/release_notes.txt` is generated from the changelog.
Examples:
- `version = 2026.6.2`
- `versionCode = 2026060201`
- another upload on the same release train: `versionCode = 2026060202`
## Commands
```bash
pnpm android:version
pnpm android:version:check
pnpm android:version:sync
pnpm android:version:pin -- --from-gateway
pnpm android:version:pin -- --version 2026.6.5 --version-code 2026060501
pnpm android:release:signing:plan
MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:sync:pull
pnpm android:release:preflight
```
## Release-note resolution order
When generating `apps/android/fastlane/metadata/android/en-US/release_notes.txt`, the tooling reads the first available changelog section in this order:
1. exact pinned version, for example `## 2026.6.2`
2. `## Unreleased`
Recommended workflow:
- while iterating on a Play internal testing train, keep pending notes under `## Unreleased`
- before the production release, move or copy the final notes under `## <pinned version>` and run sync again
## Release Workflow
1. Pin Android to the intended release version.
2. Run `pnpm android:version:sync`.
3. Update `apps/android/CHANGELOG.md`, then run `pnpm android:version:sync` again if needed.
4. Run `MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:sync:pull` to materialize encrypted Android signing assets from `apps-signing`.
5. Run `pnpm android:release:preflight` to validate Play auth, signing, synced versioning, and release notes.
6. Run `pnpm android:screenshots` to refresh raw Google Play screenshots.
7. Run `pnpm android:release:archive` to produce the signed Play AAB and third-party APK.
8. Run `pnpm android:release:upload` to upload metadata, screenshots, and the Play AAB to Google Play internal testing.
9. Promote to production manually in Google Play Console.
The third-party flavor is archived as a signed APK for non-Play distribution. It is not uploaded by the Play release lane.
## Signing model
`apps/android/Config/ReleaseSigning.json` pins the Android signing assets in the shared private `apps-signing` repo. The Android pipeline uses the same `MATCH_PASSWORD` release-owner secret as iOS, but the Android files are managed by `scripts/android-release-signing.mjs` instead of Fastlane `match`.
`sync:pull` decrypts the Play upload keystore and Gradle signing properties into `apps/android/build/release-signing/`. That directory is gitignored, and Fastlane exports the materialized values as Gradle project properties for the current release command.
If `MATCH_PASSWORD` is not set, the existing manual Gradle-property signing path still works: provide `OPENCLAW_ANDROID_STORE_FILE`, `OPENCLAW_ANDROID_STORE_PASSWORD`, `OPENCLAW_ANDROID_KEY_ALIAS`, and `OPENCLAW_ANDROID_KEY_PASSWORD` through your local Gradle user properties before running release tasks.

View File

@@ -1,24 +1,6 @@
import com.android.build.api.variant.impl.VariantOutputImpl
import java.util.Properties
val dnsjavaInetAddressResolverService = "META-INF/services/java.net.spi.InetAddressResolverProvider"
val openClawAndroidVersionFile = rootProject.file("Config/Version.properties")
val openClawAndroidVersionProperties =
Properties().apply {
if (!openClawAndroidVersionFile.isFile) {
error("Missing Android version properties. Run `pnpm android:version:sync`.")
}
openClawAndroidVersionFile.inputStream().use(::load)
}
fun requireOpenClawAndroidVersionProperty(name: String): String =
openClawAndroidVersionProperties.getProperty(name)?.trim()?.takeIf { it.isNotEmpty() }
?: error("Missing $name in Config/Version.properties. Run `pnpm android:version:sync`.")
val openClawAndroidVersionName = requireOpenClawAndroidVersionProperty("OPENCLAW_ANDROID_VERSION_NAME")
val openClawAndroidVersionCode =
requireOpenClawAndroidVersionProperty("OPENCLAW_ANDROID_VERSION_CODE").toIntOrNull()
?: error("OPENCLAW_ANDROID_VERSION_CODE must be an integer in Config/Version.properties.")
val androidStoreFile = providers.gradleProperty("OPENCLAW_ANDROID_STORE_FILE").orNull?.takeIf { it.isNotBlank() }
val androidStorePassword = providers.gradleProperty("OPENCLAW_ANDROID_STORE_PASSWORD").orNull?.takeIf { it.isNotBlank() }
@@ -83,8 +65,8 @@ android {
applicationId = "ai.openclaw.app"
minSdk = 31
targetSdk = 36
versionCode = openClawAndroidVersionCode
versionName = openClawAndroidVersionName
versionCode = 2026060201
versionName = "2026.6.2"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")

View File

@@ -1,28 +0,0 @@
package ai.openclaw.app
import android.content.Intent
const val extraAndroidScreenshotMode = "openclaw.screenshotMode"
const val extraAndroidScreenshotScene = "openclaw.screenshotScene"
enum class AndroidScreenshotScene(
val rawValue: String,
) {
Connect("connect"),
Chat("chat"),
Voice("voice"),
Screen("screen"),
Settings("settings"),
;
companion object {
fun fromRawValue(raw: String?): AndroidScreenshotScene = entries.firstOrNull { it.rawValue == raw?.trim()?.lowercase() } ?: Connect
}
}
fun parseAndroidScreenshotModeIntent(intent: Intent?): AndroidScreenshotScene? {
if (intent?.getBooleanExtra(extraAndroidScreenshotMode, false) != true) {
return null
}
return AndroidScreenshotScene.fromRawValue(intent.getStringExtra(extraAndroidScreenshotScene))
}

View File

@@ -1,6 +1,5 @@
package ai.openclaw.app
import ai.openclaw.app.ui.AndroidScreenshotModeScreen
import ai.openclaw.app.ui.OpenClawTheme
import ai.openclaw.app.ui.RootScreen
import android.content.Intent
@@ -52,12 +51,6 @@ class MainActivity : ComponentActivity() {
pendingIntent = intent
WindowCompat.setDecorFitsSystemWindows(window, false)
permissionRequester = PermissionRequester(this)
if (BuildConfig.DEBUG) {
parseAndroidScreenshotModeIntent(intent)?.let { scene ->
enterScreenshotMode(scene)
return
}
}
setContent {
var activeViewModel by remember { mutableStateOf<MainViewModel?>(null) }
@@ -86,12 +79,6 @@ class MainActivity : ComponentActivity() {
}
}
private fun enterScreenshotMode(scene: AndroidScreenshotScene) {
setContent {
AndroidScreenshotModeScreen(scene = scene)
}
}
override fun onStart() {
super.onStart()
foreground = true

View File

@@ -111,8 +111,6 @@ class MainViewModel(
val isConnected: StateFlow<Boolean> = runtimeState(initial = false) { it.isConnected }
val isNodeConnected: StateFlow<Boolean> = runtimeState(initial = false) { it.nodeConnected }
val nodeCapabilityApprovalState: StateFlow<GatewayNodeApprovalState> =
runtimeState(initial = GatewayNodeApprovalState.Loading) { it.nodeCapabilityApprovalState }
val statusText: StateFlow<String> = runtimeState(initial = "Offline") { it.statusText }
val gatewayConnectionProblem: StateFlow<GatewayConnectionProblem?> = runtimeState(initial = null) { it.gatewayConnectionProblem }
val serverName: StateFlow<String?> = runtimeState(initial = null) { it.serverName }

View File

@@ -69,7 +69,6 @@ import kotlinx.coroutines.withTimeout
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
@@ -302,8 +301,6 @@ class NodeRuntime(
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
private val _nodeConnected = MutableStateFlow(false)
val nodeConnected: StateFlow<Boolean> = _nodeConnected.asStateFlow()
private val _nodeCapabilityApprovalState = MutableStateFlow(GatewayNodeApprovalState.Loading)
val nodeCapabilityApprovalState: StateFlow<GatewayNodeApprovalState> = _nodeCapabilityApprovalState.asStateFlow()
private val _statusText = MutableStateFlow("Offline")
val statusText: StateFlow<String> = _statusText.asStateFlow()
@@ -398,7 +395,6 @@ class NodeRuntime(
val nodesDevicesRefreshing: StateFlow<Boolean> = _nodesDevicesRefreshing.asStateFlow()
private val _nodesDevicesErrorText = MutableStateFlow<String?>(null)
val nodesDevicesErrorText: StateFlow<String?> = _nodesDevicesErrorText.asStateFlow()
private val nodeApprovalRefreshGuard = GatewayNodeApprovalRefreshGuard()
private val _channelsSummary = MutableStateFlow(GatewayChannelsSummary(channels = emptyList()))
val channelsSummary: StateFlow<GatewayChannelsSummary> = _channelsSummary.asStateFlow()
private val _channelsRefreshing = MutableStateFlow(false)
@@ -447,7 +443,6 @@ class NodeRuntime(
updateStatus()
micCapture.onGatewayConnectionChanged(true)
scope.launch {
subscribeOperatorSessionEvents()
refreshHomeCanvasOverviewIfConnected()
if (voiceReplySpeakerLazy.isInitialized()) {
voiceReplySpeaker.refreshConfig()
@@ -456,7 +451,6 @@ class NodeRuntime(
},
onDisconnected = { message ->
operatorConnected = false
invalidateNodeCapabilityApprovalState()
operatorStatusText = message
_serverName.value = null
_remoteAddress.value = null
@@ -491,14 +485,6 @@ class NodeRuntime(
},
)
private suspend fun subscribeOperatorSessionEvents() {
try {
operatorSession.request("sessions.subscribe", null)
} catch (err: Throwable) {
Log.d("OpenClawRuntime", "sessions.subscribe failed: ${err.message ?: err::class.java.simpleName}")
}
}
private val nodeSession =
GatewaySession(
scope = scope,
@@ -517,15 +503,12 @@ class NodeRuntime(
publishNodePresenceAliveBeacon(NodePresenceAliveBeacon.Trigger.Connect)
val endpoint = connectedEndpoint
val auth = activeGatewayAuth
if (operatorConnected) {
scope.launch { refreshNodesDevicesFromGateway() }
} else if (endpoint != null && auth != null) {
if (endpoint != null && auth != null) {
maybeStartOperatorSessionAfterNodeConnect(endpoint, auth)
}
},
onDisconnected = { message ->
_nodeConnected.value = false
invalidateNodeCapabilityApprovalState()
nodeStatusText = message
didAutoRequestCanvasRehydrate = false
_canvasA2uiHydrated.value = false
@@ -2017,42 +2000,21 @@ class NodeRuntime(
}
private suspend fun refreshNodesDevicesFromGateway() {
val refreshGeneration = nodeApprovalRefreshGuard.begin()
val refreshStarted =
nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
_nodesDevicesRefreshing.value = true
_nodesDevicesErrorText.value = null
_nodeCapabilityApprovalState.value = GatewayNodeApprovalState.Loading
}
if (!refreshStarted) return
_nodesDevicesRefreshing.value = true
_nodesDevicesErrorText.value = null
if (!operatorConnected) {
nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
_nodesDevicesSummary.value =
GatewayNodesDevicesSummary(
nodes = emptyList(),
pendingDevices = emptyList(),
pairedDevices = emptyList(),
)
_nodesDevicesRefreshing.value = false
}
_nodesDevicesSummary.value =
GatewayNodesDevicesSummary(
nodes = emptyList(),
pendingDevices = emptyList(),
pairedDevices = emptyList(),
)
_nodesDevicesRefreshing.value = false
return
}
try {
val nodesRes = operatorSession.request("node.list", "{}")
val nodesRoot = json.parseToJsonElement(nodesRes).asObjectOrNull()
val nodes = parseGatewayNodes(nodesRoot?.get("nodes") as? JsonArray)
val approvalState =
currentNodeCapabilityApprovalState(
nodes = nodes,
selfNodeId = identityStore.loadOrCreate().deviceId,
)
val publishedApproval =
nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
_nodeCapabilityApprovalState.value = approvalState
}
if (!publishedApproval) {
return
}
val devicesRoot =
try {
val devicesRes = operatorSession.request("device.pair.list", "{}")
@@ -2060,30 +2022,16 @@ class NodeRuntime(
} catch (_: Throwable) {
null
}
nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
_nodesDevicesSummary.value =
GatewayNodesDevicesSummary(
nodes = nodes,
pendingDevices = parsePendingDevices(devicesRoot?.get("pending") as? JsonArray),
pairedDevices = parsePairedDevices(devicesRoot?.get("paired") as? JsonArray),
devicePairingAvailable = devicesRoot != null,
)
}
_nodesDevicesSummary.value =
GatewayNodesDevicesSummary(
nodes = parseGatewayNodes(nodesRoot?.get("nodes") as? JsonArray),
pendingDevices = parsePendingDevices(devicesRoot?.get("pending") as? JsonArray),
pairedDevices = parsePairedDevices(devicesRoot?.get("paired") as? JsonArray),
devicePairingAvailable = devicesRoot != null,
)
} catch (_: Throwable) {
nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
_nodesDevicesErrorText.value = "Could not load nodes and devices."
}
_nodesDevicesErrorText.value = "Could not load nodes and devices."
} finally {
nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
_nodesDevicesRefreshing.value = false
}
}
}
private fun invalidateNodeCapabilityApprovalState() {
val refreshGeneration = nodeApprovalRefreshGuard.begin()
nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
_nodeCapabilityApprovalState.value = GatewayNodeApprovalState.Loading
_nodesDevicesRefreshing.value = false
}
}
@@ -2332,8 +2280,22 @@ class NodeRuntime(
private fun parseGatewayNodes(nodes: JsonArray?): List<GatewayNodeSummary> =
nodes
?.mapNotNull(::parseGatewayNodeSummary)
.orEmpty()
?.mapNotNull { item ->
val obj = item.asObjectOrNull() ?: return@mapNotNull null
val id = obj["nodeId"].asStringOrNull()?.trim().orEmpty()
if (id.isEmpty()) return@mapNotNull null
GatewayNodeSummary(
id = id,
displayName = obj["displayName"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
remoteIp = obj["remoteIp"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
version = obj["version"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
deviceFamily = obj["deviceFamily"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
paired = obj.boolean("paired"),
connected = obj.boolean("connected"),
capabilities = parseStringArray(obj["caps"] as? JsonArray),
commands = parseStringArray(obj["commands"] as? JsonArray),
)
}.orEmpty()
private fun parsePendingDevices(devices: JsonArray?): List<GatewayPendingDeviceSummary> =
devices
@@ -2861,81 +2823,6 @@ data class GatewayNodesDevicesSummary(
val devicePairingAvailable: Boolean = true,
)
enum class GatewayNodeApprovalState {
Loading,
Unsupported,
Approved,
PendingApproval,
PendingReapproval,
Unapproved,
}
/** Prevents older node.list responses from overwriting newer approval state. */
internal class GatewayNodeApprovalRefreshGuard {
private val lock = Any()
private var generation = 0L
fun begin(): Long =
synchronized(lock) {
generation += 1
generation
}
fun publishIfCurrent(
refreshGeneration: Long,
publish: () -> Unit,
): Boolean =
synchronized(lock) {
if (refreshGeneration != generation) return@synchronized false
publish()
true
}
}
internal fun parseGatewayNodeApprovalState(raw: String?): GatewayNodeApprovalState =
when (raw?.trim()?.lowercase()) {
null, "" -> GatewayNodeApprovalState.Loading
"approved" -> GatewayNodeApprovalState.Approved
"pending-approval" -> GatewayNodeApprovalState.PendingApproval
"pending-reapproval" -> GatewayNodeApprovalState.PendingReapproval
"unapproved" -> GatewayNodeApprovalState.Unapproved
else -> GatewayNodeApprovalState.Loading
}
internal fun currentNodeCapabilityApprovalState(
nodes: List<GatewayNodeSummary>,
selfNodeId: String,
): GatewayNodeApprovalState =
nodes
.firstOrNull { it.id == selfNodeId }
?.approvalState
?: GatewayNodeApprovalState.Loading
internal fun parseGatewayNodeSummary(item: JsonElement): GatewayNodeSummary? {
val obj = item.asObjectOrNull() ?: return null
val id = obj["nodeId"].asStringOrNull()?.trim().orEmpty()
if (id.isEmpty()) return null
return GatewayNodeSummary(
id = id,
displayName = obj["displayName"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
remoteIp = obj["remoteIp"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
version = obj["version"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
deviceFamily = obj["deviceFamily"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
paired = obj.boolean("paired"),
connected = obj.boolean("connected"),
// Only an omitted field identifies a legacy gateway; malformed and future values stay fail-closed.
approvalState =
if (obj.containsKey("approvalState")) {
parseGatewayNodeApprovalState(obj["approvalState"].asStringOrNull())
} else {
GatewayNodeApprovalState.Unsupported
},
pendingRequestId = obj["pendingRequestId"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
capabilities = parseGatewayStringArray(obj["caps"] as? JsonArray),
commands = parseGatewayStringArray(obj["commands"] as? JsonArray),
)
}
data class GatewayNodeSummary(
val id: String,
val displayName: String?,
@@ -2944,8 +2831,6 @@ data class GatewayNodeSummary(
val deviceFamily: String?,
val paired: Boolean,
val connected: Boolean,
val approvalState: GatewayNodeApprovalState,
val pendingRequestId: String?,
val capabilities: List<String>,
val commands: List<String>,
)
@@ -3068,11 +2953,6 @@ private fun JsonObject?.cronStatus(key: String): String? =
?.trim()
?.takeIf { it.isNotEmpty() }
private fun parseGatewayStringArray(items: JsonArray?): List<String> =
items
?.mapNotNull { it.asStringOrNull()?.trim()?.takeIf { value -> value.isNotEmpty() } }
.orEmpty()
fun providerDisplayName(provider: String): String =
when (provider.trim().lowercase()) {
"openai" -> "OpenAI"

View File

@@ -311,6 +311,7 @@ class ChatController(
}
}
/** Applies gateway chat/agent stream events to local transcript and pending-run state. */
fun handleGatewayEvent(
event: String,
payloadJson: String?,
@@ -320,6 +321,7 @@ class ChatController(
scope.launch { pollHealthIfNeeded(force = false) }
}
"health" -> {
// If we receive a health snapshot, the gateway is reachable.
_healthOk.value = true
}
"seqGap" -> {
@@ -330,17 +332,6 @@ class ChatController(
if (payloadJson.isNullOrBlank()) return
handleChatEvent(payloadJson)
}
"sessions.changed" -> {
if (payloadJson.isNullOrBlank()) {
refreshSessionsForCurrentWindow()
} else {
handleSessionsChangedEvent(payloadJson)
}
}
"session.message" -> {
if (payloadJson.isNullOrBlank()) return
handleSessionMessageEvent(payloadJson)
}
"agent" -> {
if (payloadJson.isNullOrBlank()) return
handleAgentEvent(payloadJson)
@@ -362,7 +353,6 @@ class ChatController(
)
if (!isCurrentHistoryLoad(sessionKey, _sessionKey.value, generation, historyLoadGeneration.get())) return
val history = parseHistory(historyJson, sessionKey = sessionKey, previousMessages = _messages.value)
updateSessionFromHistory(history)
prunePersistedOptimisticMessages(history.messages)
_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
_sessionId.value = history.sessionId
@@ -398,10 +388,6 @@ class ChatController(
}
}
private fun refreshSessionsForCurrentWindow() {
scope.launch { fetchSessions(limit = _sessions.value.size.takeIf { it > 0 } ?: 100) }
}
private suspend fun pollHealthIfNeeded(force: Boolean) {
val now = System.currentTimeMillis()
val last = lastHealthPollAtMs
@@ -471,7 +457,6 @@ class ChatController(
sessionKey = currentSessionKey,
previousMessages = _messages.value,
)
updateSessionFromHistory(history)
prunePersistedOptimisticMessages(history.messages)
_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
_sessionId.value = history.sessionId
@@ -487,31 +472,6 @@ class ChatController(
}
}
private fun handleSessionsChangedEvent(payloadJson: String) {
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
if (payload["reason"].asStringOrNull() == "delete") {
removeSessionEntry(payload["sessionKey"].asStringOrNull() ?: payload["key"].asStringOrNull())
return
}
val entry = parseEventSessionEntry(payload)
if (entry != null) {
upsertSessionEntry(entry)
} else {
refreshSessionsForCurrentWindow()
}
}
private fun handleSessionMessageEvent(payloadJson: String) {
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
val entry = parseEventSessionEntry(payload)
if (entry != null) {
upsertSessionEntry(entry)
}
}
private fun parseEventSessionEntry(payload: JsonObject): ChatSessionEntry? =
payload["session"].asObjectOrNull()?.let(::parseSessionEntry) ?: parseSessionEntry(payload)
private fun handleAgentEvent(payloadJson: String) {
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
val sessionKey = payload["sessionKey"].asStringOrNull()?.trim()
@@ -640,7 +600,6 @@ class ChatController(
val root = json.parseToJsonElement(historyJson).asObjectOrNull() ?: return ChatHistory(sessionKey, null, null, emptyList())
val sid = root["sessionId"].asStringOrNull()
val thinkingLevel = root["thinkingLevel"].asStringOrNull()
val sessionInfo = root["sessionInfo"].asObjectOrNull()?.let { parseSessionEntry(it, fallbackKey = sessionKey) }
val array = root["messages"].asArrayOrNull() ?: JsonArray(emptyList())
val messages =
@@ -663,69 +622,20 @@ class ChatController(
sessionId = sid,
thinkingLevel = thinkingLevel,
messages = reconcileMessageIds(previous = previousMessages, incoming = messages),
sessionInfo = sessionInfo,
)
}
private fun parseSessions(jsonString: String): List<ChatSessionEntry> {
val root = json.parseToJsonElement(jsonString).asObjectOrNull() ?: return emptyList()
val sessions = root["sessions"].asArrayOrNull() ?: return emptyList()
return sessions.mapNotNull { item -> parseSessionEntry(item.asObjectOrNull()) }
}
private fun parseSessionEntry(
obj: JsonObject?,
fallbackKey: String? = null,
): ChatSessionEntry? {
if (obj == null) return null
val key =
obj["key"].asStringOrNull()?.trim().orEmpty()
.ifEmpty { obj["sessionKey"].asStringOrNull()?.trim().orEmpty() }
.ifEmpty { fallbackKey?.trim().orEmpty() }
if (key.isEmpty()) return null
return ChatSessionEntry(
key = key,
updatedAtMs = obj["updatedAt"].asLongOrNull(),
displayName = obj["displayName"].asStringOrNull()?.trim(),
totalTokens = obj["totalTokens"].asLongOrNull(),
totalTokensFresh = obj["totalTokensFresh"].asBooleanOrNull(),
contextTokens = obj["contextTokens"].asLongOrNull(),
hasContextUsageMetadata =
"totalTokens" in obj ||
"totalTokensFresh" in obj ||
"contextTokens" in obj,
)
}
private fun updateSessionFromHistory(history: ChatHistory) {
val info = history.sessionInfo ?: return
upsertSessionEntry(info, preserveExistingContextUsageWithoutTotal = true)
}
private fun upsertSessionEntry(
entry: ChatSessionEntry,
preserveExistingContextUsageWithoutTotal: Boolean = false,
) {
val current = _sessions.value
val index = current.indexOfFirst { it.key == entry.key }
_sessions.value =
if (index >= 0) {
current.toMutableList().also {
it[index] =
mergeChatSessionEntry(
existing = it[index],
next = entry,
preserveExistingContextUsageWithoutTotal = preserveExistingContextUsageWithoutTotal,
)
}
} else {
listOf(entry) + current
}
}
private fun removeSessionEntry(sessionKey: String?) {
val key = sessionKey?.trim()?.takeIf { it.isNotEmpty() } ?: return
_sessions.value = _sessions.value.filterNot { it.key == key }
return sessions.mapNotNull { item ->
val obj = item.asObjectOrNull() ?: return@mapNotNull null
val key = obj["key"].asStringOrNull()?.trim().orEmpty()
if (key.isEmpty()) return@mapNotNull null
val updatedAt = obj["updatedAt"].asLongOrNull()
val displayName = obj["displayName"].asStringOrNull()?.trim()
ChatSessionEntry(key = key, updatedAtMs = updatedAt, displayName = displayName)
}
}
private fun parseRunId(resJson: String): String? =
@@ -947,44 +857,3 @@ private fun JsonElement?.asLongOrNull(): Long? =
is JsonPrimitive -> content.toLongOrNull()
else -> null
}
private fun JsonElement?.asBooleanOrNull(): Boolean? =
when (this) {
is JsonPrimitive -> content.toBooleanStrictOrNull()
else -> null
}
internal fun mergeChatSessionEntry(
existing: ChatSessionEntry,
next: ChatSessionEntry,
preserveExistingContextUsageWithoutTotal: Boolean = false,
): ChatSessionEntry {
val preserveExistingContextUsage = preserveExistingContextUsageWithoutTotal && next.totalTokens == null
return existing.copy(
updatedAtMs = next.updatedAtMs ?: existing.updatedAtMs,
displayName = next.displayName ?: existing.displayName,
totalTokens =
when {
preserveExistingContextUsage -> existing.totalTokens
next.hasContextUsageMetadata -> next.totalTokens
else -> null
},
totalTokensFresh =
when {
preserveExistingContextUsage -> existing.totalTokensFresh
next.hasContextUsageMetadata -> next.totalTokensFresh
else -> null
},
contextTokens =
when {
preserveExistingContextUsage -> next.contextTokens ?: existing.contextTokens
next.hasContextUsageMetadata -> next.contextTokens
else -> null
},
hasContextUsageMetadata =
when {
preserveExistingContextUsage -> existing.hasContextUsageMetadata || next.contextTokens != null
else -> next.hasContextUsageMetadata
},
)
}

View File

@@ -40,10 +40,6 @@ data class ChatSessionEntry(
val key: String,
val updatedAtMs: Long?,
val displayName: String? = null,
val totalTokens: Long? = null,
val totalTokensFresh: Boolean? = null,
val contextTokens: Long? = null,
val hasContextUsageMetadata: Boolean = totalTokens != null || totalTokensFresh != null || contextTokens != null,
)
/**
@@ -54,7 +50,6 @@ data class ChatHistory(
val sessionId: String?,
val thinkingLevel: String?,
val messages: List<ChatMessage>,
val sessionInfo: ChatSessionEntry? = null,
)
/**

View File

@@ -1,394 +0,0 @@
package ai.openclaw.app.ui
import ai.openclaw.app.AndroidScreenshotScene
import ai.openclaw.app.ui.design.ClawDesignTheme
import ai.openclaw.app.ui.design.ClawTheme
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ScreenShare
import androidx.compose.material.icons.filled.ChatBubble
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.WifiTethering
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@Composable
fun AndroidScreenshotModeScreen(scene: AndroidScreenshotScene) {
ClawDesignTheme(dark = true) {
Column(
modifier =
Modifier
.fillMaxSize()
.background(ClawTheme.colors.canvas)
.padding(horizontal = 20.dp, vertical = 26.dp),
verticalArrangement = Arrangement.SpaceBetween,
) {
ScreenshotHeader(scene)
ScreenshotSceneBody(scene = scene, modifier = Modifier.weight(1f))
ScreenshotTabBar(activeScene = scene)
}
}
}
@Composable
private fun ScreenshotHeader(scene: AndroidScreenshotScene) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(text = "OpenClaw", style = ClawTheme.type.title, color = ClawTheme.colors.text)
Text(
text = sceneTitle(scene),
style = ClawTheme.type.caption,
color = ClawTheme.colors.textMuted,
)
}
StatusPill(label = "Connected", color = ClawTheme.colors.success)
}
}
@Composable
private fun ScreenshotSceneBody(
scene: AndroidScreenshotScene,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth().padding(vertical = 20.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
when (scene) {
AndroidScreenshotScene.Connect -> ConnectScene()
AndroidScreenshotScene.Chat -> ChatScene()
AndroidScreenshotScene.Voice -> VoiceScene()
AndroidScreenshotScene.Screen -> ScreenScene()
AndroidScreenshotScene.Settings -> SettingsScene()
}
}
}
@Composable
private fun ConnectScene() {
FeaturePanel(icon = Icons.Default.WifiTethering, title = "Gateway paired", subtitle = "Mac Studio - Tailnet") {
MetricRow(label = "Node", value = "Android Pixel 9")
MetricRow(label = "Transport", value = "Secure WebSocket")
MetricRow(label = "Capabilities", value = "Chat, Talk, Camera, Screen")
}
CompactList(
title = "Ready",
rows =
listOf(
"Push wakes active",
"Approvals synced",
"Device tools available",
),
)
}
@Composable
private fun ChatScene() {
ChatBubble(label = "You", text = "Summarize the launch checklist before I start the release.")
ChatBubble(
label = "OpenClaw",
text = "Android archive, Play metadata, and internal testing upload are ready. Screenshots are being refreshed now.",
raised = true,
)
CompactList(
title = "Working set",
rows = listOf("Release notes", "Play bundle", "Device screenshots"),
)
}
@Composable
private fun VoiceScene() {
Box(modifier = Modifier.fillMaxWidth().padding(vertical = 20.dp), contentAlignment = Alignment.Center) {
Surface(
modifier = Modifier.size(196.dp),
shape = CircleShape,
color = ClawTheme.colors.surfaceRaised,
border = BorderStroke(1.dp, ClawTheme.colors.borderStrong),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Default.Mic,
contentDescription = null,
tint = ClawTheme.colors.primary,
modifier = Modifier.size(72.dp),
)
}
}
}
FeaturePanel(icon = Icons.Default.Mic, title = "Talk mode", subtitle = "Listening on device") {
MetricRow(label = "Wake phrase", value = "OpenClaw")
MetricRow(label = "Latency", value = "Realtime")
}
}
@Composable
private fun ScreenScene() {
FeaturePanel(icon = Icons.AutoMirrored.Filled.ScreenShare, title = "Screen tools", subtitle = "Shared with your gateway") {
MetricRow(label = "Canvas", value = "Available")
MetricRow(label = "Camera", value = "Permission granted")
MetricRow(label = "Location", value = "On request")
}
Surface(
modifier = Modifier.fillMaxWidth().height(168.dp),
shape = RoundedCornerShape(8.dp),
color = ClawTheme.colors.surfaceRaised,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(text = "Live context", style = ClawTheme.type.section, color = ClawTheme.colors.text)
ContextBar(label = "Camera", fraction = 0.74f)
ContextBar(label = "Screen", fraction = 0.58f)
ContextBar(label = "Location", fraction = 0.38f)
}
}
}
@Composable
private fun SettingsScene() {
CompactList(
title = "Security",
rows = listOf("Biometric lock enabled", "Gateway token encrypted", "Tool approvals required"),
)
CompactList(
title = "Notifications",
rows = listOf("Gateway status", "Approval requests", "Background presence"),
)
}
@Composable
private fun FeaturePanel(
icon: ImageVector,
title: String,
subtitle: String,
content: @Composable () -> Unit,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = ClawTheme.colors.surface,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
IconBox(icon = icon)
Column {
Text(text = title, style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = subtitle, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
}
}
content()
}
}
}
@Composable
private fun CompactList(
title: String,
rows: List<String>,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = ClawTheme.colors.surfaceRaised,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(text = title, style = ClawTheme.type.section, color = ClawTheme.colors.text)
rows.forEach { row ->
Row(verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier.size(7.dp).clip(CircleShape).background(ClawTheme.colors.success))
Spacer(modifier = Modifier.width(10.dp))
Text(text = row, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
}
}
}
@Composable
private fun ChatBubble(
label: String,
text: String,
raised: Boolean = false,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = if (raised) ClawTheme.colors.surfaceRaised else ClawTheme.colors.surface,
border = BorderStroke(1.dp, if (raised) ClawTheme.colors.borderStrong else ClawTheme.colors.border),
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(text = label, style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle)
Text(text = text, style = ClawTheme.type.body, color = ClawTheme.colors.text)
}
}
}
@Composable
private fun MetricRow(
label: String,
value: String,
) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(text = label, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
Text(
text = value,
style = ClawTheme.type.label,
color = ClawTheme.colors.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun ContextBar(
label: String,
fraction: Float,
) {
Column(verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text(text = label, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
Box(
modifier =
Modifier
.fillMaxWidth()
.height(7.dp)
.clip(RoundedCornerShape(4.dp))
.background(ClawTheme.colors.surfacePressed),
) {
Box(
modifier =
Modifier
.fillMaxWidth(fraction)
.height(7.dp)
.background(ClawTheme.colors.primary),
)
}
}
}
@Composable
private fun ScreenshotTabBar(activeScene: AndroidScreenshotScene) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = ClawTheme.colors.surface,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
TabIcon(icon = Icons.Default.CheckCircle, active = activeScene == AndroidScreenshotScene.Connect)
TabIcon(icon = Icons.Default.ChatBubble, active = activeScene == AndroidScreenshotScene.Chat)
TabIcon(icon = Icons.Default.Mic, active = activeScene == AndroidScreenshotScene.Voice)
TabIcon(icon = Icons.AutoMirrored.Filled.ScreenShare, active = activeScene == AndroidScreenshotScene.Screen)
TabIcon(icon = Icons.Default.Settings, active = activeScene == AndroidScreenshotScene.Settings)
}
}
}
@Composable
private fun TabIcon(
icon: ImageVector,
active: Boolean,
) {
Box(
modifier =
Modifier
.size(42.dp)
.clip(RoundedCornerShape(6.dp))
.background(if (active) ClawTheme.colors.surfacePressed else Color.Transparent),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = if (active) ClawTheme.colors.text else ClawTheme.colors.textSubtle,
modifier = Modifier.size(20.dp),
)
}
}
@Composable
private fun IconBox(icon: ImageVector) {
Box(
modifier =
Modifier
.size(42.dp)
.clip(RoundedCornerShape(8.dp))
.background(ClawTheme.colors.surfacePressed),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = ClawTheme.colors.primary,
modifier = Modifier.size(22.dp),
)
}
}
@Composable
private fun StatusPill(
label: String,
color: Color,
) {
Surface(
shape = RoundedCornerShape(8.dp),
color = ClawTheme.colors.surfaceRaised,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(modifier = Modifier.size(7.dp).clip(CircleShape).background(color))
Spacer(modifier = Modifier.width(7.dp))
Text(
text = label,
style = ClawTheme.type.caption.copy(fontWeight = FontWeight.SemiBold),
color = color,
)
}
}
}
private fun sceneTitle(scene: AndroidScreenshotScene): String =
when (scene) {
AndroidScreenshotScene.Connect -> "Connect"
AndroidScreenshotScene.Chat -> "Chat"
AndroidScreenshotScene.Voice -> "Talk"
AndroidScreenshotScene.Screen -> "Device tools"
AndroidScreenshotScene.Settings -> "Settings"
}

View File

@@ -1,7 +1,6 @@
package ai.openclaw.app.ui
import ai.openclaw.app.GatewayDeviceTokenSummary
import ai.openclaw.app.GatewayNodeApprovalState
import ai.openclaw.app.GatewayNodeSummary
import ai.openclaw.app.GatewayNodesDevicesSummary
import ai.openclaw.app.GatewayPairedDeviceSummary
@@ -156,8 +155,8 @@ private fun NodeRow(node: GatewayNodeSummary) {
badge = nodeBadge(node.displayName ?: node.id),
title = node.displayName ?: node.id,
subtitle = nodeSubtitle(node),
statusText = nodeStatusText(node),
status = nodeStatus(node),
statusText = if (node.connected) "Online" else "Offline",
status = if (node.connected) ClawStatus.Success else ClawStatus.Warning,
)
}
@@ -206,46 +205,14 @@ private fun nodeSubtitle(node: GatewayNodeSummary): String {
val kind = node.deviceFamily ?: "Node host"
val version = node.version?.let { "OpenClaw $it" }
val status = if (node.paired) "Paired" else "Unpaired"
val approval = nodeApprovalSubtitle(node.approvalState)
val commands =
node.commands
.take(2)
.joinToString(", ")
.takeIf { it.isNotBlank() }
return listOfNotNull(kind, version, status, approval, commands).joinToString(" · ")
return listOfNotNull(kind, version, status, commands).joinToString(" · ")
}
private fun nodeStatusText(node: GatewayNodeSummary): String =
when (node.approvalState) {
GatewayNodeApprovalState.PendingApproval -> "Needs approval"
GatewayNodeApprovalState.PendingReapproval -> "Needs reapproval"
GatewayNodeApprovalState.Unapproved -> "Unapproved"
else -> if (node.connected) "Online" else "Offline"
}
private fun nodeStatus(node: GatewayNodeSummary): ClawStatus =
when (node.approvalState) {
GatewayNodeApprovalState.Approved -> if (node.connected) ClawStatus.Success else ClawStatus.Warning
GatewayNodeApprovalState.PendingApproval,
GatewayNodeApprovalState.PendingReapproval,
GatewayNodeApprovalState.Unapproved,
-> ClawStatus.Warning
GatewayNodeApprovalState.Loading,
GatewayNodeApprovalState.Unsupported,
-> if (node.connected) ClawStatus.Neutral else ClawStatus.Warning
}
private fun nodeApprovalSubtitle(approvalState: GatewayNodeApprovalState): String? =
when (approvalState) {
GatewayNodeApprovalState.Approved -> "Approved"
GatewayNodeApprovalState.PendingApproval -> "Capability approval pending"
GatewayNodeApprovalState.PendingReapproval -> "Capability reapproval pending"
GatewayNodeApprovalState.Unapproved -> "Capability unapproved"
GatewayNodeApprovalState.Loading,
GatewayNodeApprovalState.Unsupported,
-> null
}
private fun pendingDeviceSubtitle(device: GatewayPendingDeviceSummary): String {
val roles = formatDeviceList(device.roles, "role")
val scopes = formatDeviceList(device.scopes, "scope")

View File

@@ -1,7 +1,6 @@
package ai.openclaw.app.ui
import ai.openclaw.app.GatewayConnectionProblem
import ai.openclaw.app.GatewayNodeApprovalState
import ai.openclaw.app.LocationMode
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.R
@@ -140,7 +139,6 @@ fun OnboardingFlow(
val gatewayConnectionProblem by viewModel.gatewayConnectionProblem.collectAsState()
val isConnected by viewModel.isConnected.collectAsState()
val isNodeConnected by viewModel.isNodeConnected.collectAsState()
val nodeCapabilityApprovalState by viewModel.nodeCapabilityApprovalState.collectAsState()
val runtimeInitialized by viewModel.runtimeInitialized.collectAsState()
val serverName by viewModel.serverName.collectAsState()
val remoteAddress by viewModel.remoteAddress.collectAsState()
@@ -149,12 +147,7 @@ fun OnboardingFlow(
val savedToken by viewModel.gatewayToken.collectAsState()
val pendingTrust by viewModel.pendingGatewayTrust.collectAsState()
val startAtGatewaySetup by viewModel.startOnboardingAtGatewaySetup.collectAsState()
val ready =
canFinishOnboarding(
isConnected = isConnected,
isNodeConnected = isNodeConnected,
nodeCapabilityApprovalState = nodeCapabilityApprovalState,
)
val ready = canFinishOnboarding(isConnected = isConnected, isNodeConnected = isNodeConnected)
var step by rememberSaveable { mutableStateOf(OnboardingStep.Welcome) }
var setupCode by rememberSaveable { mutableStateOf("") }
@@ -334,7 +327,6 @@ fun OnboardingFlow(
attemptedGatewayName = attemptedGatewayName,
remoteAddress = remoteAddress,
ready = ready,
nodeCapabilityApprovalState = nodeCapabilityApprovalState,
gatewayConnectionProblem = gatewayConnectionProblem,
connectSettling = recoveryNowMs - connectAttemptStartedAtMs < GATEWAY_CONNECT_SETTLING_MS,
onBack = { step = OnboardingStep.Gateway },
@@ -617,7 +609,6 @@ private fun GatewayRecoveryScreen(
attemptedGatewayName: String?,
remoteAddress: String?,
ready: Boolean,
nodeCapabilityApprovalState: GatewayNodeApprovalState,
gatewayConnectionProblem: GatewayConnectionProblem?,
connectSettling: Boolean,
onBack: () -> Unit,
@@ -626,14 +617,7 @@ private fun GatewayRecoveryScreen(
onContinue: () -> Unit,
modifier: Modifier = Modifier,
) {
val recoveryState =
gatewayRecoveryUiState(
ready = ready,
statusText = statusText,
connectSettling = connectSettling,
nodeCapabilityApprovalState = nodeCapabilityApprovalState,
gatewayConnectionProblem = gatewayConnectionProblem,
)
val recoveryState = gatewayRecoveryUiState(ready = ready, statusText = statusText, connectSettling = connectSettling, gatewayConnectionProblem = gatewayConnectionProblem)
val context = LocalContext.current
ClawScaffold(modifier = modifier, contentPadding = PaddingValues(horizontal = 18.dp, vertical = 16.dp)) {
@@ -645,7 +629,6 @@ private fun GatewayRecoveryScreen(
imageVector =
when (recoveryState) {
GatewayRecoveryUiState.Connected -> Icons.Default.CheckCircle
GatewayRecoveryUiState.NodeCapabilityApprovalPending -> Icons.Default.WifiTethering
GatewayRecoveryUiState.ApprovalRequired -> Icons.Default.WifiTethering
GatewayRecoveryUiState.Pairing -> Icons.Default.WifiTethering
GatewayRecoveryUiState.Finishing -> Icons.Default.WifiTethering
@@ -656,7 +639,6 @@ private fun GatewayRecoveryScreen(
tint =
when (recoveryState) {
GatewayRecoveryUiState.Connected -> ClawTheme.colors.success
GatewayRecoveryUiState.NodeCapabilityApprovalPending -> ClawTheme.colors.warning
GatewayRecoveryUiState.ApprovalRequired -> ClawTheme.colors.warning
GatewayRecoveryUiState.Pairing -> ClawTheme.colors.text
GatewayRecoveryUiState.Finishing -> ClawTheme.colors.text
@@ -676,18 +658,7 @@ private fun GatewayRecoveryScreen(
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(text = "Last gateway", style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
Text(text = recoveryGatewayName(serverName = serverName, attemptedGatewayName = attemptedGatewayName), style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(
text =
recoveryGatewayDetail(
ready = ready,
remoteAddress = remoteAddress,
statusText = statusText,
nodeCapabilityApprovalState = nodeCapabilityApprovalState,
gatewayConnectionProblem = gatewayConnectionProblem,
),
style = ClawTheme.type.body,
color = ClawTheme.colors.textMuted,
)
Text(text = recoveryGatewayDetail(ready = ready, remoteAddress = remoteAddress, statusText = statusText, gatewayConnectionProblem = gatewayConnectionProblem), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
recoveryGatewayApprovalCommand(gatewayConnectionProblem)?.let { command ->
ApprovalCommandBlock(command = command, onCopy = { copyApprovalCommand(context, command) })
}
@@ -695,7 +666,6 @@ private fun GatewayRecoveryScreen(
text =
when (recoveryState) {
GatewayRecoveryUiState.Connected -> "Healthy"
GatewayRecoveryUiState.NodeCapabilityApprovalPending -> "Node approval"
GatewayRecoveryUiState.ApprovalRequired -> "Needs approval"
GatewayRecoveryUiState.Pairing -> "Pairing"
GatewayRecoveryUiState.Finishing -> "Connecting"
@@ -704,7 +674,6 @@ private fun GatewayRecoveryScreen(
status =
when (recoveryState) {
GatewayRecoveryUiState.Connected -> ClawStatus.Success
GatewayRecoveryUiState.NodeCapabilityApprovalPending -> ClawStatus.Warning
GatewayRecoveryUiState.ApprovalRequired -> ClawStatus.Warning
GatewayRecoveryUiState.Pairing -> ClawStatus.Neutral
GatewayRecoveryUiState.Finishing -> ClawStatus.Neutral
@@ -1053,10 +1022,6 @@ internal enum class GatewayRecoveryUiState(
title = "Pairing Gateway",
message = "Approve this phone on the gateway.\nThen retry the connection.",
),
NodeCapabilityApprovalPending(
title = "Node Approval Pending",
message = "Gateway pairing worked.\nApprove this phone's node capabilities from an operator UI.",
),
Pairing(
title = "Pairing Gateway",
message = "Approval is in progress.\nOpenClaw will reconnect automatically.",
@@ -1114,14 +1079,10 @@ internal fun gatewayRecoveryUiState(
ready: Boolean,
statusText: String,
connectSettling: Boolean,
nodeCapabilityApprovalState: GatewayNodeApprovalState = GatewayNodeApprovalState.Loading,
gatewayConnectionProblem: GatewayConnectionProblem? = null,
): GatewayRecoveryUiState =
when {
ready -> GatewayRecoveryUiState.Connected
nodeCapabilityApprovalState == GatewayNodeApprovalState.PendingApproval ||
nodeCapabilityApprovalState == GatewayNodeApprovalState.PendingReapproval ||
nodeCapabilityApprovalState == GatewayNodeApprovalState.Unapproved -> GatewayRecoveryUiState.NodeCapabilityApprovalPending
gatewayConnectionProblem?.isPairingRequired == true &&
!gatewayConnectionProblem.canAutoRetry -> GatewayRecoveryUiState.ApprovalRequired
gatewayConnectionProblem?.isPairingRequired == true -> GatewayRecoveryUiState.Pairing
@@ -1129,14 +1090,9 @@ internal fun gatewayRecoveryUiState(
connectSettling -> GatewayRecoveryUiState.Finishing
gatewayStatusLooksLikePairing(statusText) -> GatewayRecoveryUiState.Pairing
gatewayStatusLooksLikePartialConnect(statusText) -> GatewayRecoveryUiState.Finishing
nodeCapabilityApprovalState == GatewayNodeApprovalState.Loading &&
gatewayStatusLooksLikeConnected(statusText) -> GatewayRecoveryUiState.Finishing
else -> GatewayRecoveryUiState.Failed
}
internal fun gatewayStatusLooksLikeConnected(statusText: String): Boolean =
gatewayStatusForDisplay(statusText).equals("Connected", ignoreCase = true)
/** Detects gateway-approved states where the Android node is still coming online. */
internal fun gatewayStatusLooksLikePartialConnect(statusText: String): Boolean {
val lower = gatewayStatusForDisplay(statusText).lowercase()
@@ -1214,19 +1170,12 @@ private fun recoveryGatewayDetail(
ready: Boolean,
remoteAddress: String?,
statusText: String,
nodeCapabilityApprovalState: GatewayNodeApprovalState,
gatewayConnectionProblem: GatewayConnectionProblem?,
): String =
remoteAddress
?.takeIf { it.isNotBlank() }
?: if (ready) {
"Ready for chat and voice"
} else if (
nodeCapabilityApprovalState == GatewayNodeApprovalState.PendingApproval ||
nodeCapabilityApprovalState == GatewayNodeApprovalState.PendingReapproval ||
nodeCapabilityApprovalState == GatewayNodeApprovalState.Unapproved
) {
"Gateway paired. Waiting for node capability approval."
} else if (gatewayConnectionProblem?.isPairingRequired == true && !gatewayConnectionProblem.canAutoRetry) {
recoveryGatewayApprovalCommand(gatewayConnectionProblem)
?.let { "Gateway approval is pending. Run this on the gateway host:" }
@@ -1235,11 +1184,6 @@ private fun recoveryGatewayDetail(
"Gateway paired. Waiting for operator access."
} else if (gatewayStatusLooksLikePairing(statusText)) {
"Gateway approval is in progress. OpenClaw will retry automatically."
} else if (
nodeCapabilityApprovalState == GatewayNodeApprovalState.Loading &&
gatewayStatusLooksLikeConnected(statusText)
) {
"Gateway paired. Checking node capability approval."
} else {
"Gateway unreachable"
}
@@ -1304,24 +1248,11 @@ private class PermissionState(
val applyToViewModel: () -> Unit,
)
/** Onboarding finishes only after the gateway resolves node capability approval. */
/** Onboarding can finish only after gateway and node channels are both ready. */
internal fun canFinishOnboarding(
isConnected: Boolean,
isNodeConnected: Boolean,
nodeCapabilityApprovalState: GatewayNodeApprovalState,
): Boolean =
isConnected &&
isNodeConnected &&
when (nodeCapabilityApprovalState) {
GatewayNodeApprovalState.PendingApproval,
GatewayNodeApprovalState.PendingReapproval,
GatewayNodeApprovalState.Unapproved,
GatewayNodeApprovalState.Loading,
-> false
GatewayNodeApprovalState.Approved,
GatewayNodeApprovalState.Unsupported,
-> true
}
): Boolean = isConnected && isNodeConnected
/** Builds permission rows and applies granted feature toggles after onboarding. */
@Composable

View File

@@ -3,7 +3,6 @@ package ai.openclaw.app.ui
import ai.openclaw.app.BuildConfig
import ai.openclaw.app.GatewayChannelsSummary
import ai.openclaw.app.GatewayDreamingSummary
import ai.openclaw.app.GatewayNodeApprovalState
import ai.openclaw.app.GatewayNodesDevicesSummary
import ai.openclaw.app.GatewaySkillSummary
import ai.openclaw.app.HomeDestination
@@ -567,7 +566,7 @@ internal fun homeAttentionRows(
} else {
null
},
if (nodesDevicesSummary.pendingDevices.isNotEmpty() || nodesDevicesSummary.hasNodeCapabilityApprovalPending()) {
if (nodesDevicesSummary.pendingDevices.isNotEmpty()) {
HomeAttentionRow("Nodes & Devices", nodesDevicesSummaryText(nodesDevicesSummary), Icons.Default.Cloud, Tab.Settings, SettingsRoute.NodesDevices)
} else {
null
@@ -998,7 +997,6 @@ private fun nodesDevicesSummaryText(summary: GatewayNodesDevicesSummary): String
val devices = summary.pairedDevices.size
return when {
summary.pendingDevices.isNotEmpty() -> "${summary.pendingDevices.size} pending"
summary.hasNodeCapabilityApprovalPending() -> "Node approval pending"
summary.nodes.isNotEmpty() -> "$online/${summary.nodes.size} online"
devices > 0 -> "$devices paired"
else -> "No devices"
@@ -1009,19 +1007,11 @@ private fun nodesDevicesSummaryText(summary: GatewayNodesDevicesSummary): String
private fun nodesDevicesStatus(summary: GatewayNodesDevicesSummary): Boolean? =
when {
summary.pendingDevices.isNotEmpty() -> false
summary.hasNodeCapabilityApprovalPending() -> false
summary.nodes.any { it.connected } -> true
summary.pairedDevices.isNotEmpty() -> true
else -> null
}
private fun GatewayNodesDevicesSummary.hasNodeCapabilityApprovalPending(): Boolean =
nodes.any { node ->
node.approvalState == GatewayNodeApprovalState.PendingApproval ||
node.approvalState == GatewayNodeApprovalState.PendingReapproval ||
node.approvalState == GatewayNodeApprovalState.Unapproved
}
/** Summarizes channel connection state, surfacing errors before connected counts. */
private fun channelsSummaryText(summary: GatewayChannelsSummary): String {
val connected = summary.channels.count { it.connected }

View File

@@ -74,7 +74,6 @@ import kotlinx.coroutines.withContext
import java.text.DateFormat
import java.util.Date
import java.util.Locale
import kotlin.math.roundToInt
/** Full chat surface that wires MainViewModel state to messages, attachments, voice, and composer actions. */
@Composable
@@ -96,7 +95,6 @@ fun ChatScreen(
val sessions by viewModel.chatSessions.collectAsState()
val chatDraft by viewModel.chatDraft.collectAsState()
val pendingAssistantAutoSend by viewModel.pendingAssistantAutoSend.collectAsState()
val contextUsage = resolveChatContextUsage(sessionKey = sessionKey, mainSessionKey = mainSessionKey, sessions = sessions)
val context = LocalContext.current
val resolver = context.contentResolver
val scope = rememberCoroutineScope()
@@ -198,7 +196,6 @@ fun ChatScreen(
onValueChange = { input = it },
attachments = attachments,
thinkingLevel = thinkingLevel,
contextUsage = contextUsage,
healthOk = healthOk,
pendingRunCount = pendingRunCount,
onThinkingLevelChange = viewModel::setChatThinkingLevel,
@@ -688,7 +685,6 @@ private fun ChatComposer(
onValueChange: (String) -> Unit,
attachments: List<PendingImageAttachment>,
thinkingLevel: String,
contextUsage: ChatContextUsage,
healthOk: Boolean,
pendingRunCount: Int,
onThinkingLevelChange: (String) -> Unit,
@@ -703,11 +699,7 @@ private fun ChatComposer(
AttachmentStrip(attachments = attachments, onRemoveAttachment = onRemoveAttachment)
}
ChatContextMeter(
thinkingLevel = thinkingLevel,
contextUsage = contextUsage,
onClick = { onThinkingLevelChange(nextThinkingValue(thinkingLevel)) },
)
ChatContextMeter(thinkingLevel = thinkingLevel, onClick = { onThinkingLevelChange(nextThinkingValue(thinkingLevel)) })
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
ChatInputPill(value = value, onValueChange = onValueChange, onPickImages = onPickImages, onVoice = onVoice, modifier = Modifier.weight(1f))
@@ -743,10 +735,8 @@ private fun ChatComposer(
@Composable
private fun ChatContextMeter(
thinkingLevel: String,
contextUsage: ChatContextUsage,
onClick: () -> Unit,
) {
val contextFraction = contextMeterWidth(contextUsage) ?: 0f
Row(
modifier = Modifier.width(178.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -765,13 +755,7 @@ private fun ChatContextMeter(
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(imageVector = Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(12.dp), tint = ClawTheme.colors.textSubtle)
Text(
text = contextMeterLabel(contextUsage, thinkingLevel),
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp),
color = ClawTheme.colors.textMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(text = "Context ${contextPercent(thinkingLevel)}%", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted)
}
}
Box(
@@ -784,7 +768,7 @@ private fun ChatContextMeter(
Box(
modifier =
Modifier
.fillMaxWidth(contextFraction)
.fillMaxWidth(thinkingMeterWidth(thinkingLevel))
.height(3.dp)
.background(ClawTheme.colors.primary, RoundedCornerShape(999.dp)),
)
@@ -918,32 +902,6 @@ private fun isActiveSessionChoice(
return choiceKey == current
}
internal data class ChatContextUsage(
val totalTokens: Long?,
val totalTokensFresh: Boolean?,
val contextTokens: Long?,
)
internal fun resolveChatContextUsage(
sessionKey: String,
mainSessionKey: String,
sessions: List<ChatSessionEntry>,
): ChatContextUsage {
val entry =
sessions.firstOrNull {
isActiveSessionChoice(
choiceKey = it.key,
sessionKey = sessionKey,
mainSessionKey = mainSessionKey,
)
}
return ChatContextUsage(
totalTokens = entry?.totalTokens,
totalTokensFresh = entry?.totalTokensFresh,
contextTokens = entry?.contextTokens,
)
}
@Composable
private fun SendButton(
enabled: Boolean,
@@ -1000,29 +958,17 @@ private fun nextThinkingValue(value: String): String =
else -> "off"
}
internal fun contextMeterWidth(usage: ChatContextUsage): Float? {
if (usage.totalTokensFresh == false) return null
val total = usage.totalTokens?.takeIf { it >= 0L } ?: return null
val context = usage.contextTokens?.takeIf { it > 0L } ?: return null
return (total.toDouble() / context.toDouble()).coerceIn(0.0, 1.0).toFloat()
}
internal fun contextMeterLabel(
usage: ChatContextUsage,
thinkingLevel: String,
): String {
val contextLabel = contextMeterWidth(usage)?.let { "Context ${(it * 100).roundToInt()}%" } ?: "Context --"
return "$contextLabel · ${contextMeterThinkingLabel(thinkingLevel)}"
}
internal fun contextMeterThinkingLabel(value: String): String =
/** Maps thinking presets to the visual context meter fill fraction. */
private fun thinkingMeterWidth(value: String): Float =
when (value.lowercase(Locale.US)) {
"low" -> "low"
"medium" -> "medium"
"high" -> "high"
else -> "off"
"low" -> 0.34f
"medium" -> 0.58f
"high" -> 0.82f
else -> 0.18f
}
private fun contextPercent(value: String): Int = (thinkingMeterWidth(value) * 100).toInt()
private fun formatChatTimestamp(timestampMs: Long): String = DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault()).format(Date(timestampMs))
/** Quick markdown detector used to avoid routing plain chat text through the markdown renderer. */

View File

@@ -1,42 +0,0 @@
package ai.openclaw.app
import android.content.Intent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class AndroidScreenshotModeTest {
@Test
fun ignoresNormalLaunches() {
assertNull(parseAndroidScreenshotModeIntent(Intent(Intent.ACTION_MAIN)))
}
@Test
fun parsesRequestedScene() {
val parsed =
parseAndroidScreenshotModeIntent(
Intent(Intent.ACTION_MAIN)
.putExtra(extraAndroidScreenshotMode, true)
.putExtra(extraAndroidScreenshotScene, "voice"),
)
assertEquals(AndroidScreenshotScene.Voice, parsed)
}
@Test
fun defaultsUnknownScenesToConnect() {
val parsed =
parseAndroidScreenshotModeIntent(
Intent(Intent.ACTION_MAIN)
.putExtra(extraAndroidScreenshotMode, true)
.putExtra(extraAndroidScreenshotScene, "unknown"),
)
assertEquals(AndroidScreenshotScene.Connect, parsed)
}
}

View File

@@ -1,118 +0,0 @@
package ai.openclaw.app
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class GatewayNodeApprovalStateTest {
@Test
fun parsesGatewayNodeApprovalState() {
assertEquals(GatewayNodeApprovalState.Approved, parseGatewayNodeApprovalState("approved"))
assertEquals(GatewayNodeApprovalState.PendingApproval, parseGatewayNodeApprovalState("pending-approval"))
assertEquals(GatewayNodeApprovalState.PendingReapproval, parseGatewayNodeApprovalState("pending-reapproval"))
assertEquals(GatewayNodeApprovalState.Unapproved, parseGatewayNodeApprovalState("unapproved"))
assertEquals(GatewayNodeApprovalState.Loading, parseGatewayNodeApprovalState(null))
assertEquals(GatewayNodeApprovalState.Loading, parseGatewayNodeApprovalState("future-state"))
}
@Test
fun parsesNodeListApprovalFields() {
val node =
parseGatewayNodeSummary(
Json.parseToJsonElement(
"""
{
"nodeId": "android-node",
"paired": true,
"connected": true,
"approvalState": "pending-approval",
"pendingRequestId": "request-1",
"caps": ["device"],
"commands": ["device.status"]
}
""".trimIndent(),
),
)
requireNotNull(node)
assertEquals(GatewayNodeApprovalState.PendingApproval, node.approvalState)
assertEquals("request-1", node.pendingRequestId)
assertEquals(listOf("device"), node.capabilities)
assertEquals(listOf("device.status"), node.commands)
}
@Test
fun treatsMissingNodeApprovalStateAsUnsupported() {
val node =
parseGatewayNodeSummary(
Json.parseToJsonElement("""{"nodeId":"android-node","paired":true,"connected":true}"""),
)
requireNotNull(node)
assertEquals(GatewayNodeApprovalState.Unsupported, node.approvalState)
assertEquals(
GatewayNodeApprovalState.Unsupported,
currentNodeCapabilityApprovalState(nodes = listOf(node), selfNodeId = "android-node"),
)
assertNull(node.pendingRequestId)
}
@Test
fun resolvesCurrentPhoneNodeApprovalState() {
val nodes =
listOf(
GatewayNodeSummary(
id = "other",
displayName = null,
remoteIp = null,
version = null,
deviceFamily = null,
paired = true,
connected = false,
approvalState = GatewayNodeApprovalState.Approved,
pendingRequestId = null,
capabilities = emptyList(),
commands = emptyList(),
),
GatewayNodeSummary(
id = "self",
displayName = null,
remoteIp = null,
version = null,
deviceFamily = null,
paired = true,
connected = true,
approvalState = GatewayNodeApprovalState.PendingApproval,
pendingRequestId = null,
capabilities = emptyList(),
commands = emptyList(),
),
)
assertEquals(
GatewayNodeApprovalState.PendingApproval,
currentNodeCapabilityApprovalState(nodes = nodes, selfNodeId = "self"),
)
assertEquals(
GatewayNodeApprovalState.Loading,
currentNodeCapabilityApprovalState(nodes = nodes, selfNodeId = "missing"),
)
}
@Test
fun ignoresStaleNodeApprovalRefreshResults() {
val guard = GatewayNodeApprovalRefreshGuard()
var approvalState = GatewayNodeApprovalState.Loading
val staleRefresh = guard.begin()
val currentRefresh = guard.begin()
assertFalse(guard.publishIfCurrent(staleRefresh) { approvalState = GatewayNodeApprovalState.Approved })
assertTrue(
guard.publishIfCurrent(currentRefresh) { approvalState = GatewayNodeApprovalState.PendingReapproval },
)
assertEquals(GatewayNodeApprovalState.PendingReapproval, approvalState)
}
}

View File

@@ -59,96 +59,4 @@ class ChatControllerSessionPolicyTest {
),
)
}
@Test
fun sessionMergeClearsUsageWhenNewSnapshotOmitsUsageMetadata() {
val existing =
ChatSessionEntry(
key = "agent:main:phone",
updatedAtMs = 1L,
displayName = "Phone",
totalTokens = 41_000L,
totalTokensFresh = true,
contextTokens = 100_000L,
)
val next =
ChatSessionEntry(
key = "agent:main:phone",
updatedAtMs = 2L,
displayName = "Phone renamed",
hasContextUsageMetadata = false,
)
val merged = mergeChatSessionEntry(existing, next)
assertEquals("agent:main:phone", merged.key)
assertEquals(2L, merged.updatedAtMs)
assertEquals("Phone renamed", merged.displayName)
assertEquals(null, merged.totalTokens)
assertEquals(null, merged.totalTokensFresh)
assertEquals(null, merged.contextTokens)
assertFalse(merged.hasContextUsageMetadata)
}
@Test
fun sessionMergePreservesUsageWhenHistorySnapshotOmitsTotalTokens() {
val existing =
ChatSessionEntry(
key = "agent:main:phone",
updatedAtMs = 1L,
displayName = "Phone",
totalTokens = 41_000L,
totalTokensFresh = true,
contextTokens = 100_000L,
)
val next =
ChatSessionEntry(
key = "agent:main:phone",
updatedAtMs = 2L,
displayName = "Phone renamed",
totalTokensFresh = false,
contextTokens = 120_000L,
)
val merged =
mergeChatSessionEntry(
existing = existing,
next = next,
preserveExistingContextUsageWithoutTotal = true,
)
assertEquals(2L, merged.updatedAtMs)
assertEquals("Phone renamed", merged.displayName)
assertEquals(41_000L, merged.totalTokens)
assertEquals(true, merged.totalTokensFresh)
assertEquals(120_000L, merged.contextTokens)
assertTrue(merged.hasContextUsageMetadata)
}
@Test
fun sessionMergeAppliesExplicitStaleUsageMetadata() {
val existing =
ChatSessionEntry(
key = "agent:main:phone",
updatedAtMs = 1L,
totalTokens = 41_000L,
totalTokensFresh = true,
contextTokens = 100_000L,
)
val next =
ChatSessionEntry(
key = "agent:main:phone",
updatedAtMs = 2L,
totalTokens = 82_000L,
totalTokensFresh = false,
contextTokens = 100_000L,
)
val merged = mergeChatSessionEntry(existing, next)
assertEquals(82_000L, merged.totalTokens)
assertEquals(false, merged.totalTokensFresh)
assertEquals(100_000L, merged.contextTokens)
assertTrue(merged.hasContextUsageMetadata)
}
}

View File

@@ -1,10 +1,6 @@
package ai.openclaw.app.ui
import ai.openclaw.app.GatewayConnectionProblem
import ai.openclaw.app.GatewayNodeApprovalState
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
@@ -13,48 +9,22 @@ import org.junit.Test
class OnboardingFlowLogicTest {
@Test
fun blocksFinishWhenOnlyOperatorIsConnected() {
assertFalse(canFinishOnboarding(isConnected = true, isNodeConnected = false, nodeCapabilityApprovalState = GatewayNodeApprovalState.Approved))
assertFalse(canFinishOnboarding(isConnected = true, isNodeConnected = false))
}
@Test
fun blocksFinishWhenDisconnected() {
assertFalse(canFinishOnboarding(isConnected = false, isNodeConnected = false, nodeCapabilityApprovalState = GatewayNodeApprovalState.Approved))
assertFalse(canFinishOnboarding(isConnected = false, isNodeConnected = false))
}
@Test
fun blocksFinishWhenOnlyNodeIsConnected() {
assertFalse(canFinishOnboarding(isConnected = false, isNodeConnected = true, nodeCapabilityApprovalState = GatewayNodeApprovalState.Approved))
assertFalse(canFinishOnboarding(isConnected = false, isNodeConnected = true))
}
@Test
fun blocksFinishWhenNodeCapabilityApprovalIsPending() {
assertFalse(canFinishOnboarding(isConnected = true, isNodeConnected = true, nodeCapabilityApprovalState = GatewayNodeApprovalState.PendingApproval))
assertFalse(canFinishOnboarding(isConnected = true, isNodeConnected = true, nodeCapabilityApprovalState = GatewayNodeApprovalState.PendingReapproval))
assertFalse(canFinishOnboarding(isConnected = true, isNodeConnected = true, nodeCapabilityApprovalState = GatewayNodeApprovalState.Unapproved))
}
@Test
fun allowsFinishWhenOperatorNodeAndCapabilityApprovalAreReady() {
assertTrue(canFinishOnboarding(isConnected = true, isNodeConnected = true, nodeCapabilityApprovalState = GatewayNodeApprovalState.Approved))
}
@Test
fun blocksFinishWhileDelayedNodeListResolvesPendingApproval() =
runTest {
val delayedNodeList = CompletableDeferred<GatewayNodeApprovalState>()
var approvalState = GatewayNodeApprovalState.Loading
val refresh = launch { approvalState = delayedNodeList.await() }
assertFalse(canFinishOnboarding(isConnected = true, isNodeConnected = true, nodeCapabilityApprovalState = approvalState))
delayedNodeList.complete(GatewayNodeApprovalState.PendingApproval)
refresh.join()
assertFalse(canFinishOnboarding(isConnected = true, isNodeConnected = true, nodeCapabilityApprovalState = approvalState))
}
@Test
fun allowsFinishWhenSuccessfulLegacyNodeListOmitsApprovalState() {
assertTrue(canFinishOnboarding(isConnected = true, isNodeConnected = true, nodeCapabilityApprovalState = GatewayNodeApprovalState.Unsupported))
fun allowsFinishOnlyWhenOperatorAndNodeAreConnected() {
assertTrue(canFinishOnboarding(isConnected = true, isNodeConnected = true))
}
@Test
@@ -128,32 +98,6 @@ class OnboardingFlowLogicTest {
)
}
@Test
fun showsNodeApprovalStateWhenCapabilityApprovalIsPending() {
assertEquals(
GatewayRecoveryUiState.NodeCapabilityApprovalPending,
gatewayRecoveryUiState(
ready = false,
statusText = "Connected",
connectSettling = false,
nodeCapabilityApprovalState = GatewayNodeApprovalState.PendingApproval,
),
)
}
@Test
fun showsFinishingStateWhileNodeApprovalLoads() {
assertEquals(
GatewayRecoveryUiState.Finishing,
gatewayRecoveryUiState(
ready = false,
statusText = "Connected",
connectSettling = false,
nodeCapabilityApprovalState = GatewayNodeApprovalState.Loading,
),
)
}
@Test
fun showsApprovalRequiredForPausedPairingProblem() {
assertEquals(

View File

@@ -3,8 +3,6 @@ package ai.openclaw.app.ui
import ai.openclaw.app.AppearanceThemeMode
import ai.openclaw.app.GatewayChannelSummary
import ai.openclaw.app.GatewayChannelsSummary
import ai.openclaw.app.GatewayNodeApprovalState
import ai.openclaw.app.GatewayNodeSummary
import ai.openclaw.app.GatewayNodesDevicesSummary
import ai.openclaw.app.GatewayPendingDeviceSummary
import org.junit.Assert.assertEquals
@@ -120,41 +118,6 @@ class ShellScreenLogicTest {
assertEquals(emptyList<String>(), rows.map { it.title })
}
@Test
fun homeAttentionRowsSurfacePendingNodeCapabilityApproval() {
val rows =
homeAttentionRows(
isConnected = true,
pendingApprovals = 0,
channelsSummary = emptyChannels(),
nodesDevicesSummary =
GatewayNodesDevicesSummary(
nodes =
listOf(
GatewayNodeSummary(
id = "android-node",
displayName = "Android",
remoteIp = null,
version = null,
deviceFamily = "Android",
paired = true,
connected = true,
approvalState = GatewayNodeApprovalState.PendingApproval,
pendingRequestId = null,
capabilities = emptyList(),
commands = emptyList(),
),
),
pendingDevices = emptyList(),
pairedDevices = emptyList(),
),
readyProviderCount = 1,
)
assertEquals(listOf("Nodes & Devices"), rows.map { it.title })
assertEquals("Node approval pending", rows.single().subtitle)
}
private fun emptyChannels(): GatewayChannelsSummary = GatewayChannelsSummary(channels = emptyList())
private fun emptyNodesDevices(): GatewayNodesDevicesSummary = GatewayNodesDevicesSummary(nodes = emptyList(), pendingDevices = emptyList(), pairedDevices = emptyList())

View File

@@ -1,84 +0,0 @@
package ai.openclaw.app.ui.chat
import ai.openclaw.app.chat.ChatSessionEntry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class ChatContextMeterTest {
@Test
fun contextMeterUsesActiveSessionTokenBudget() {
val sessions =
listOf(
ChatSessionEntry(key = "main", updatedAtMs = 1L, displayName = "Main", totalTokens = 8_000L, totalTokensFresh = true, contextTokens = 10_000L),
ChatSessionEntry(
key = "agent:main:mobile:test-device",
updatedAtMs = 2L,
displayName = "Phone",
totalTokens = 1_250L,
totalTokensFresh = true,
contextTokens = 5_000L,
),
)
val usage =
resolveChatContextUsage(
sessionKey = "agent:main:mobile:test-device",
mainSessionKey = "main",
sessions = sessions,
)
assertEquals(ChatContextUsage(totalTokens = 1_250L, totalTokensFresh = true, contextTokens = 5_000L), usage)
assertEquals(0.25f, contextMeterWidth(usage))
assertEquals("Context 25% · high", contextMeterLabel(usage, "high"))
}
@Test
fun contextMeterResolvesCanonicalMainAlias() {
val sessions =
listOf(
ChatSessionEntry(
key = "agent:main:node-phone",
updatedAtMs = 1L,
displayName = "Main",
totalTokens = 41_000L,
totalTokensFresh = true,
contextTokens = 100_000L,
),
)
val usage =
resolveChatContextUsage(
sessionKey = "main",
mainSessionKey = "agent:main:node-phone",
sessions = sessions,
)
assertEquals(ChatContextUsage(totalTokens = 41_000L, totalTokensFresh = true, contextTokens = 100_000L), usage)
assertEquals("Context 41% · off", contextMeterLabel(usage, "off"))
}
@Test
fun contextMeterDoesNotInventPercentWhenBudgetIsMissing() {
val usage = ChatContextUsage(totalTokens = 8_200L, totalTokensFresh = true, contextTokens = null)
assertNull(contextMeterWidth(usage))
assertEquals("Context -- · medium", contextMeterLabel(usage, "medium"))
}
@Test
fun contextMeterClampsOverfullSessions() {
val usage = ChatContextUsage(totalTokens = 150_000L, totalTokensFresh = true, contextTokens = 100_000L)
assertEquals(1.0f, contextMeterWidth(usage))
assertEquals("Context 100% · low", contextMeterLabel(usage, "low"))
}
@Test
fun contextMeterDoesNotDisplayStaleTokenUsage() {
val usage = ChatContextUsage(totalTokens = 82_000L, totalTokensFresh = false, contextTokens = 100_000L)
assertNull(contextMeterWidth(usage))
assertEquals("Context -- · high", contextMeterLabel(usage, "high"))
}
}

View File

@@ -1,20 +0,0 @@
# Google Play API key (pick one approach)
#
# Recommended local path:
# GOOGLE_PLAY_JSON_KEY=/absolute/path/to/google-play-service-account.json
#
# Or raw JSON content for CI:
# GOOGLE_PLAY_JSON_KEY_DATA={"type":"service_account",...}
# Optional app targeting
# GOOGLE_PLAY_PACKAGE_NAME=ai.openclaw.app
# Release target
# GOOGLE_PLAY_TRACK=internal
# GOOGLE_PLAY_RELEASE_STATUS=completed
# GOOGLE_PLAY_VALIDATE_ONLY=1
# Metadata toggles
# SUPPLY_UPLOAD_METADATA=1
# SUPPLY_UPLOAD_IMAGES=1
# SUPPLY_UPLOAD_SCREENSHOTS=1

View File

@@ -1,3 +0,0 @@
package_name(ENV["GOOGLE_PLAY_PACKAGE_NAME"] || "ai.openclaw.app")
json_key_file(ENV["GOOGLE_PLAY_JSON_KEY"]) if ENV["GOOGLE_PLAY_JSON_KEY"]

View File

@@ -1,389 +0,0 @@
require "fileutils"
require "json"
require "open3"
require "shellwords"
require "supply/client"
default_platform(:android)
DEFAULT_PLAY_PACKAGE_NAME = "ai.openclaw.app"
DEFAULT_PLAY_TRACK = "internal"
DEFAULT_PLAY_RELEASE_STATUS = "completed"
ANDROID_RELEASE_SIGNING_GRADLE_PROPERTIES = [
"OPENCLAW_ANDROID_STORE_FILE",
"OPENCLAW_ANDROID_STORE_PASSWORD",
"OPENCLAW_ANDROID_KEY_ALIAS",
"OPENCLAW_ANDROID_KEY_PASSWORD"
].freeze
def load_env_file(path)
return unless File.exist?(path)
File.foreach(path) do |line|
stripped = line.strip
next if stripped.empty? || stripped.start_with?("#")
key, value = stripped.split("=", 2)
next if key.nil? || key.empty? || value.nil?
ENV[key] = value if ENV[key].nil? || ENV[key].strip.empty?
end
end
def env_present?(value)
!value.nil? && !value.strip.empty?
end
def android_root
File.expand_path("..", __dir__)
end
def repo_root
File.expand_path("../..", android_root)
end
def android_release_signing_script
File.join(repo_root, "scripts", "android-release-signing.mjs")
end
def android_release_signing_materialized_properties_path
File.join(android_root, "build", "release-signing", "gradle.properties")
end
def shell_join(args)
args.shelljoin
end
def play_package_name
raw = ENV["GOOGLE_PLAY_PACKAGE_NAME"].to_s.strip
raw.empty? ? DEFAULT_PLAY_PACKAGE_NAME : raw
end
def play_track
raw = ENV["GOOGLE_PLAY_TRACK"].to_s.strip
raw.empty? ? DEFAULT_PLAY_TRACK : raw
end
def play_release_status
raw = ENV["GOOGLE_PLAY_RELEASE_STATUS"].to_s.strip
raw.empty? ? DEFAULT_PLAY_RELEASE_STATUS : raw
end
def play_validate_only?
ENV["GOOGLE_PLAY_VALIDATE_ONLY"] == "1"
end
def play_metadata_upload_requested?
ENV["SUPPLY_UPLOAD_METADATA"] == "1"
end
def play_screenshot_upload_requested?
ENV["SUPPLY_UPLOAD_SCREENSHOTS"] == "1"
end
def play_image_upload_requested?
ENV["SUPPLY_UPLOAD_IMAGES"] == "1"
end
def play_auth_options
json_key = ENV["GOOGLE_PLAY_JSON_KEY"].to_s.strip
json_key = ENV["SUPPLY_JSON_KEY"].to_s.strip if json_key.empty?
json_key = ENV["GOOGLE_PLAY_JSON_KEY_PATH"].to_s.strip if json_key.empty?
return { json_key: json_key } unless json_key.empty?
json_key_data = ENV["GOOGLE_PLAY_JSON_KEY_DATA"].to_s.strip
json_key_data = ENV["SUPPLY_JSON_KEY_DATA"].to_s.strip if json_key_data.empty?
return { json_key_data: json_key_data } unless json_key_data.empty?
UI.user_error!("Missing Google Play API credentials. Set GOOGLE_PLAY_JSON_KEY or GOOGLE_PLAY_JSON_KEY_DATA.")
end
def validate_play_auth!
client = nil
begin
client = Supply::Client.make_from_config(params: play_auth_options)
client.begin_edit(package_name: play_package_name)
rescue => e
UI.user_error!("Google Play API credentials are invalid for #{play_package_name}: #{e.message}")
ensure
if client&.current_edit
begin
client.abort_current_edit
rescue => e
UI.user_error!("Google Play API credentials opened a validation edit but could not close it: #{e.message}")
end
end
end
end
def read_android_version_metadata
stdout, stderr, status = Open3.capture3(
"node",
"--import",
"tsx",
File.join(repo_root, "scripts", "android-version.ts"),
"--json",
"--root",
repo_root
)
unless status.success?
detail = stderr.to_s.strip
detail = stdout.to_s.strip if detail.empty?
UI.user_error!("Failed to read Android version metadata: #{detail}")
end
parsed = JSON.parse(stdout)
version = parsed.fetch("canonicalVersion").to_s
version_code = parsed.fetch("versionCode").to_i
UI.user_error!("Android version helper returned incomplete metadata.") if version.empty? || version_code <= 0
{ version: version, version_code: version_code }
rescue JSON::ParserError => e
UI.user_error!("Invalid JSON from Android version helper: #{e.message}")
end
def sync_android_versioning!
sh(shell_join(["node", "--import", "tsx", File.join(repo_root, "scripts", "android-sync-versioning.ts"), "--check", "--root", repo_root]))
end
def android_release_notes_path
File.join(__dir__, "metadata", "android", "en-US", "release_notes.txt")
end
def validate_android_release_notes!
release_notes_path = android_release_notes_path
UI.user_error!("Missing Android release notes at #{release_notes_path}. Run `pnpm android:version:sync`.") unless File.exist?(release_notes_path)
UI.user_error!("Android release notes at #{release_notes_path} are empty.") unless env_present?(File.read(release_notes_path))
end
def android_changelog_path(version_code)
File.join(__dir__, "metadata", "android", "en-US", "changelogs", "#{version_code}.txt")
end
def sync_android_changelog!(version_code)
validate_android_release_notes!
changelog_path = android_changelog_path(version_code)
FileUtils.mkdir_p(File.dirname(changelog_path))
File.write(changelog_path, File.read(android_release_notes_path))
changelog_path
end
def play_metadata_path
File.join(__dir__, "metadata", "android")
end
def play_screenshot_paths
Dir[File.join(play_metadata_path, "**", "images", "**", "*.png")]
end
def validate_android_screenshots!
return unless play_screenshot_upload_requested?
if play_screenshot_paths.empty?
UI.user_error!("SUPPLY_UPLOAD_SCREENSHOTS=1 but no PNG screenshots were found under apps/android/fastlane/metadata/android/*/images.")
end
end
def release_artifact_path(version)
File.join(android_root, "build", "release-artifacts", "openclaw-#{version}-play-release.aab")
end
def build_release_artifacts!
sh(shell_join(["bun", File.join(android_root, "scripts", "build-release-artifacts.ts")]))
end
def capture_android_screenshots!
sh(shell_join(["bash", File.join(repo_root, "scripts", "android-screenshots.sh")]))
end
def read_android_release_signing_properties!(path)
UI.user_error!("Missing materialized Android release signing properties at #{path}.") unless File.exist?(path)
properties = {}
File.foreach(path) do |line|
stripped = line.strip
next if stripped.empty? || stripped.start_with?("#")
key, value = stripped.split("=", 2)
next if key.nil? || key.empty? || value.nil?
properties[key] = value.strip
end
missing = ANDROID_RELEASE_SIGNING_GRADLE_PROPERTIES.reject { |key| env_present?(properties[key]) }
UI.user_error!("Materialized Android release signing properties are missing: #{missing.join(', ')}.") unless missing.empty?
properties
end
def export_android_release_signing_properties!(path)
read_android_release_signing_properties!(path).each do |key, value|
ENV["ORG_GRADLE_PROJECT_#{key}"] = value
end
end
def sync_android_release_signing!
sh(shell_join(["node", android_release_signing_script, "--mode", "sync-pull"]))
export_android_release_signing_properties!(android_release_signing_materialized_properties_path)
end
def prepare_android_release_signing!
if env_present?(ENV["MATCH_PASSWORD"])
sync_android_release_signing!
elsif File.exist?(android_release_signing_materialized_properties_path)
export_android_release_signing_properties!(android_release_signing_materialized_properties_path)
end
end
def validate_android_release_signing!
Dir.chdir(android_root) do
sh(shell_join(["./gradlew", ":app:bundlePlayRelease", "--dry-run"]))
end
end
def print_android_release_plan!(version_metadata)
UI.message("Android Play release plan:")
UI.message(" package: #{play_package_name}")
UI.message(" track: #{play_track}")
UI.message(" release_status: #{play_release_status}")
UI.message(" validate_only: #{play_validate_only?}")
UI.message(" versionName: #{version_metadata.fetch(:version)}")
UI.message(" versionCode: #{version_metadata.fetch(:version_code)}")
end
def validate_android_release_preflight!(version_metadata)
validate_play_auth!
prepare_android_release_signing!
validate_android_release_signing!
validate_android_release_notes!
print_android_release_plan!(version_metadata)
end
def upload_play_store_metadata!(version_metadata)
validate_android_screenshots!
sync_android_changelog!(version_metadata.fetch(:version_code))
upload_to_play_store(
**play_auth_options,
package_name: play_package_name,
track: play_track,
version_code: version_metadata.fetch(:version_code),
metadata_path: play_metadata_path,
skip_upload_apk: true,
skip_upload_aab: true,
skip_upload_metadata: !play_metadata_upload_requested?,
skip_upload_changelogs: false,
skip_upload_images: !play_image_upload_requested?,
skip_upload_screenshots: !play_screenshot_upload_requested?,
validate_only: play_validate_only?
)
end
def upload_play_store_build!(version_metadata, upload_metadata: false, upload_images: false, upload_screenshots: false)
ENV["SUPPLY_UPLOAD_SCREENSHOTS"] = "1" if upload_screenshots
validate_android_screenshots!
sync_android_changelog!(version_metadata.fetch(:version_code))
artifact_path = release_artifact_path(version_metadata.fetch(:version))
UI.user_error!("Missing Play release artifact at #{artifact_path}. Run pnpm android:release:archive first.") unless File.exist?(artifact_path)
upload_to_play_store(
**play_auth_options,
package_name: play_package_name,
aab: artifact_path,
track: play_track,
release_status: play_release_status,
metadata_path: play_metadata_path,
skip_upload_apk: true,
skip_upload_metadata: !upload_metadata,
skip_upload_changelogs: false,
skip_upload_images: !upload_images,
skip_upload_screenshots: !upload_screenshots,
validate_only: play_validate_only?
)
end
load_env_file(File.join(__dir__, ".env"))
platform :android do
desc "Validate Google Play API credentials"
lane :auth_check do
validate_play_auth!
UI.success("Google Play API credentials are valid.")
end
desc "Print the Android release signing plan"
lane :signing_plan do
sh(shell_join(["node", android_release_signing_script, "--mode", "plan"]))
end
desc "Pull encrypted Android release signing assets and validate Gradle release signing"
lane :signing_check do
sync_android_release_signing!
validate_android_release_signing!
UI.success("Android release signing assets are available locally.")
end
desc "Pull encrypted Android release signing assets from the shared signing repo"
lane :signing_sync_pull do
sync_android_release_signing!
UI.success("Pulled Android release signing assets.")
end
desc "Create or refresh encrypted Android release signing assets in the shared signing repo"
lane :signing_sync_push do
sh(shell_join(["node", android_release_signing_script, "--mode", "sync-push"]))
UI.success("Pushed Android release signing assets.")
end
desc "Validate Android Play release auth, signing, versioning, and release notes"
lane :release_preflight do
sync_android_versioning!
version_metadata = read_android_version_metadata
validate_android_release_preflight!(version_metadata)
UI.success("Android Play release preflight passed for #{version_metadata[:version]} (#{version_metadata[:version_code]}).")
end
desc "Upload Google Play metadata, changelog, and optional screenshots"
lane :metadata do
sync_android_versioning!
version_metadata = read_android_version_metadata
ENV["SUPPLY_UPLOAD_METADATA"] = "1" unless ENV.key?("SUPPLY_UPLOAD_METADATA")
upload_play_store_metadata!(version_metadata)
UI.success("Uploaded Android Play metadata for #{version_metadata[:version]} (#{version_metadata[:version_code]}).")
end
desc "Build signed Android release artifacts locally without uploading"
lane :play_store_archive do
sync_android_versioning!
prepare_android_release_signing!
build_release_artifacts!
end
desc "Generate deterministic Android screenshots for Google Play metadata"
lane :screenshots do
capture_android_screenshots!
end
desc "Upload the signed Play AAB to Google Play"
lane :play_store do
sync_android_versioning!
version_metadata = read_android_version_metadata
upload_play_store_build!(version_metadata)
UI.success("Uploaded Android Play build to #{play_track}: version=#{version_metadata[:version]} code=#{version_metadata[:version_code]}")
end
desc "Upload Android metadata, archive release artifacts, then upload the Play AAB"
lane :release_upload do
sync_android_versioning!
version_metadata = read_android_version_metadata
validate_android_release_preflight!(version_metadata)
screenshots
ENV["SUPPLY_UPLOAD_METADATA"] = "1"
ENV["SUPPLY_UPLOAD_SCREENSHOTS"] = "1"
build_release_artifacts!
upload_play_store_build!(version_metadata, upload_metadata: true, upload_screenshots: true)
UI.success("Uploaded Android Play build to #{play_track}: version=#{version_metadata[:version]} code=#{version_metadata[:version_code]}")
UI.important("Production promotion remains manual in Google Play Console.")
end
end

View File

@@ -1,110 +0,0 @@
# fastlane setup (OpenClaw Android)
Install:
```bash
brew install fastlane
```
Create a Google Play service account JSON key with Google Play Developer API access, then grant that service account access to the OpenClaw app in Play Console.
Recommended local auth:
```bash
GOOGLE_PLAY_JSON_KEY=/absolute/path/to/google-play-service-account.json
```
Optional app targeting:
```bash
GOOGLE_PLAY_PACKAGE_NAME=ai.openclaw.app
```
Android release signing uses the same private `apps-signing` repository and `MATCH_PASSWORD` secret as iOS, but with Android-specific encrypted assets. Pull the shared upload key before release validation:
```bash
pnpm android:release:signing:plan
MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:sync:pull
MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:check
```
The pull command materializes decrypted signing files under `apps/android/build/release-signing/`, which is gitignored. Later Fastlane release commands reload those materialized values and export them to Gradle for the current process.
For the first setup or rotation, provide the Play upload keystore and a local signing properties file, then push encrypted assets to `apps-signing`:
```bash
MATCH_PASSWORD=<signing repo password> \
OPENCLAW_ANDROID_UPLOAD_KEYSTORE=<path-to-upload-keystore.jks> \
OPENCLAW_ANDROID_SIGNING_PROPERTIES=<path-to-android-signing.properties> \
pnpm android:release:signing:sync:push
```
The source signing properties file must contain:
```properties
OPENCLAW_ANDROID_STORE_PASSWORD=<store-password>
OPENCLAW_ANDROID_KEY_ALIAS=<upload-key-alias>
OPENCLAW_ANDROID_KEY_PASSWORD=<key-password>
```
Store the Google Play upload key, not the irreplaceable app signing key, when Play App Signing is enabled.
Validate auth:
```bash
cd apps/android
fastlane android auth_check
```
Archive locally without upload:
```bash
pnpm android:release:archive
```
Generate deterministic Google Play screenshots:
```bash
pnpm android:screenshots
```
Upload metadata, release notes, and the Play AAB to the internal testing track:
```bash
pnpm android:release:upload
```
Direct Fastlane entry point:
```bash
cd apps/android
fastlane android release_upload
```
Release rules:
- `apps/android/version.json` is the pinned Android release version source.
- `apps/android/Config/Version.properties` is generated from that source and read by Gradle.
- `apps/android/CHANGELOG.md` is the Android-only changelog and release-note source.
- `apps/android/fastlane/metadata/android/en-US/release_notes.txt` is generated from that changelog by `pnpm android:version:sync`.
- `apps/android/Config/ReleaseSigning.json` pins the encrypted Android signing assets in the shared signing repo.
- `MATCH_PASSWORD` enables Fastlane to pull encrypted Android signing assets into `apps/android/build/release-signing/` before release validation or archive builds.
- Supported pinned Android versions use CalVer: `YYYY.M.D`.
- `versionCode` uses `YYYYMMDDNN`, where `NN` is a two-digit build number for the pinned version.
- `pnpm android:version:pin -- --from-gateway` promotes the current root gateway version into the pinned Android release version.
- `pnpm android:version:pin -- --version 2026.6.5 --version-code 2026060502` increments another build on the same Android release train.
- `pnpm android:version:sync` updates generated version artifacts.
- `pnpm android:version:check` validates checked-in Android version artifacts.
- `pnpm android:release:preflight` validates Google Play auth, Android release signing, synced versioning, release notes, and prints the package/track/version/versionCode that will be uploaded.
- `pnpm android:release:signing:sync:pull` pulls encrypted Android signing assets from `apps-signing`.
- `pnpm android:release:signing:sync:push` creates or refreshes encrypted Android signing assets in `apps-signing`.
- `pnpm android:screenshots` builds and installs the Play debug app, launches deterministic screenshot scenes, and captures raw PNGs.
- `pnpm android:release:archive` builds the signed Play AAB and third-party APK into `apps/android/build/release-artifacts/`.
- `pnpm android:release:upload` uploads the Play AAB to the configured Google Play track. The default track is `internal`.
- Production promotion remains manual in Google Play Console.
Screenshots:
- Android screenshot capture writes raw Play screenshots under `apps/android/fastlane/metadata/android/<locale>/images/phoneScreenshots/`.
- Set `SUPPLY_UPLOAD_SCREENSHOTS=1` to include those screenshots in `fastlane android metadata`.
- Do not commit generated screenshot captures unless they become intentional store metadata assets.

View File

@@ -1,3 +0,0 @@
OpenClaw is now available on Android.
Connect to your OpenClaw Gateway to chat with your assistant, use realtime Talk mode, review approvals, and bring Android device capabilities like camera, location, screen, and notifications into your private automation workflows.

View File

@@ -1,18 +0,0 @@
OpenClaw is a personal AI assistant you run on your own devices.
Pair this Android app with your OpenClaw Gateway to use your phone as a secure node for chat, voice, approvals, and device-aware automation.
What you can do:
- Pair with your private OpenClaw Gateway by QR code or setup code
- Chat with your assistant from Android
- Use realtime Talk mode and push-to-talk
- Review Gateway action approvals from your phone
- Enable device capabilities such as camera, screen, location, and notifications when you choose
- Receive push wakes and node status updates for connected workflows
OpenClaw is local-first: you control your gateway, keys, configuration, and permissions. Device access is managed by Android permissions and can be enabled only for the capabilities you want to use.
Getting started:
1) Set up your OpenClaw Gateway
2) Open the Android app and pair with your gateway
3) Start using chat, Talk mode, approvals, and automations from your phone

View File

@@ -1,3 +0,0 @@
OpenClaw is now available on Android.
Connect to your OpenClaw Gateway to chat with your assistant, use realtime Talk mode, review approvals, and bring Android device capabilities like camera, location, screen, and notifications into your private automation workflows.

View File

@@ -1 +0,0 @@
Personal AI on your Android devices

View File

@@ -0,0 +1,163 @@
#!/usr/bin/env bun
/**
* Android release helper that bumps version fields, builds release AAB variants,
* verifies signatures, and prints SHA-256 checksums.
*/
import { $ } from "bun";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const androidDir = join(scriptDir, "..");
const buildGradlePath = join(androidDir, "app", "build.gradle.kts");
const releaseOutputDir = join(androidDir, "build", "release-bundles");
const releaseVariants = [
{
flavorName: "play",
gradleTask: ":app:bundlePlayRelease",
bundlePath: join(androidDir, "app", "build", "outputs", "bundle", "playRelease", "app-play-release.aab"),
},
{
flavorName: "third-party",
gradleTask: ":app:bundleThirdPartyRelease",
bundlePath: join(
androidDir,
"app",
"build",
"outputs",
"bundle",
"thirdPartyRelease",
"app-thirdParty-release.aab",
),
},
] as const;
type VersionState = {
versionName: string;
versionCode: number;
};
type ParsedVersionMatches = {
versionNameMatch: RegExpMatchArray;
versionCodeMatch: RegExpMatchArray;
};
function formatVersionName(date: Date): string {
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
return `${year}.${month}.${day}`;
}
function formatVersionCodePrefix(date: Date): string {
const year = date.getFullYear().toString();
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const day = date.getDate().toString().padStart(2, "0");
return `${year}${month}${day}`;
}
function parseVersionMatches(buildGradleText: string): ParsedVersionMatches {
const versionCodeMatch = buildGradleText.match(/versionCode = (\d+)/);
const versionNameMatch = buildGradleText.match(/versionName = "([^"]+)"/);
if (!versionCodeMatch || !versionNameMatch) {
throw new Error(`Couldn't parse versionName/versionCode from ${buildGradlePath}`);
}
return { versionCodeMatch, versionNameMatch };
}
function resolveNextVersionCode(currentVersionCode: number, todayPrefix: string): number {
const currentRaw = currentVersionCode.toString();
let nextSuffix = 0;
if (currentRaw.startsWith(todayPrefix)) {
const suffixRaw = currentRaw.slice(todayPrefix.length);
nextSuffix = (suffixRaw ? Number.parseInt(suffixRaw, 10) : 0) + 1;
}
if (!Number.isInteger(nextSuffix) || nextSuffix < 0 || nextSuffix > 99) {
throw new Error(
`Can't auto-bump Android versionCode for ${todayPrefix}: next suffix ${nextSuffix} is invalid`,
);
}
return Number.parseInt(`${todayPrefix}${nextSuffix.toString().padStart(2, "0")}`, 10);
}
function resolveNextVersion(buildGradleText: string, date: Date): VersionState {
const { versionCodeMatch } = parseVersionMatches(buildGradleText);
const currentVersionCode = Number.parseInt(versionCodeMatch[1] ?? "", 10);
if (!Number.isInteger(currentVersionCode)) {
throw new Error(`Invalid Android versionCode in ${buildGradlePath}`);
}
const versionName = formatVersionName(date);
const versionCode = resolveNextVersionCode(currentVersionCode, formatVersionCodePrefix(date));
return { versionName, versionCode };
}
function updateBuildGradleVersions(buildGradleText: string, nextVersion: VersionState): string {
return buildGradleText
.replace(/versionCode = \d+/, `versionCode = ${nextVersion.versionCode}`)
.replace(/versionName = "[^"]+"/, `versionName = "${nextVersion.versionName}"`);
}
async function sha256Hex(path: string): Promise<string> {
const buffer = await Bun.file(path).arrayBuffer();
const digest = await crypto.subtle.digest("SHA-256", buffer);
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
async function verifyBundleSignature(path: string): Promise<void> {
await $`jarsigner -verify ${path}`.quiet();
}
async function copyBundle(sourcePath: string, destinationPath: string): Promise<void> {
const sourceFile = Bun.file(sourcePath);
if (!(await sourceFile.exists())) {
throw new Error(`Signed bundle missing at ${sourcePath}`);
}
await Bun.write(destinationPath, sourceFile);
}
async function main() {
const buildGradleFile = Bun.file(buildGradlePath);
const originalText = await buildGradleFile.text();
const nextVersion = resolveNextVersion(originalText, new Date());
const updatedText = updateBuildGradleVersions(originalText, nextVersion);
if (updatedText === originalText) {
throw new Error("Android version bump produced no change");
}
console.log(`Android versionName -> ${nextVersion.versionName}`);
console.log(`Android versionCode -> ${nextVersion.versionCode}`);
await Bun.write(buildGradlePath, updatedText);
await $`mkdir -p ${releaseOutputDir}`;
try {
await $`./gradlew ${releaseVariants[0].gradleTask} ${releaseVariants[1].gradleTask}`.cwd(androidDir);
} catch (error) {
await Bun.write(buildGradlePath, originalText);
throw error;
}
for (const variant of releaseVariants) {
const outputPath = join(
releaseOutputDir,
`openclaw-${nextVersion.versionName}-${variant.flavorName}-release.aab`,
);
await copyBundle(variant.bundlePath, outputPath);
await verifyBundleSignature(outputPath);
const hash = await sha256Hex(outputPath);
console.log(`Signed AAB (${variant.flavorName}): ${outputPath}`);
console.log(`SHA-256 (${variant.flavorName}): ${hash}`);
}
}
await main();

View File

@@ -1,209 +0,0 @@
#!/usr/bin/env bun
/**
* Android release helper that builds signed release artifacts from the pinned
* version metadata, verifies signatures, and writes SHA-256 checksum files.
*/
import { $ } from "bun";
import { existsSync, readdirSync } from "node:fs";
import { basename, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { resolveAndroidVersion, syncAndroidVersioning } from "../../../scripts/lib/android-version.ts";
type ReleaseArtifact = {
flavorName: "play" | "third-party";
kind: "aab" | "apk";
gradleTask: string;
sourcePath: string;
};
type CliOptions = {
dryRun: boolean;
};
const scriptDir = dirname(fileURLToPath(import.meta.url));
const androidDir = join(scriptDir, "..");
const rootDir = join(androidDir, "..", "..");
const releaseOutputDir = join(androidDir, "build", "release-artifacts");
function parseArgs(argv: string[]): CliOptions {
let dryRun = false;
for (const arg of argv) {
switch (arg) {
case "--dry-run": {
dryRun = true;
break;
}
case "-h":
case "--help": {
console.log(
[
"Usage: bun apps/android/scripts/build-release-artifacts.ts [--dry-run]",
"",
"Builds the signed Play AAB and third-party APK from apps/android/version.json.",
].join("\n"),
);
process.exit(0);
}
default: {
throw new Error(`Unknown argument: ${arg}`);
}
}
}
return { dryRun };
}
function releaseArtifacts(versionName: string): ReleaseArtifact[] {
return [
{
flavorName: "play",
kind: "aab",
gradleTask: ":app:bundlePlayRelease",
sourcePath: join(
androidDir,
"app",
"build",
"outputs",
"bundle",
"playRelease",
"app-play-release.aab",
),
},
{
flavorName: "third-party",
kind: "apk",
gradleTask: ":app:assembleThirdPartyRelease",
sourcePath: join(
androidDir,
"app",
"build",
"outputs",
"apk",
"thirdParty",
"release",
`openclaw-${versionName}-thirdParty-release.apk`,
),
},
];
}
async function sha256Hex(path: string): Promise<string> {
const buffer = await Bun.file(path).arrayBuffer();
const digest = await crypto.subtle.digest("SHA-256", buffer);
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
async function writeSha256File(path: string): Promise<string> {
const hash = await sha256Hex(path);
const checksumPath = `${path}.sha256`;
await Bun.write(checksumPath, `${hash} ${basename(path)}\n`);
return hash;
}
async function verifyAabSignature(path: string): Promise<void> {
await $`jarsigner -verify ${path}`.quiet();
}
function resolveApkSignerFromSdk(sdkRoot: string | undefined): string | null {
if (!sdkRoot) {
return null;
}
const buildToolsDir = join(sdkRoot, "build-tools");
if (!existsSync(buildToolsDir)) {
return null;
}
const candidates = readdirSync(buildToolsDir)
.toSorted((left, right) => right.localeCompare(left))
.map((version) => join(buildToolsDir, version, "apksigner"))
.filter((candidate) => existsSync(candidate));
return candidates[0] ?? null;
}
async function resolveApkSigner(): Promise<string> {
const sdkApkSigner =
resolveApkSignerFromSdk(Bun.env.ANDROID_HOME) ??
resolveApkSignerFromSdk(Bun.env.ANDROID_SDK_ROOT);
if (sdkApkSigner) {
return sdkApkSigner;
}
try {
return (await $`command -v apksigner`.text()).trim();
} catch {
throw new Error(
"Missing apksigner. Install Android SDK build-tools or put apksigner on PATH.",
);
}
}
async function verifyApkSignature(path: string): Promise<void> {
const apkSigner = await resolveApkSigner();
const apkSignerProcess = Bun.spawn([apkSigner, "verify", path], {
stdout: "ignore",
stderr: "inherit",
});
const exitCode = await apkSignerProcess.exited;
if (exitCode !== 0) {
throw new Error(`apksigner verification failed for ${path}`);
}
}
async function copyArtifact(sourcePath: string, destinationPath: string): Promise<void> {
const sourceFile = Bun.file(sourcePath);
if (!(await sourceFile.exists())) {
throw new Error(`Signed release artifact missing at ${sourcePath}`);
}
await Bun.write(destinationPath, sourceFile);
}
async function verifyArtifactSignature(artifact: ReleaseArtifact, outputPath: string): Promise<void> {
if (artifact.kind === "aab") {
await verifyAabSignature(outputPath);
} else {
await verifyApkSignature(outputPath);
}
}
async function main() {
const options = parseArgs(process.argv.slice(2));
syncAndroidVersioning({ mode: "check", rootDir });
const version = resolveAndroidVersion(rootDir);
const artifacts = releaseArtifacts(version.canonicalVersion);
console.log(`Android versionName: ${version.canonicalVersion}`);
console.log(`Android versionCode: ${version.versionCode}`);
for (const artifact of artifacts) {
console.log(`Release artifact: ${artifact.flavorName} ${artifact.kind}`);
console.log(`Gradle task: ${artifact.gradleTask}`);
}
if (options.dryRun) {
console.log("Dry run complete. No Gradle tasks were executed.");
return;
}
await $`mkdir -p ${releaseOutputDir}`;
await $`./gradlew ${artifacts.map((artifact) => artifact.gradleTask)}`.cwd(androidDir);
for (const artifact of artifacts) {
const outputPath = join(
releaseOutputDir,
`openclaw-${version.canonicalVersion}-${artifact.flavorName}-release.${artifact.kind}`,
);
await copyArtifact(artifact.sourcePath, outputPath);
await verifyArtifactSignature(artifact, outputPath);
const hash = await writeSha256File(outputPath);
console.log(`Signed ${artifact.kind.toUpperCase()} (${artifact.flavorName}): ${outputPath}`);
console.log(`SHA-256 (${artifact.flavorName}): ${hash}`);
}
}
await main();

View File

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

View File

@@ -1,18 +0,0 @@
project: OpenClaw.xcodeproj
schemes:
- OpenClaw
retain_codable_properties: true
retain_swift_ui_previews: true
retain_objc_accessible: true
retain_unused_protocol_func_params: true
retain_assign_only_properties: true
relative_results: true
disable_update_check: true
report_include:
- Sources/**
- ShareExtension/**
- ActivityWidget/**
- WatchExtension/Sources/**
build_arguments:
- -destination
- generic/platform=iOS Simulator

View File

@@ -58,11 +58,11 @@ Maintenance update for the current OpenClaw release.
## 2026.5.12 - 2026-05-12
Maintenance update for the current OpenClaw release.
Maintenance update for the current OpenClaw beta release.
## 2026.5.10 - 2026-05-10
Maintenance update for the current OpenClaw release.
Maintenance update for the current OpenClaw beta release.
- Gateway connections now recover after a trusted Gateway certificate changes by refreshing the stored certificate pin during reconnect.
@@ -128,7 +128,7 @@ Maintenance update for the current OpenClaw release.
## 2026.4.19 - 2026-04-19
Maintenance update for the current OpenClaw release.
Maintenance update for the current OpenClaw beta release.
## 2026.4.18 - 2026-04-18
@@ -136,11 +136,11 @@ Maintenance update for the current OpenClaw release.
## 2026.4.15 - 2026-04-15
Maintenance update for the current OpenClaw release.
Maintenance update for the current OpenClaw beta release.
## 2026.4.14 - 2026-04-14
Maintenance update for the current OpenClaw release.
Maintenance update for the current OpenClaw beta release.
## 2026.4.12 - 2026-04-12

View File

@@ -1,53 +0,0 @@
{
"teamId": "FWJYW4S8P8",
"signingRepo": "git@github.com:openclaw/apps-signing.git",
"signingBranch": "main",
"profileType": "appstore",
"targets": [
{
"target": "OpenClaw",
"displayName": "OpenClaw",
"bundleId": "ai.openclawfoundation.app",
"platform": "IOS",
"profileKey": "OPENCLAW_APP_PROFILE",
"profileName": "OpenClaw App Store ai.openclawfoundation.app",
"capabilities": ["PUSH_NOTIFICATIONS"]
},
{
"target": "OpenClawShareExtension",
"displayName": "OpenClaw Share",
"bundleId": "ai.openclawfoundation.app.share",
"platform": "IOS",
"profileKey": "OPENCLAW_SHARE_PROFILE",
"profileName": "OpenClaw App Store ai.openclawfoundation.app.share",
"capabilities": []
},
{
"target": "OpenClawActivityWidget",
"displayName": "OpenClaw Activity Widget",
"bundleId": "ai.openclawfoundation.app.activitywidget",
"platform": "IOS",
"profileKey": "OPENCLAW_ACTIVITY_WIDGET_PROFILE",
"profileName": "OpenClaw App Store ai.openclawfoundation.app.activitywidget",
"capabilities": []
},
{
"target": "OpenClawWatchApp",
"displayName": "OpenClaw Watch App",
"bundleId": "ai.openclawfoundation.app.watchkitapp",
"platform": "IOS",
"profileKey": "OPENCLAW_WATCH_APP_PROFILE",
"profileName": "OpenClaw App Store ai.openclawfoundation.app.watchkitapp",
"capabilities": []
},
{
"target": "OpenClawWatchExtension",
"displayName": "OpenClaw Watch Extension",
"bundleId": "ai.openclawfoundation.app.watchkitapp.extension",
"platform": "IOS",
"profileKey": "OPENCLAW_WATCH_EXTENSION_PROFILE",
"profileName": "OpenClaw App Store ai.openclawfoundation.app.watchkitapp.extension",
"capabilities": []
}
]
}

View File

@@ -1,16 +1,14 @@
// Shared iOS signing defaults for local development + CI.
#include "Version.xcconfig"
OPENCLAW_IOS_DEFAULT_TEAM = FWJYW4S8P8
OPENCLAW_IOS_DEFAULT_TEAM = Y5PE65HELJ
OPENCLAW_IOS_SELECTED_TEAM = $(OPENCLAW_IOS_DEFAULT_TEAM)
OPENCLAW_DEVELOPMENT_TEAM = $(OPENCLAW_IOS_SELECTED_TEAM)
OPENCLAW_CODE_SIGN_STYLE = Automatic
OPENCLAW_CODE_SIGN_IDENTITY = Apple Development
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.extension
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
OPENCLAW_APP_BUNDLE_ID = ai.openclaw.client
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.client.watchkitapp
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.client.watchkitapp.extension
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclaw.client.activitywidget
OPENCLAW_WATCH_APP_PROFILE =
OPENCLAW_WATCH_EXTENSION_PROFILE =
@@ -20,7 +18,7 @@ OPENCLAW_WATCH_EXTENSION_PROFILE =
#include? "../LocalSigning.xcconfig"
CODE_SIGN_STYLE = $(OPENCLAW_CODE_SIGN_STYLE)
CODE_SIGN_IDENTITY = $(OPENCLAW_CODE_SIGN_IDENTITY)
CODE_SIGN_IDENTITY = Apple Development
DEVELOPMENT_TEAM = $(OPENCLAW_DEVELOPMENT_TEAM)
// Let Xcode manage provisioning for the selected local team unless a local override pins one.

View File

@@ -2,18 +2,16 @@
// This file is only an example and should stay committed.
OPENCLAW_CODE_SIGN_STYLE = Automatic
OPENCLAW_CODE_SIGN_IDENTITY = Apple Development
OPENCLAW_DEVELOPMENT_TEAM = YOUR_TEAM_ID
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
OPENCLAW_SHARE_BUNDLE_ID = ai.openclawfoundation.app.share
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.extension
OPENCLAW_APP_BUNDLE_ID = ai.openclaw.client
OPENCLAW_SHARE_BUNDLE_ID = ai.openclaw.client.share
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclaw.client.activitywidget
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.client.watchkitapp
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.client.watchkitapp.extension
// Leave empty with automatic signing.
OPENCLAW_APP_PROFILE =
OPENCLAW_SHARE_PROFILE =
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
OPENCLAW_WATCH_APP_PROFILE =
OPENCLAW_WATCH_EXTENSION_PROFILE =

View File

@@ -4,8 +4,8 @@ This iOS app is super-alpha and internal-use only. The first public App Store re
## Distribution Status
- Public distribution: App Store Connect app created; production signing is configured through the App Store release Fastlane path.
- Internal TestFlight distribution: uses the same App Store distribution archive uploaded to App Store Connect.
- Public distribution: not available.
- Internal beta distribution: local archive + TestFlight upload via Fastlane.
- Local/manual deploy from source via Xcode remains the default development path.
## Super-Alpha Disclaimer
@@ -47,7 +47,7 @@ Shortcut command (same flow + open project):
pnpm ios:open
```
## App Store Release Flow
## Local Beta Release Flow
Prereqs:
@@ -55,82 +55,51 @@ Prereqs:
- `pnpm`
- `xcodegen`
- `fastlane`
- Apple account signed into Xcode for the canonical OpenClaw team (`FWJYW4S8P8`)
- Fastlane Apple Developer Portal session for the canonical OpenClaw team when creating bundle IDs or enabling services
- Release-owner access to the encrypted signing repo password (`MATCH_PASSWORD`)
- App Store Connect app already created for `ai.openclawfoundation.app`
- App Store Connect API key set up in Keychain via `scripts/ios-app-store-connect-keychain-setup.sh` when auto-resolving a build number or uploading to App Store Connect
- Apple account signed into Xcode for automatic signing/provisioning
- App Store Connect API key set up in Keychain via `scripts/ios-asc-keychain-setup.sh` when auto-resolving a beta build number or uploading to TestFlight
Release behavior:
- Local development uses the canonical `ai.openclawfoundation.app*` bundle IDs when the OpenClaw team is available, and unique `ai.openclawfoundation.app.test.*` bundle IDs only for non-canonical fallback teams.
- App Store release uses canonical `ai.openclawfoundation.app*` bundle IDs through a temporary generated xcconfig in `apps/ios/build/AppStoreRelease.xcconfig`.
- App Store release uses manual `Apple Distribution` signing with profile names pinned in `apps/ios/Config/AppStoreSigning.json`.
- Fastlane owns one-time Developer Portal setup, encrypted `match` signing sync to the repo/branch pinned in `apps/ios/Config/AppStoreSigning.json`, and release handling.
- App Store release also switches the app to `OpenClawPushTransport=relay`, `OpenClawPushDistribution=official`, `OpenClawPushAPNsEnvironment=production`, and a production `aps-environment` entitlement.
- `pnpm ios:release:upload` generates App Store screenshots and uploads release notes before archiving and uploading the IPA.
- `pnpm ios:release` remains a compatibility alias for `pnpm ios:release:upload`; prefer the explicit upload command in new release docs and automation.
- App Review submission is manual in App Store Connect. The release lane uploads a build and metadata, but does not submit for review.
- The release flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
- Local development keeps using unique per-developer bundle IDs from `scripts/ios-configure-signing.sh`.
- Beta release uses canonical `ai.openclaw.client*` bundle IDs through a temporary generated xcconfig in `apps/ios/build/BetaRelease.xcconfig`.
- Beta release also switches the app to `OpenClawPushTransport=relay`, `OpenClawPushDistribution=official`, and `OpenClawPushAPNsEnvironment=production`.
- The beta flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
- `apps/ios/version.json` is the pinned iOS release version source.
- `apps/ios/CHANGELOG.md` is the iOS-only changelog and release-note source.
- The pinned iOS version must use CalVer like `2026.4.10`.
- That pinned value becomes:
- `CFBundleShortVersionString = 2026.4.10`
- `CFBundleVersion = next App Store Connect build number for 2026.4.10`
- `CFBundleVersion = next TestFlight build number for 2026.4.10`
- Changing the root gateway version does not change the iOS app version until you explicitly pin from the gateway.
- See `apps/ios/VERSIONING.md` for the full workflow.
Relay behavior for App Store builds:
Relay behavior for beta builds:
- Release builds default to `https://ios-push-relay.openclaw.ai`.
- Beta builds default to `https://ios-push-relay.openclaw.ai`.
- Optional custom relay override: `OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com`
This must be a plain `https://host[:port][/path]` base URL without whitespace, query params, fragments, or xcconfig metacharacters.
Signing setup commands:
```bash
pnpm ios:release:signing:plan
pnpm ios:release:signing:check
pnpm ios:release:signing:setup
MATCH_PASSWORD=... pnpm ios:release:signing:sync:push
MATCH_PASSWORD=... pnpm ios:release:signing:sync:pull
```
Release-owner secrets:
- App Store Connect API auth uses Keychain for private key material plus non-secret `apps/ios/fastlane/.env` variables.
- The encrypted signing repo password lives outside this repo in the release-owner vault and is exposed locally as `MATCH_PASSWORD`.
- Apple Distribution private keys, certificates, provisioning profiles, and decrypted signing sync output stay under `apps/ios/build/` or Keychain and are gitignored.
- Rotating release signing means refreshing Fastlane `match` assets and pushing a fresh encrypted sync state.
Prepare the generated release xcconfig/project without archiving:
```bash
pnpm ios:release:prepare -- --build-number 7
```
Archive without upload:
```bash
pnpm ios:release:archive
pnpm ios:beta:archive
```
Archive and upload to App Store Connect:
Archive and upload to TestFlight:
```bash
pnpm ios:release:upload
pnpm ios:beta
```
If you need to force a specific build number:
```bash
pnpm ios:release:upload -- --build-number 7
pnpm ios:beta -- --build-number 7
```
### Maintainer Quick Release Checklist
Use this when a clone is missing local iOS release setup and you want the shortest path to an App Store Connect upload.
Use this when a clone is missing local iOS release setup and you want the shortest path to a TestFlight upload.
1. Confirm Fastlane auth is set up:
@@ -142,58 +111,46 @@ fastlane ios auth_check
2. If auth is missing, bootstrap it once on this Mac:
```bash
scripts/ios-app-store-connect-keychain-setup.sh \
scripts/ios-asc-keychain-setup.sh \
--key-path /absolute/path/to/AuthKey_XXXXXXXXXX.p8 \
--issuer-id YOUR_ISSUER_ID \
--write-env
```
This should create `apps/ios/fastlane/.env` with non-secret App Store Connect variables while the private key stays in Keychain.
This should create `apps/ios/fastlane/.env` with the non-secret ASC variables while the private key stays in Keychain.
3. Confirm the App Store Connect app and Apple Developer identifiers/capabilities exist for:
- `ai.openclawfoundation.app`
- `ai.openclawfoundation.app.share`
- `ai.openclawfoundation.app.activitywidget`
- `ai.openclawfoundation.app.watchkitapp`
- `ai.openclawfoundation.app.watchkitapp.extension`
Use `pnpm ios:release:signing:setup` for the initial portal setup, then `MATCH_PASSWORD=... pnpm ios:release:signing:sync:push` to publish encrypted Fastlane match assets to the shared private repo.
4. Optional: set a custom official relay URL for the build. If unset, the release flow uses `https://ios-push-relay.openclaw.ai`.
3. Optional: set a custom official/TestFlight relay URL for the build. If unset, the beta flow uses `https://ios-push-relay.openclaw.ai`.
```bash
export OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com
```
5. If you are starting a brand-new production release train, pin iOS to the current gateway version first:
4. If you are starting a brand-new production release train, pin iOS to the current gateway version first:
```bash
pnpm ios:version:pin -- --from-gateway
```
6. Upload the build:
5. Upload the beta:
```bash
pnpm ios:release:upload
pnpm ios:beta
```
7. Expected behavior:
6. Expected behavior:
- Fastlane reads `apps/ios/version.json`
- verifies synced iOS versioning artifacts
- resolves the next App Store Connect build number for that short version
- generates deterministic App Store screenshots
- uploads release notes and screenshots to the editable App Store version
- generates `apps/ios/build/AppStoreRelease.xcconfig`
- resolves the next TestFlight build number for that short version
- generates `apps/ios/build/BetaRelease.xcconfig`
- archives `OpenClaw`
- uploads the IPA to App Store Connect for TestFlight/App Review use
- leaves App Review submission for a maintainer to complete manually
- uploads the IPA to TestFlight
8. Expected outputs after a successful run:
- `apps/ios/build/app-store/OpenClaw-<version>.ipa`
- `apps/ios/build/app-store/OpenClaw-<version>.app.dSYM.zip`
- Fastlane log line like `Uploaded iOS App Store build: version=<version> short=<short> build=<build>`
7. Expected outputs after a successful run:
- `apps/ios/build/beta/OpenClaw-<version>.ipa`
- `apps/ios/build/beta/OpenClaw-<version>.app.dSYM.zip`
- Fastlane log line like `Uploaded iOS beta: version=<version> short=<short> build=<build>`
9. If this is a fresh clone on a maintainer machine that already works elsewhere, it is OK to copy the non-secret `apps/ios/fastlane/.env` from another trusted local clone on the same Mac. The Keychain-backed private key remains machine-local and is not stored in the repo.
8. If this is a fresh clone on a maintainer machine that already works elsewhere, it is OK to copy the non-secret `apps/ios/fastlane/.env` from another trusted local clone on the same Mac. The Keychain-backed private key remains machine-local and is not stored in the repo.
## iOS Versioning Workflow
@@ -219,7 +176,7 @@ Recommended flow:
1. Keep `apps/ios/version.json` pinned to the current train version.
2. Update `apps/ios/CHANGELOG.md`, usually under `## Unreleased` while iterating.
3. Run `pnpm ios:version:sync` after changelog changes.
4. Upload more TestFlight builds with `pnpm ios:release:upload`.
4. Upload more TestFlight builds with `pnpm ios:beta`.
5. Let Fastlane bump only the numeric build number.
### Starting the next production release train
@@ -232,7 +189,7 @@ pnpm ios:version:pin -- --from-gateway
2. Update `apps/ios/CHANGELOG.md` for the new release as needed.
3. Run `pnpm ios:version:sync`.
4. Submit the first App Store Connect build for that newly pinned version.
4. Submit the first TestFlight build for that newly pinned version.
5. Keep iterating on that same version until the release candidate is ready.
See `apps/ios/VERSIONING.md` for the detailed spec.
@@ -240,9 +197,9 @@ See `apps/ios/VERSIONING.md` for the detailed spec.
## APNs Expectations For Local/Manual Builds
- The app calls `registerForRemoteNotifications()` at launch.
- `apps/ios/Sources/OpenClaw.entitlements` derives `aps-environment` from the active build configuration/signing override.
- `apps/ios/Sources/OpenClaw.entitlements` sets `aps-environment` to `development`.
- APNs token registration to gateway happens only after gateway connection (`push.apns.register`).
- Local/manual builds default to `OpenClawPushTransport=direct`, `OpenClawPushDistribution=local`, and a development `aps-environment` entitlement.
- Local/manual builds default to `OpenClawPushTransport=direct` and `OpenClawPushDistribution=local`.
- Your selected team/profile must support Push Notifications for the app bundle ID you are signing.
- If push capability or provisioning is wrong, APNs registration fails at runtime (check Xcode logs for `APNs registration failed`).
- The gateway host also needs direct APNs auth configured separately with `OPENCLAW_APNS_TEAM_ID`, `OPENCLAW_APNS_KEY_ID`, and either `OPENCLAW_APNS_PRIVATE_KEY_P8` or `OPENCLAW_APNS_PRIVATE_KEY_PATH`.
@@ -362,7 +319,7 @@ Automatic wake/reconnect hardening:
5. If network path is unclear:
- switch to manual host/port + TLS in Gateway Advanced settings
6. In Xcode console, filter for subsystem/category signals:
- `ai.openclawfoundation.app`
- `ai.openclaw.ios`
- `GatewayDiag`
- `APNs registration failed`
7. Validate background expectations:

View File

@@ -17,7 +17,7 @@ final class ShareViewController: UIViewController {
var attachments: [ShareAttachment]
}
private let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "ShareExtension")
private let logger = Logger(subsystem: "ai.openclaw.ios", category: "ShareExtension")
private var statusLabel: UILabel?
private let draftTextView = UITextView()
private let sendButton = UIButton(type: .system)

View File

@@ -5,21 +5,16 @@
#include "Config/Version.xcconfig"
OPENCLAW_CODE_SIGN_STYLE = Manual
OPENCLAW_CODE_SIGN_IDENTITY = Apple Development
OPENCLAW_DEVELOPMENT_TEAM = FWJYW4S8P8
OPENCLAW_DEVELOPMENT_TEAM = Y5PE65HELJ
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
OPENCLAW_SHARE_BUNDLE_ID = ai.openclawfoundation.app.share
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.extension
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT = development
OPENCLAW_APP_BUNDLE_ID = ai.openclaw.client
OPENCLAW_SHARE_BUNDLE_ID = ai.openclaw.client.share
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.client.watchkitapp
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.client.watchkitapp.extension
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclaw.client.activitywidget
OPENCLAW_APP_PROFILE = ai.openclawfoundation.app Development
OPENCLAW_SHARE_PROFILE = ai.openclawfoundation.app.share Development
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
OPENCLAW_WATCH_APP_PROFILE =
OPENCLAW_WATCH_EXTENSION_PROFILE =
OPENCLAW_APP_PROFILE = ai.openclaw.client Development
OPENCLAW_SHARE_PROFILE = ai.openclaw.client.share Development
// Keep local includes after defaults: xcconfig is evaluated top-to-bottom,
// so later assignments in local files override the defaults above.

View File

@@ -14,50 +14,7 @@ enum AppleReviewDemoMode {
}
static var agents: [AgentSummary] {
LocalChatFixture.appleReviewDemo.agents
}
}
enum ScreenshotFixtureMode {
static let gatewayName = "OpenClaw Gateway"
static let gatewayAddress = "Mac Studio on local network"
static let gatewayID = "screenshot-fixture-gateway"
static var agents: [AgentSummary] {
LocalChatFixture.appScreenshots.agents
}
}
struct LocalChatFixture {
let sessionKey: String
let sessionIDPrefix: String
let displayName: String
let subject: String
let workspace: String
let modelProvider: String
let modelID: String
let modelName: String
let responsePrefix: String
let seedMessages: [String]
let agents: [AgentSummary]
static let appleReviewDemo = LocalChatFixture(
sessionKey: "main",
sessionIDPrefix: "apple-review-demo",
displayName: "Apple Review Demo",
subject: "Gateway review flow",
workspace: "Apple Review Demo",
modelProvider: "demo",
modelID: "local-demo",
modelName: "Apple Review Demo",
responsePrefix: "Demo mode is active.",
seedMessages: [
"""
Apple Review demo mode is active. This local chat transport lets reviewers inspect the iOS app \
without a private Gateway.
""",
],
agents: [
[
AgentSummary(
id: "main",
name: "Main",
@@ -68,70 +25,12 @@ struct LocalChatFixture {
thinkinglevels: nil,
thinkingoptions: ["auto", "low", "medium"],
thinkingdefault: "auto"),
])
static let appScreenshots = LocalChatFixture(
sessionKey: "main",
sessionIDPrefix: "screenshot-fixture",
displayName: "Molty",
subject: "Mobile command center",
workspace: "OpenClaw",
modelProvider: "openai",
modelID: "gpt-5.5",
modelName: "GPT-5.5",
responsePrefix: "OpenClaw is connected to your gateway.",
seedMessages: [
"""
OpenClaw is connected to your gateway. I can coordinate agents, inspect project context, and prepare \
actions from your phone.
""",
"""
The Molty agent is ready. Recent context, voice controls, and gateway settings are available \
across the app.
""",
],
agents: [
AgentSummary(
id: "main",
name: "Molty",
identity: ["emoji": AnyCodable("M")],
workspace: "OpenClaw",
model: ["provider": AnyCodable("openai"), "model": AnyCodable("gpt-5.5")],
agentruntime: ["kind": AnyCodable("gateway")],
thinkinglevels: nil,
thinkingoptions: ["auto", "low", "medium", "high"],
thinkingdefault: "auto"),
AgentSummary(
id: "research",
name: "Research",
identity: ["emoji": AnyCodable("RS")],
workspace: "OpenClaw",
model: ["provider": AnyCodable("openai"), "model": AnyCodable("gpt-5.5")],
agentruntime: ["kind": AnyCodable("gateway")],
thinkinglevels: nil,
thinkingoptions: ["auto", "low", "medium", "high"],
thinkingdefault: "medium"),
AgentSummary(
id: "automation",
name: "Automation",
identity: ["emoji": AnyCodable("AU")],
workspace: "OpenClaw",
model: ["provider": AnyCodable("openai"), "model": AnyCodable("gpt-5.5")],
agentruntime: ["kind": AnyCodable("gateway")],
thinkinglevels: nil,
thinkingoptions: ["auto", "low", "medium", "high"],
thinkingdefault: "auto"),
])
]
}
}
struct LocalFixtureChatTransport: OpenClawChatTransport {
private let fixture: LocalChatFixture
private let store: LocalFixtureChatStore
init(fixture: LocalChatFixture) {
self.fixture = fixture
self.store = LocalFixtureChatStore(fixture: fixture)
}
struct AppleReviewDemoChatTransport: OpenClawChatTransport {
private let store = AppleReviewDemoChatStore()
func createSession(
key: String,
@@ -148,9 +47,9 @@ struct LocalFixtureChatTransport: OpenClawChatTransport {
func listModels() async throws -> [OpenClawChatModelChoice] {
[
OpenClawChatModelChoice(
modelID: self.fixture.modelID,
name: self.fixture.modelName,
provider: self.fixture.modelProvider,
modelID: "local-demo",
name: "Apple Review Demo",
provider: "demo",
contextWindow: 128_000),
]
}
@@ -202,102 +101,26 @@ struct LocalFixtureChatTransport: OpenClawChatTransport {
func compactSession(sessionKey _: String) async throws {}
}
struct AppleReviewDemoChatTransport: OpenClawChatTransport {
private let transport = LocalFixtureChatTransport(fixture: .appleReviewDemo)
func createSession(
key: String,
label: String?,
parentSessionKey: String?) async throws -> OpenClawChatCreateSessionResponse
{
try await self.transport.createSession(key: key, label: label, parentSessionKey: parentSessionKey)
}
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
try await self.transport.requestHistory(sessionKey: sessionKey)
}
func listModels() async throws -> [OpenClawChatModelChoice] {
try await self.transport.listModels()
}
func sendMessage(
sessionKey: String,
message: String,
thinking: String,
idempotencyKey: String,
attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
{
try await self.transport.sendMessage(
sessionKey: sessionKey,
message: message,
thinking: thinking,
idempotencyKey: idempotencyKey,
attachments: attachments)
}
func abortRun(sessionKey: String, runId: String) async throws {
try await self.transport.abortRun(sessionKey: sessionKey, runId: runId)
}
func listSessions(limit: Int?) async throws -> OpenClawChatSessionsListResponse {
try await self.transport.listSessions(limit: limit)
}
func setSessionModel(sessionKey: String, model: String?) async throws {
try await self.transport.setSessionModel(sessionKey: sessionKey, model: model)
}
func setSessionThinking(sessionKey: String, thinkingLevel: String) async throws {
try await self.transport.setSessionThinking(sessionKey: sessionKey, thinkingLevel: thinkingLevel)
}
func requestHealth(timeoutMs: Int) async throws -> Bool {
try await self.transport.requestHealth(timeoutMs: timeoutMs)
}
func waitForRunCompletion(runId: String, timeoutMs: Int) async -> Bool {
await self.transport.waitForRunCompletion(runId: runId, timeoutMs: timeoutMs)
}
func events() -> AsyncStream<OpenClawChatTransportEvent> {
self.transport.events()
}
func setActiveSessionKey(_ sessionKey: String) async throws {
try await self.transport.setActiveSessionKey(sessionKey)
}
func resetSession(sessionKey: String) async throws {
try await self.transport.resetSession(sessionKey: sessionKey)
}
func compactSession(sessionKey: String) async throws {
try await self.transport.compactSession(sessionKey: sessionKey)
}
}
private actor LocalFixtureChatStore {
private let fixture: LocalChatFixture
private actor AppleReviewDemoChatStore {
private let sessionKey = "main"
private var messages: [OpenClawChatMessage]
init(fixture: LocalChatFixture) {
self.fixture = fixture
self.messages = Self.seedMessages(fixture: fixture)
init() {
self.messages = AppleReviewDemoChatStore.seedMessages()
}
func createSession(key: String) throws -> OpenClawChatCreateSessionResponse {
try Self.decode(
CreateSessionPayload(ok: true, key: key, sessionId: "\(self.fixture.sessionIDPrefix)-\(key)"),
CreateSessionPayload(ok: true, key: key, sessionId: "apple-review-demo-\(key)"),
as: OpenClawChatCreateSessionResponse.self)
}
func history(sessionKey: String) throws -> OpenClawChatHistoryPayload {
let normalizedSessionKey = Self.normalizedSessionKey(sessionKey, fallback: self.fixture.sessionKey)
let normalizedSessionKey = Self.normalizedSessionKey(sessionKey)
return try Self.decode(
HistoryPayload(
sessionKey: normalizedSessionKey,
sessionId: "\(self.fixture.sessionIDPrefix)-\(normalizedSessionKey)",
sessionId: "apple-review-demo-\(normalizedSessionKey)",
messages: self.messages,
thinkingLevel: "auto"),
as: OpenClawChatHistoryPayload.self)
@@ -312,8 +135,9 @@ private actor LocalFixtureChatStore {
Self.message(
role: "assistant",
text: """
\(self.fixture.responsePrefix) I can help with \(subject), summarize current project context, \
prepare agent actions, and keep the mobile workflow connected to the gateway.
Demo mode is active. I can show the review flow locally for \(subject), including chat, agent \
selection, settings, and Gateway-connected UI states. Live automation requires pairing a real \
OpenClaw Gateway.
""",
timestamp: now + 1))
return try Self.decode(
@@ -323,15 +147,15 @@ private actor LocalFixtureChatStore {
func sessions() throws -> OpenClawChatSessionsListResponse {
let entry = OpenClawChatSessionEntry(
key: self.fixture.sessionKey,
key: self.sessionKey,
kind: "chat",
displayName: self.fixture.displayName,
displayName: "Apple Review Demo",
surface: "ios",
subject: self.fixture.subject,
subject: "Gateway review flow",
room: nil,
space: nil,
updatedAt: Date().timeIntervalSince1970 * 1000,
sessionId: "\(self.fixture.sessionIDPrefix)-\(self.fixture.sessionKey)",
sessionId: "apple-review-demo-main",
systemSent: true,
abortedLastRun: false,
thinkingLevel: "auto",
@@ -339,49 +163,50 @@ private actor LocalFixtureChatStore {
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
modelProvider: self.fixture.modelProvider,
model: self.fixture.modelID,
modelProvider: "demo",
model: "local-demo",
contextTokens: 128_000,
thinkingLevels: Self.thinkingLevels,
thinkingOptions: Self.thinkingOptions,
thinkingLevels: [
OpenClawChatThinkingLevelOption(id: "auto", label: "Auto"),
OpenClawChatThinkingLevelOption(id: "low", label: "Low"),
OpenClawChatThinkingLevelOption(id: "medium", label: "Medium"),
],
thinkingOptions: ["auto", "low", "medium"],
thinkingDefault: "auto")
return OpenClawChatSessionsListResponse(
ts: Date().timeIntervalSince1970 * 1000,
path: nil,
count: 1,
defaults: OpenClawChatSessionsDefaults(
modelProvider: self.fixture.modelProvider,
model: self.fixture.modelID,
modelProvider: "demo",
model: "local-demo",
contextTokens: 128_000,
thinkingLevels: Self.thinkingLevels,
thinkingOptions: Self.thinkingOptions,
thinkingLevels: [
OpenClawChatThinkingLevelOption(id: "auto", label: "Auto"),
OpenClawChatThinkingLevelOption(id: "low", label: "Low"),
OpenClawChatThinkingLevelOption(id: "medium", label: "Medium"),
],
thinkingOptions: ["auto", "low", "medium"],
thinkingDefault: "auto",
mainSessionKey: self.fixture.sessionKey),
mainSessionKey: self.sessionKey),
sessions: [entry])
}
func reset() {
self.messages = Self.seedMessages(fixture: self.fixture)
self.messages = Self.seedMessages()
}
private static var thinkingOptions: [String] {
["auto", "low", "medium", "high"]
}
private static var thinkingLevels: [OpenClawChatThinkingLevelOption] {
[
OpenClawChatThinkingLevelOption(id: "auto", label: "Auto"),
OpenClawChatThinkingLevelOption(id: "low", label: "Low"),
OpenClawChatThinkingLevelOption(id: "medium", label: "Medium"),
OpenClawChatThinkingLevelOption(id: "high", label: "High"),
]
}
private static func seedMessages(fixture: LocalChatFixture) -> [OpenClawChatMessage] {
private static func seedMessages() -> [OpenClawChatMessage] {
let now = Date().timeIntervalSince1970 * 1000
return fixture.seedMessages.enumerated().map { index, text in
self.message(role: "assistant", text: text, timestamp: now + Double(index))
}
return [
self.message(
role: "assistant",
text: """
Apple Review demo mode is active. This local chat transport lets reviewers inspect the iOS app \
without a private Gateway.
""",
timestamp: now),
]
}
private static func message(role: String, text: String, timestamp: Double) -> OpenClawChatMessage {
@@ -398,9 +223,9 @@ private actor LocalFixtureChatStore {
timestamp: timestamp)
}
private static func normalizedSessionKey(_ value: String, fallback: String) -> String {
private static func normalizedSessionKey(_ value: String) -> String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? fallback : trimmed
return trimmed.isEmpty ? "main" : trimmed
}
private static func decode<T: Decodable>(_ value: some Encodable, as type: T.Type) throws -> T {

View File

@@ -5,7 +5,7 @@ import OpenClawProtocol
import OSLog
struct IOSGatewayChatTransport: OpenClawChatTransport {
static let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "ios.chat.transport")
static let logger = Logger(subsystem: "ai.openclaw", category: "ios.chat.transport")
static let defaultChatSendTimeoutMs = 30000
private let gateway: GatewayNodeSession

View File

@@ -202,4 +202,10 @@ final class ContactsService: ContactsServicing {
phoneNumbers: contact.phoneNumbers.map(\.value.stringValue),
emails: contact.emailAddresses.map { String($0.value) })
}
#if DEBUG
static func _test_matches(contact: CNContact, phoneNumbers: [String], emails: [String]) -> Bool {
self.matchContacts(contacts: [contact], phoneNumbers: phoneNumbers, emails: emails) != nil
}
#endif
}

View File

@@ -1,4 +1,5 @@
import OpenClawKit
import OpenClawProtocol
import SwiftUI
extension AgentProTab {

View File

@@ -303,7 +303,7 @@ extension AgentProTab {
}
.padding(.vertical, 14)
.padding(.horizontal, 13)
.frame(maxWidth: .infinity, minHeight: AgentLayout.rowMinHeight, alignment: .leading)
.frame(minHeight: AgentLayout.rowMinHeight, alignment: .center)
.contentShape(Rectangle())
.onTapGesture {
self.appModel.setSelectedAgentId(agent.id)
@@ -557,7 +557,7 @@ extension AgentProTab {
}
var liveGatewayConnected: Bool {
!self.appModel.isLocalGatewayFixtureEnabled &&
!self.appModel.isAppleReviewDemoModeEnabled &&
self.gatewayConnected &&
self.appModel.isOperatorGatewayConnected
}

View File

@@ -1,10 +1,12 @@
import OpenClawKit
import OpenClawProtocol
import SwiftUI
struct AgentProTab: View {
@Environment(NodeAppModel.self) var appModel
@Environment(\.colorScheme) var colorScheme
@Environment(\.scenePhase) var scenePhase
let initialRoute: AgentRoute?
let directRoute: AgentRoute?
let headerLeadingAction: OpenClawSidebarHeaderAction?
let headerTitle: String
@@ -125,11 +127,13 @@ struct AgentProTab: View {
}
init(
initialRoute: AgentRoute? = nil,
directRoute: AgentRoute? = nil,
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
headerTitle: String = "Agents",
openSettings: (() -> Void)? = nil)
{
self.initialRoute = initialRoute
self.directRoute = directRoute
self.headerLeadingAction = headerLeadingAction
self.headerTitle = headerTitle
@@ -180,6 +184,9 @@ struct AgentProTab: View {
self.destination(for: route)
}
}
.onAppear {
self.applyInitialRouteIfNeeded()
}
}
private func directDestination(for route: AgentRoute) -> some View {
@@ -188,4 +195,11 @@ struct AgentProTab: View {
self.directHeaderLeadingAction(for: route) == nil ? .visible : .hidden,
for: .navigationBar)
}
private func applyInitialRouteIfNeeded() {
guard self.directRoute == nil else { return }
guard let initialRoute else { return }
guard self.navigationPath != [initialRoute] else { return }
self.navigationPath = [initialRoute]
}
}

View File

@@ -6,7 +6,7 @@ struct ChatProTab: View {
@Environment(NodeAppModel.self) private var appModel
@Environment(\.colorScheme) private var colorScheme
@State private var viewModel: OpenClawChatViewModel?
@State private var viewModelTransportModeID = ""
@State private var viewModelUsesAppleReviewDemoTransport = false
let headerLeadingAction: OpenClawSidebarHeaderAction?
let headerTitle: String?
let headerSubtitle: String?
@@ -64,7 +64,6 @@ struct ChatProTab: View {
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.safeAreaPadding(.top, 8)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.navigationBarHidden(true)
@@ -79,10 +78,6 @@ struct ChatProTab: View {
self.syncChatViewModel()
self.viewModel?.refresh()
}
.onChange(of: self.appModel.isScreenshotFixtureModeEnabled) { _, _ in
self.syncChatViewModel()
self.viewModel?.refresh()
}
.onChange(of: self.appModel.isOperatorGatewayConnected) { _, connected in
guard connected else { return }
self.syncChatViewModel()
@@ -108,6 +103,7 @@ struct ChatProTab: View {
self.connectionPillButton
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
.padding(.top, 8)
.padding(.bottom, 4)
}
@@ -139,12 +135,14 @@ struct ChatProTab: View {
private func syncChatViewModel() {
let sessionKey = self.appModel.chatSessionKey
let transportModeID = self.appModel.chatTransportModeID
let usesDemoTransport = self.appModel.isAppleReviewDemoModeEnabled
guard let viewModel else {
self.viewModelTransportModeID = transportModeID
self.viewModelUsesAppleReviewDemoTransport = usesDemoTransport
self.viewModel = OpenClawChatViewModel(
sessionKey: sessionKey,
transport: self.appModel.makeChatTransport(),
transport: usesDemoTransport
? AppleReviewDemoChatTransport()
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession),
onSessionChanged: { sessionKey in
self.appModel.focusChatSession(sessionKey)
},
@@ -153,11 +151,13 @@ struct ChatProTab: View {
})
return
}
if self.viewModelTransportModeID != transportModeID {
self.viewModelTransportModeID = transportModeID
if self.viewModelUsesAppleReviewDemoTransport != usesDemoTransport {
self.viewModelUsesAppleReviewDemoTransport = usesDemoTransport
self.viewModel = OpenClawChatViewModel(
sessionKey: sessionKey,
transport: self.appModel.makeChatTransport(),
transport: usesDemoTransport
? AppleReviewDemoChatTransport()
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession),
onSessionChanged: { sessionKey in
self.appModel.focusChatSession(sessionKey)
},
@@ -226,7 +226,7 @@ struct ChatProTab: View {
guard self.gatewayDisplayState == .connected else {
return false
}
return self.appModel.isLocalChatFixtureEnabled || self.appModel.isOperatorGatewayConnected
return self.appModel.isAppleReviewDemoModeEnabled || self.appModel.isOperatorGatewayConnected
}
private var gatewayDisplayState: GatewayDisplayState {

View File

@@ -185,3 +185,33 @@ struct CommandEmptyStateRow: View {
}
}
}
struct CommandTaskRow: View {
let item: CommandCenterTab.WorkItem
var body: some View {
HStack(alignment: .center, spacing: 6) {
Text(self.item.title)
.font(.footnote.weight(.semibold))
.lineLimit(1)
.minimumScaleFactor(0.80)
.frame(maxWidth: .infinity, minHeight: 20, alignment: .leading)
Text(self.item.detail)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.78)
.frame(width: 64, alignment: .leading)
if let progress = self.item.progress {
ProProgressBar(progress: progress, color: self.item.color)
.frame(width: 56)
}
Text(self.item.state)
.font(.footnote.weight(.medium))
.foregroundStyle(self.item.progress == nil ? self.item.color : .secondary)
.lineLimit(1)
.frame(width: self.item.progress == nil ? 58 : 34, alignment: .trailing)
}
.padding(.vertical, 8)
}
}

View File

@@ -370,11 +370,12 @@ struct CommandCenterTab: View {
}
private var sessionListAvailable: Bool {
self.appModel.isLocalChatFixtureEnabled || self.appModel.isOperatorGatewayConnected
self.appModel.isAppleReviewDemoModeEnabled || self.appModel.isOperatorGatewayConnected
}
private var sessionListMode: String {
self.appModel.chatTransportModeID
if self.appModel.isAppleReviewDemoModeEnabled { return "demo" }
return self.appModel.isOperatorGatewayConnected ? "operator" : "offline"
}
private var sessionItems: [WorkItem] {
@@ -413,7 +414,9 @@ struct CommandCenterTab: View {
}
do {
let transport = self.appModel.makeChatTransport()
let transport: any OpenClawChatTransport = self.appModel.isAppleReviewDemoModeEnabled
? AppleReviewDemoChatTransport()
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession)
let response = try await transport.listSessions(limit: Self.recentSessionsFetchLimit)
self.defaultChatSessionEntry = response.sessions.first {
$0.key == self.appModel.defaultChatSessionKey
@@ -762,7 +765,9 @@ struct CommandSessionsScreen: View {
defer { self.isLoading = false }
do {
let transport = self.appModel.makeChatTransport()
let transport: any OpenClawChatTransport = self.appModel.isAppleReviewDemoModeEnabled
? AppleReviewDemoChatTransport()
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession)
let response = try await transport.listSessions(limit: CommandCenterTab.recentSessionsFetchLimit)
self.sessions = response.sessions
} catch {
@@ -774,10 +779,11 @@ struct CommandSessionsScreen: View {
extension NodeAppModel {
fileprivate var isCommandSessionListAvailable: Bool {
self.isLocalChatFixtureEnabled || self.isOperatorGatewayConnected
self.isAppleReviewDemoModeEnabled || self.isOperatorGatewayConnected
}
fileprivate var commandSessionListMode: String {
self.chatTransportModeID
if self.isAppleReviewDemoModeEnabled { return "demo" }
return self.isOperatorGatewayConnected ? "operator" : "offline"
}
}

View File

@@ -180,11 +180,12 @@ struct IPadActivityScreen: View {
}
private var sessionsAvailable: Bool {
self.appModel.isLocalChatFixtureEnabled || self.appModel.isOperatorGatewayConnected
self.appModel.isAppleReviewDemoModeEnabled || self.appModel.isOperatorGatewayConnected
}
private var sessionsMode: String {
self.appModel.chatTransportModeID
if self.appModel.isAppleReviewDemoModeEnabled { return "demo" }
return self.appModel.isOperatorGatewayConnected ? "operator" : "offline"
}
private var sessionRows: [CommandCenterTab.WorkItem] {
@@ -214,7 +215,9 @@ struct IPadActivityScreen: View {
defer { self.isLoading = false }
do {
let transport = self.appModel.makeChatTransport()
let transport: any OpenClawChatTransport = self.appModel.isAppleReviewDemoModeEnabled
? AppleReviewDemoChatTransport()
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession)
let response = try await transport.listSessions(limit: CommandCenterTab.recentSessionsFetchLimit)
self.sessions = response.sessions
} catch {

View File

@@ -213,6 +213,32 @@ struct IPadSkillWorkshopScreen: View {
}
}
private var statusMenu: some View {
HStack(spacing: 8) {
Text("Status")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Menu {
ForEach(Self.proposalStatusFilters, id: \.self) { filter in
Button(Self.proposalStatusFilterLabel(filter)) {
self.statusFilter = filter
}
}
} label: {
HStack(spacing: 6) {
Text(self.statusFilterLabel)
.font(.subheadline.weight(.semibold))
Image(systemName: "chevron.up.chevron.down")
.font(.caption2.weight(.bold))
}
.frame(maxWidth: .infinity, alignment: .trailing)
}
.buttonStyle(.bordered)
.controlSize(.small)
.tint(self.neutralControlTint)
}
}
private var agentScopeMenu: some View {
HStack(spacing: 8) {
Text("Agent")
@@ -1104,6 +1130,7 @@ struct IPadSkillProposalRecord: Decodable {
let description: String
let createdAt: String
let updatedAt: String
let proposedVersion: String
let target: IPadSkillProposalTarget
}

View File

@@ -47,6 +47,13 @@ enum AppAppearancePreference: String, CaseIterable, Identifiable {
}
enum OpenClawBrand {
static let lightCanvasTop = Color(red: 246 / 255.0, green: 247 / 255.0, blue: 249 / 255.0)
static let lightCanvasMiddle = Color(red: 250 / 255.0, green: 251 / 255.0, blue: 252 / 255.0)
static let lightCanvasBottom = Color.white
static let darkCanvasTop = Color(red: 3 / 255.0, green: 7 / 255.0, blue: 7 / 255.0)
static let darkCanvasMiddle = Color(red: 13 / 255.0, green: 17 / 255.0, blue: 17 / 255.0)
static let darkCanvasBottom = Color(red: 17 / 255.0, green: 18 / 255.0, blue: 20 / 255.0)
static let accent = Color(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 198 / 255.0, green: 62 / 255.0, blue: 56 / 255.0, alpha: 1)
@@ -74,6 +81,11 @@ enum OpenClawBrand {
? UIColor(red: 34 / 255.0, green: 36 / 255.0, blue: 39 / 255.0, alpha: 1)
: UIColor.white
})
static let graphiteSoft = Color(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 148 / 255.0, green: 163 / 255.0, blue: 184 / 255.0, alpha: 1)
: UIColor(red: 102 / 255.0, green: 112 / 255.0, blue: 133 / 255.0, alpha: 1)
})
static var sheetBackground: LinearGradient {
LinearGradient(
@@ -85,6 +97,40 @@ enum OpenClawBrand {
startPoint: .topLeading,
endPoint: .bottomTrailing)
}
static var toolbarChrome: LinearGradient {
LinearGradient(
colors: [
graphiteElevated.opacity(0.92),
graphite.opacity(0.78),
],
startPoint: .topLeading,
endPoint: .bottomTrailing)
}
static func glassFill(brighten: Bool) -> Color {
Color.black.opacity(brighten ? 0.10 : 0.22)
}
static func glassStroke(brighten: Bool, increasedContrast: Bool, active: Bool = false) -> Color {
if active {
return self.accent.opacity(increasedContrast ? 0.70 : 0.46)
}
return Color.white.opacity(increasedContrast ? 0.50 : (brighten ? 0.24 : 0.16))
}
static func formSectionHeader(_ title: String) -> some View {
Text(title)
.font(.caption.weight(.semibold))
.foregroundStyle(self.accent)
.textCase(.uppercase)
}
static func canvasColors(for colorScheme: ColorScheme) -> [Color] {
colorScheme == .dark
? [self.darkCanvasTop, self.darkCanvasMiddle, self.darkCanvasBottom]
: [self.lightCanvasTop, self.lightCanvasMiddle, self.lightCanvasBottom]
}
}
extension View {

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