Compare commits

..

3 Commits

2249 changed files with 36523 additions and 183397 deletions

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

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

@@ -150,21 +150,9 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
- 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
@@ -321,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
@@ -330,38 +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
package name did not exist in the base tag, verify the target registry package
already exists in npm/ClawHub or stop and help the owner mint/prepublish the
package first. Do not hide or disable release surfaces just to unblock a
train unless the owner explicitly decides the plugin should not ship in that
release; first-package registry ownership is release prep, not product
rollback. The mint/prepublish path must either be the real release publish
path for the auto-bumped beta version, or a deliberately non-consuming
registry-prep step that cannot occupy the next beta version/tag. Confirm
registry owner, npm scope/package-creation permission, provenance path, and
first-package publish plan before the full release publish continues. Useful
npm probe:
`npm view <package-name> version dist-tags --json --prefer-online`; a 404 for
a package newly added to the release is a release-prep blocker, not something
to discover from the publish job.
- Use `pnpm qa:otel:smoke` when release validation needs telemetry coverage.
It starts a local OTLP/HTTP trace receiver, runs QA-lab's
`otel-trace-smoke`, and checks span names plus content/identifier redaction
@@ -607,11 +562,7 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
- Use `NPM_TOKEN` only for explicit npm dist-tag management modes, because npm
does not support trusted publishing for `npm dist-tag add`.
- `@openclaw/*` plugin publishes use a separate maintainer-only flow.
- Publishable plugins that are new to npm require owner-led first-package
minting before the full release publish. Do not consume the next beta version
with an ad-hoc manual package publish; use the release-owned auto-bumped
version path, or a non-consuming registry setup/preflight step. Bundled
disk-tree-only plugins stay unpublished.
- Only publish plugins that already exist on npm; bundled disk-tree-only plugins stay unpublished.
## Fallback local mac publish
@@ -660,18 +611,15 @@ 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.
12. Confirm the target npm version is not already published.
13. Create and push the git tag from the release branch.
14. Do not create or publish the matching GitHub release page yet. The real
publish workflow creates or undrafts it only after postpublish verification
and release evidence upload pass.
14. Create or refresh the matching GitHub release.
15. Dispatch Actions > `QA-Lab - All Lanes` against the release tag and wait
for the mock parity, live Matrix, and live Telegram credentialed-channel
lanes to pass.
@@ -694,33 +642,20 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
with `preflight_only=true` and wait for it to pass. Save that run id because
the real publish requires it to reuse the notarized mac artifacts.
21. If any preflight or validation run fails, fix the issue on a new commit,
delete the tag and any accidental draft/incomplete GitHub release, recreate
the tag from the fixed commit, and rerun all relevant preflights from
scratch before continuing. Never reuse old preflight results after the
commit changes. Once the npm version exists, do not rerun the publish
workflow for that same version; finalize the existing draft/evidence state
manually or cut a correction tag. For pushed or published beta tags, do not
delete/recreate; increment to the next beta tag. For preflight-only failures
where npm did not publish the beta version, delete/recreate the same beta
tag and any accidental draft/incomplete prerelease at the fixed commit
instead of skipping a prerelease number.
22. Start `.github/workflows/openclaw-release-publish.yml` from the same branch with
delete the tag and matching GitHub release, recreate them from the fixed
commit, and rerun all relevant preflights from scratch before continuing.
Never reuse old preflight results after the commit changes. For pushed or
published beta tags, do not delete/recreate; increment to the next beta tag.
For preflight-only failures where npm did not publish the beta version,
delete/recreate the same beta tag and prerelease at the fixed commit instead
of skipping a prerelease number.
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 recovery probe:
24. Run postpublish verification:
`node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>`.
25. Run the post-published beta verification roster. First scan current `main`
for critical fixes that landed after the release branch cut; backport only

View File

@@ -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,8 +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-additional-extension-channels
group: extension-channels
- check_name: check-additional-extension-bundled
@@ -1511,15 +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
;;
extension-channels)
run_check "lint:extensions:channels" pnpm run lint:extensions:channels
;;

View File

@@ -783,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"

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

@@ -437,17 +437,8 @@ jobs:
echo "::warning::Could not generate motion-trimmed desktop previews; continuing with screenshots and full MP4 links."
fi
read_discord_status_reaction_status() {
local lane="$1"
if [[ -f "$root/$lane/qa-evidence.json" ]]; then
jq -r '.entries[0].result.status' "$root/$lane/qa-evidence.json"
return
fi
jq -r '.scenarios[0].status' "$root/$lane/discord-qa-summary.json"
}
baseline_status="$(read_discord_status_reaction_status baseline)"
candidate_status="$(read_discord_status_reaction_status candidate)"
baseline_status="$(jq -r '.scenarios[0].status' "$root/baseline/discord-qa-summary.json")"
candidate_status="$(jq -r '.scenarios[0].status' "$root/candidate/discord-qa-summary.json")"
jq -n \
--arg baseline_status "$baseline_status" \

View File

@@ -451,17 +451,8 @@ jobs:
capture_candidate_discord_web
read_discord_thread_attachment_status() {
local lane="$1"
if [[ -f "$root/$lane/qa-evidence.json" ]]; then
jq -r '.entries[] | select(.test.id == "discord-thread-reply-filepath-attachment") | .result.status' "$root/$lane/qa-evidence.json"
return
fi
jq -r '.scenarios[] | select(.id == "discord-thread-reply-filepath-attachment") | .status' "$root/$lane/discord-qa-summary.json"
}
baseline_status="$(read_discord_thread_attachment_status baseline)"
candidate_status="$(read_discord_thread_attachment_status candidate)"
baseline_status="$(jq -r '.scenarios[] | select(.id == "discord-thread-reply-filepath-attachment") | .status' "$root/baseline/discord-qa-summary.json")"
candidate_status="$(jq -r '.scenarios[] | select(.id == "discord-thread-reply-filepath-attachment") | .status' "$root/candidate/discord-qa-summary.json")"
comparison_status="fail"
if [[ "$baseline_status" == "fail" && "$candidate_status" == "pass" ]]; then
comparison_status="pass"

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 }}
@@ -444,8 +445,8 @@ jobs:
telegram_exit=$?
set -e
if [[ ! -f "$root/qa-evidence.json" && ! -f "$root/telegram-qa-summary.json" ]]; then
echo "Telegram live QA did not produce an evidence summary." >&2
if [[ ! -f "$root/telegram-qa-summary.json" ]]; then
echo "Telegram live QA did not produce a summary." >&2
exit "$telegram_exit"
fi
echo "telegram_exit=${telegram_exit}" >> "$GITHUB_OUTPUT"

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

@@ -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"
@@ -1749,7 +1748,6 @@ jobs:
anthropic) require_any Anthropic ANTHROPIC_API_KEY ANTHROPIC_API_KEY_OLD ANTHROPIC_API_TOKEN ;;
google) require_any Google GEMINI_API_KEY GOOGLE_API_KEY ;;
minimax) require_any MiniMax MINIMAX_API_KEY ;;
moonshot) require_any Moonshot MOONSHOT_API_KEY KIMI_API_KEY ;;
openai) require_any OpenAI OPENAI_API_KEY ;;
opencode-go) require_any OpenCode OPENCODE_API_KEY OPENCODE_ZEN_API_KEY ;;
openrouter) require_any OpenRouter OPENROUTER_API_KEY ;;
@@ -1838,7 +1836,7 @@ jobs:
run: |
set -euo pipefail
all_providers=(anthropic google minimax moonshot openai opencode-go openrouter xai zai fireworks)
all_providers=(anthropic google minimax openai opencode-go openrouter xai zai fireworks)
normalize_provider() {
local value="${1,,}"
@@ -1924,7 +1922,6 @@ jobs:
anthropic) require_any Anthropic ANTHROPIC_API_KEY ANTHROPIC_API_KEY_OLD ANTHROPIC_API_TOKEN ;;
google) require_any Google GEMINI_API_KEY GOOGLE_API_KEY ;;
minimax) require_any MiniMax MINIMAX_API_KEY ;;
moonshot) require_any Moonshot MOONSHOT_API_KEY KIMI_API_KEY ;;
openai) require_any OpenAI OPENAI_API_KEY ;;
opencode-go) require_any OpenCode OPENCODE_API_KEY OPENCODE_ZEN_API_KEY ;;
openrouter) require_any OpenRouter OPENROUTER_API_KEY ;;
@@ -1957,12 +1954,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

View File

@@ -527,13 +527,6 @@ jobs:
cleanup_gateway
trap - EXIT
if node -e "const fs=require('node:fs'); const scripts=require('./package.json').scripts||{}; process.exit(scripts['test:sqlite:perf:smoke'] && fs.existsSync('scripts/bench-sqlite-state.ts') ? 0 : 1)"; then
pnpm test:sqlite:perf:smoke
cp .artifacts/sqlite-perf/smoke.json "$SOURCE_PERF_DIR/sqlite-perf-smoke.json"
else
echo "SQLite state smoke probe is not available in ${TESTED_REF}; continuing with the remaining source probes." >> "$GITHUB_STEP_SUMMARY"
fi
summary_args=(node "$PERFORMANCE_HELPER_DIR/scripts/openclaw-performance-source-summary.mjs" \
--source-dir "$SOURCE_PERF_DIR" \
--output "$SOURCE_PERF_DIR/index.md")
@@ -611,7 +604,7 @@ jobs:
## Source probes
Additional gateway boot, memory, plugin pressure, mock hello-loop, CLI startup, and SQLite state smoke numbers are in [source/index.md](source/index.md).
Additional gateway boot, memory, plugin pressure, mock hello-loop, and CLI startup numbers are in [source/index.md](source/index.md).
EOF
fi
fi

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,19 +383,11 @@ 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
dispatch_workflow() {
local workflow="$1"
shift
@@ -503,7 +397,7 @@ jobs:
-F per_page=100 \
--jq '[.workflow_runs[].id]')"
dispatch_output="$(gh workflow run --repo "$GITHUB_REPOSITORY" "$workflow" --ref "$workflow_ref" "$@" 2>&1)"
dispatch_output="$(gh workflow run --repo "$GITHUB_REPOSITORY" "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)"
printf '%s\n' "$dispatch_output" >&2
run_id="$(
printf '%s\n' "$dispatch_output" |
@@ -538,10 +432,6 @@ jobs:
printf '%s\n' "${run_id}"
}
dispatch_workflow() {
dispatch_workflow_at_ref "$CHILD_WORKFLOW_REF" "$@"
}
print_pending_deployments() {
local workflow="$1"
local run_id="$2"
@@ -763,128 +653,6 @@ jobs:
done
}
guard_existing_public_release() {
local release_version asset_name release_json is_draft has_sha has_proof has_asset release_url
if [[ "${PUBLISH_OPENCLAW_NPM}" != "true" ]]; then
return 0
fi
if ! release_json="$(gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --json isDraft,assets,body,url 2>/dev/null)"; then
return 0
fi
is_draft="$(printf '%s' "${release_json}" | jq -r '.isDraft')"
if [[ "${is_draft}" == "true" ]]; then
return 0
fi
release_version="${RELEASE_TAG#v}"
asset_name="openclaw-${release_version}-dependency-evidence.zip"
has_sha="$(printf '%s' "${release_json}" | jq --arg sha "${TARGET_SHA}" -r '.body | contains($sha)')"
has_proof="$(printf '%s' "${release_json}" | jq -r '.body | contains("### Release verification")')"
has_asset="$(printf '%s' "${release_json}" | jq --arg name "${asset_name}" -r 'any(.assets[]?; .name == $name)')"
release_url="$(printf '%s' "${release_json}" | jq -r '.url')"
if [[ "${has_sha}" == "true" && "${has_proof}" == "true" && "${has_asset}" == "true" ]]; then
return 0
fi
{
echo "Release ${RELEASE_TAG} already has a public GitHub release page without complete postpublish evidence for ${TARGET_SHA}."
echo "Refusing to reuse a public prerelease tag after publication started: ${release_url}"
echo "Create a new beta tag or delete/draft the incomplete public release before retrying."
} >&2
exit 1
}
guard_openclaw_npm_not_already_published() {
local release_version release_url
if [[ "${PUBLISH_OPENCLAW_NPM}" != "true" ]]; then
return 0
fi
release_version="${RELEASE_TAG#v}"
if ! npm view "openclaw@${release_version}" version >/dev/null 2>&1; then
return 0
fi
release_url="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}"
{
echo "openclaw@${release_version} is already published on npm."
echo "Refusing to dispatch publish child workflows for an already-published version."
echo "If this is recovery from a failed postpublish evidence or draft-release step, repair/finalize the existing draft or create a correction tag; do not rerun the publish workflow for the same npm version."
echo "Release page, if present: ${release_url}"
} >&2
exit 1
}
resolve_clawhub_release_plan() {
local -a plan_args
clawhub_plan_path="${RUNNER_TEMP}/openclaw-release-clawhub-plan.json"
plan_args=(
--release-tag "${RELEASE_TAG}"
--release-publish-branch "${CHILD_WORKFLOW_REF}"
--release-publish-run-id "${GITHUB_RUN_ID}"
--plugin-publish-scope "${PLUGIN_PUBLISH_SCOPE}"
)
if [[ -n "${PLUGINS// }" ]]; then
plan_args+=(--plugins "${PLUGINS}")
fi
CLAWHUB_REGISTRY="${CLAWHUB_REGISTRY:-https://clawhub.ai}" \
node --import tsx scripts/openclaw-release-clawhub-plan.ts "${plan_args[@]}" > "${clawhub_plan_path}"
echo "Resolved OpenClaw release ClawHub dispatch plan:"
cat "${clawhub_plan_path}"
clawhub_workflow_ref="$(jq -r '.clawHubWorkflowRef' "${clawhub_plan_path}")"
normal_plugins="$(jq -r '.summary.normalPlugins' "${clawhub_plan_path}")"
bootstrap_plugins="$(jq -r '.summary.bootstrapPlugins' "${clawhub_plan_path}")"
missing_trusted_plugins="$(jq -r '.summary.missingTrustedPlugins' "${clawhub_plan_path}")"
normal_plugin_count="$(jq -r '.summary.normalCount' "${clawhub_plan_path}")"
bootstrap_plugin_count="$(jq -r '.summary.bootstrapCount' "${clawhub_plan_path}")"
missing_trusted_plugin_count="$(jq -r '.summary.missingTrustedPublisherCount' "${clawhub_plan_path}")"
{
echo "### ClawHub release plan"
echo
echo "- Normal OIDC candidates: \`${normal_plugin_count}\`"
echo "- Bootstrap/repair candidates: \`${bootstrap_plugin_count}\`"
echo "- Existing-package trusted-publisher repairs: \`${missing_trusted_plugin_count}\`"
if [[ -n "${normal_plugins}" ]]; then
echo "- Normal plugins: \`${normal_plugins}\`"
fi
if [[ -n "${bootstrap_plugins}" ]]; then
echo "- Bootstrap/repair plugins: \`${bootstrap_plugins}\`"
fi
if [[ -n "${missing_trusted_plugins}" ]]; then
echo "- Trusted-publisher repair plugins: \`${missing_trusted_plugins}\`"
fi
} >> "$GITHUB_STEP_SUMMARY"
}
append_clawhub_dispatch_args() {
local target="$1"
while IFS=$'\t' read -r key value; do
clawhub_dispatch_args+=(-f "${key}=${value}")
done < <(jq -r --arg target "${target}" '.[$target].inputs | to_entries[] | [.key, .value] | @tsv' "${clawhub_plan_path}")
}
write_clawhub_runtime_state() {
local force_skip_clawhub="$1"
local output_path="$2"
node --import tsx scripts/openclaw-release-clawhub-runtime-state.ts \
--repository "${GITHUB_REPOSITORY}" \
--wait-for-clawhub "${WAIT_FOR_CLAWHUB}" \
--force-skip-clawhub "${force_skip_clawhub}" \
--normal-run-id "${plugin_clawhub_run_id:-}" \
--bootstrap-run-id "${plugin_clawhub_bootstrap_run_id:-}" \
--bootstrap-completed "${plugin_clawhub_bootstrap_completed:-false}" > "${output_path}"
}
create_or_update_github_release() {
local release_version notes_version title notes_file changelog_file latest_arg prerelease_args
release_version="${RELEASE_TAG#v}"
@@ -930,115 +698,14 @@ jobs:
else
gh release create "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" \
--verify-tag \
--draft \
--title "${title}" \
--notes-file "${notes_file}" \
"${prerelease_args[@]}" \
"${latest_arg}"
fi
echo "- GitHub release draft: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY"
}
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}"
@@ -1068,11 +735,9 @@ jobs:
}
verify_published_release() {
local release_version evidence_path skip_clawhub clawhub_runtime_state_path
local release_version evidence_path
local -a verify_args
skip_clawhub="${1:-false}"
release_version="${RELEASE_TAG#v}"
evidence_path="${POSTPUBLISH_EVIDENCE_DIR}/release-postpublish-evidence.json"
mkdir -p "${POSTPUBLISH_EVIDENCE_DIR}"
@@ -1085,18 +750,16 @@ jobs:
--dist-tag "${RELEASE_NPM_DIST_TAG}"
--repo "${GITHUB_REPOSITORY}"
--workflow-ref "${CHILD_WORKFLOW_REF}"
--clawhub-workflow-ref "${clawhub_workflow_ref}"
--full-release-validation-run "${FULL_RELEASE_VALIDATION_RUN_ID}"
--plugin-npm-run "${plugin_npm_run_id}"
--openclaw-npm-run "${openclaw_npm_run_id}"
--evidence-out "${evidence_path}"
--skip-github-release
)
clawhub_runtime_state_path="${RUNNER_TEMP}/openclaw-release-clawhub-runtime-state-verify.json"
write_clawhub_runtime_state "${skip_clawhub}" "${clawhub_runtime_state_path}"
while IFS= read -r arg; do
verify_args+=("${arg}")
done < <(jq -r '.verifierArgs[]' "${clawhub_runtime_state_path}")
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
verify_args+=(--plugin-clawhub-run "${plugin_clawhub_run_id}")
else
verify_args+=(--skip-clawhub)
fi
if [[ -n "${PLUGINS// }" ]]; then
verify_args+=(--plugins "${PLUGINS}")
fi
@@ -1112,7 +775,7 @@ jobs:
}
append_release_proof_to_github_release() {
local release_version body_file notes_file 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
release_version="${RELEASE_TAG#v}"
body_file="${RUNNER_TEMP}/release-body.md"
@@ -1126,20 +789,16 @@ jobs:
else
telegram_line="- npm Telegram beta E2E: not supplied"
fi
clawhub_runtime_state_path="${RUNNER_TEMP}/openclaw-release-clawhub-runtime-state-proof.json"
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}"
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
clawhub_line="- plugin ClawHub publish: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${plugin_clawhub_run_id}"
else
clawhub_line="- plugin ClawHub publish: dispatched separately, not awaited by this proof: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${plugin_clawhub_run_id}"
fi
RELEASE_BODY_FILE="${body_file}" \
RELEASE_NOTES_FILE="${notes_file}" \
RELEASE_VERSION="${release_version}" \
RELEASE_TAG="${RELEASE_TAG}" \
RELEASE_SHA="${TARGET_SHA}" \
RELEASE_REPO="${GITHUB_REPOSITORY}" \
RELEASE_TARBALL="${tarball}" \
RELEASE_INTEGRITY="${integrity}" \
@@ -1149,9 +808,7 @@ jobs:
PLUGIN_NPM_RUN_ID="${plugin_npm_run_id}" \
OPENCLAW_NPM_RUN_ID="${openclaw_npm_run_id}" \
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";
@@ -1168,17 +825,14 @@ jobs:
`- npm package: https://www.npmjs.com/package/openclaw/v/${process.env.RELEASE_VERSION}`,
`- registry tarball: ${process.env.RELEASE_TARBALL}`,
`- integrity: \`${process.env.RELEASE_INTEGRITY}\``,
`- release SHA: \`${process.env.RELEASE_SHA}\``,
`- full release CI report: https://github.com/openclaw/releases/blob/main/evidence/${process.env.RELEASE_VERSION}/release-evidence.md`,
`- release publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.RELEASE_PUBLISH_RUN_ID}`,
`- npm preflight: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.PREFLIGHT_RUN_ID}`,
`- full release validation: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.FULL_RELEASE_VALIDATION_RUN_ID}`,
`- plugin npm publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.PLUGIN_NPM_RUN_ID}`,
process.env.CLAWHUB_LINE,
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## |$)/, "");
@@ -1193,7 +847,6 @@ jobs:
echo "### Publish sequence"
echo
echo "- Workflow ref: \`${CHILD_WORKFLOW_REF}\`"
echo "- ClawHub workflow ref: release tag \`${RELEASE_TAG}\`"
echo "- Release tag: \`${RELEASE_TAG}\`"
echo "- Release SHA: \`${TARGET_SHA}\`"
echo "- Release approval: this workflow job"
@@ -1203,9 +856,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
@@ -1213,68 +863,26 @@ jobs:
fi
} >> "$GITHUB_STEP_SUMMARY"
guard_existing_public_release
guard_openclaw_npm_not_already_published
resolve_clawhub_release_plan
npm_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}" -f release_publish_run_id="${GITHUB_RUN_ID}")
clawhub_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}" -f release_publish_run_id="${GITHUB_RUN_ID}")
if [[ -n "${PLUGINS}" ]]; then
npm_args+=(-f plugins="${PLUGINS}")
clawhub_args+=(-f plugins="${PLUGINS}")
fi
plugin_npm_run_id="$(dispatch_workflow plugin-npm-release.yml "${npm_args[@]}")"
plugin_clawhub_run_id=""
if [[ "$(jq -r '.normal.shouldDispatch' "${clawhub_plan_path}")" == "true" ]]; then
clawhub_dispatch_args=()
append_clawhub_dispatch_args normal
plugin_clawhub_run_id="$(dispatch_workflow_at_ref \
"$(jq -r '.normal.ref' "${clawhub_plan_path}")" \
"$(jq -r '.normal.workflow' "${clawhub_plan_path}")" \
"${clawhub_dispatch_args[@]}")"
else
echo "- plugin-clawhub-release.yml: no normal OIDC candidates" >> "$GITHUB_STEP_SUMMARY"
fi
plugin_clawhub_bootstrap_run_id=""
plugin_clawhub_bootstrap_completed="false"
if [[ "$(jq -r '.bootstrap.shouldDispatch' "${clawhub_plan_path}")" == "true" ]]; then
clawhub_dispatch_args=()
append_clawhub_dispatch_args bootstrap
plugin_clawhub_bootstrap_run_id="$(dispatch_workflow_at_ref \
"$(jq -r '.bootstrap.ref' "${clawhub_plan_path}")" \
"$(jq -r '.bootstrap.workflow' "${clawhub_plan_path}")" \
"${clawhub_dispatch_args[@]}")"
else
echo "- plugin-clawhub-new.yml: no bootstrap candidates" >> "$GITHUB_STEP_SUMMARY"
fi
plugin_clawhub_run_id="$(dispatch_workflow plugin-clawhub-release.yml "${clawhub_args[@]}")"
{
echo "- Plugin npm run ID: \`${plugin_npm_run_id}\`"
echo "- Plugin ClawHub run ID: \`${plugin_clawhub_run_id:-none}\`"
echo "- Plugin ClawHub bootstrap run ID: \`${plugin_clawhub_bootstrap_run_id:-none}\`"
echo "- Plugin ClawHub run ID: \`${plugin_clawhub_run_id}\`"
} >> "$GITHUB_STEP_SUMMARY"
if ! wait_for_run plugin-npm-release.yml "${plugin_npm_run_id}"; then
echo "Plugin npm publish failed; cancelling dispatched ClawHub child workflows." >&2
if [[ -n "${plugin_clawhub_run_id}" ]]; then
gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_run_id}" >/dev/null 2>&1 || true
fi
if [[ -n "${plugin_clawhub_bootstrap_run_id}" ]]; then
gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_bootstrap_run_id}" >/dev/null 2>&1 || true
fi
echo "Plugin npm publish failed; cancelling ClawHub publish child ${plugin_clawhub_run_id}." >&2
gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_run_id}" >/dev/null 2>&1 || true
exit 1
fi
if [[ -n "${plugin_clawhub_bootstrap_run_id}" && "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
echo "Waiting for plugin-clawhub-new.yml bootstrap to finish before continuing release publish."
if wait_for_run plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}"; then
plugin_clawhub_bootstrap_completed="true"
else
if [[ -n "${plugin_clawhub_run_id}" ]]; then
gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_run_id}" >/dev/null 2>&1 || true
fi
exit 1
fi
fi
openclaw_npm_run_id=""
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
openclaw_npm_run_id="$(dispatch_workflow openclaw-npm-release.yml \
@@ -1291,52 +899,19 @@ jobs:
clawhub_result=""
clawhub_pid=""
clawhub_bootstrap_result=""
clawhub_bootstrap_pid=""
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
if [[ -n "${plugin_clawhub_run_id}" ]]; then
clawhub_result="$RUNNER_TEMP/clawhub-result.txt"
wait_run_pid=""
wait_for_run_background plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "${clawhub_result}"
clawhub_pid="${wait_run_pid}"
fi
if [[ -n "${plugin_clawhub_bootstrap_run_id}" ]]; then
if [[ "${plugin_clawhub_bootstrap_completed}" == "true" ]]; then
echo "- plugin-clawhub-new.yml: bootstrap already completed before continuing" >> "$GITHUB_STEP_SUMMARY"
else
clawhub_bootstrap_result="$RUNNER_TEMP/clawhub-bootstrap-result.txt"
wait_run_pid=""
wait_for_run_background plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}" "${clawhub_bootstrap_result}"
clawhub_bootstrap_pid="${wait_run_pid}"
fi
fi
clawhub_result="$RUNNER_TEMP/clawhub-result.txt"
wait_run_pid=""
wait_for_run_background plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "${clawhub_result}"
clawhub_pid="${wait_run_pid}"
else
if [[ -n "${plugin_clawhub_run_id}" ]]; then
wait_for_job_success plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "Validate release publish approval"
if approve_child_publish_environment plugin-clawhub-release.yml "${plugin_clawhub_run_id}"; then
:
else
echo "- plugin-clawhub-release.yml: child environment gate not ready; publish was left dispatched (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
fi
echo "- plugin-clawhub-release.yml: publish not awaited (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
wait_for_job_success plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "Validate release publish approval"
if approve_child_publish_environment plugin-clawhub-release.yml "${plugin_clawhub_run_id}"; then
:
else
echo "- plugin-clawhub-release.yml: no normal OIDC publish to await" >> "$GITHUB_STEP_SUMMARY"
fi
if [[ -n "${plugin_clawhub_bootstrap_run_id}" ]]; then
if [[ "${plugin_clawhub_bootstrap_completed}" == "true" ]]; then
echo "- plugin-clawhub-new.yml: bootstrap already completed before continuing" >> "$GITHUB_STEP_SUMMARY"
else
wait_for_job_success plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}" "Validate release publish approval"
if approve_child_publish_environment plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}"; then
:
else
echo "- plugin-clawhub-new.yml: child environment gate not ready; bootstrap was left dispatched (${plugin_clawhub_bootstrap_run_id})" >> "$GITHUB_STEP_SUMMARY"
fi
echo "- plugin-clawhub-new.yml: bootstrap not awaited (${plugin_clawhub_bootstrap_run_id})" >> "$GITHUB_STEP_SUMMARY"
fi
else
echo "- plugin-clawhub-new.yml: no bootstrap publish to await" >> "$GITHUB_STEP_SUMMARY"
echo "- plugin-clawhub-release.yml: child environment gate not ready; publish was left dispatched (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
fi
echo "- plugin-clawhub-release.yml: publish not awaited (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
fi
openclaw_result=""
@@ -1350,7 +925,6 @@ jobs:
failed=0
openclaw_failed=0
windows_node_run_id=""
if [[ -n "${openclaw_pid}" ]] && ! wait "${openclaw_pid}"; then
failed=1
openclaw_failed=1
@@ -1360,36 +934,21 @@ jobs:
openclaw_failed=1
fi
if [[ -n "${openclaw_npm_run_id}" && "${openclaw_failed}" == "0" ]]; then
create_or_update_github_release
upload_dependency_evidence_release_asset
fi
if [[ -n "${clawhub_pid}" ]] && ! wait "${clawhub_pid}"; then
failed=1
fi
if [[ -f "${clawhub_result}" && "$(cat "${clawhub_result}")" != "success" ]]; then
failed=1
fi
if [[ -n "${clawhub_bootstrap_pid}" ]] && ! wait "${clawhub_bootstrap_pid}"; then
failed=1
fi
if [[ -f "${clawhub_bootstrap_result}" && "$(cat "${clawhub_bootstrap_result}")" != "success" ]]; then
failed=1
fi
if [[ -n "${openclaw_npm_run_id}" && "${openclaw_failed}" == "0" ]]; then
if [[ "${failed}" == "0" ]]; then
verify_published_release
else
verify_published_release true
fi
create_or_update_github_release
upload_dependency_evidence_release_asset
if ! promote_windows_release_assets; then
failed=1
fi
if [[ "${failed}" == "0" && -n "${openclaw_npm_run_id}" ]]; then
verify_published_release
append_release_proof_to_github_release
if [[ "${failed}" == "0" ]]; then
publish_github_release
else
echo "- GitHub release: left as draft because a required publish child failed" >> "$GITHUB_STEP_SUMMARY"
fi
fi
if [[ "${failed}" != "0" ]]; then
exit 1

View File

@@ -1,504 +0,0 @@
name: Plugin ClawHub New
on:
workflow_dispatch:
inputs:
plugins:
description: Comma-separated plugin package names to bootstrap on ClawHub
required: true
type: string
ref:
description: Commit SHA on main, a release branch, or the matching Tideclaw alpha branch to publish from; defaults to the workflow ref
required: false
default: ""
type: string
release_publish_run_id:
description: Approved OpenClaw Release Publish workflow run id
required: false
type: string
release_publish_branch:
description: Branch name of the approving OpenClaw Release Publish workflow run
required: false
type: string
dry_run:
description: Validate the token-gated ClawHub bootstrap handoff without publishing.
required: false
default: false
type: boolean
concurrency:
group: plugin-clawhub-new-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24.15.0"
CLAWHUB_REGISTRY: "https://clawhub.ai"
CLAWHUB_CLI_PACKAGE: "clawhub@0.21.0"
jobs:
resolve_bootstrap_plan:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
ref_revision: ${{ steps.ref.outputs.sha }}
has_bootstrap_candidates: ${{ steps.plan.outputs.has_bootstrap_candidates }}
bootstrap_candidate_count: ${{ steps.plan.outputs.bootstrap_candidate_count }}
matrix: ${{ steps.plan.outputs.matrix }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ github.ref }}
fetch-depth: 0
- name: Resolve checked-out ref
id: ref
env:
TARGET_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
run: |
set -euo pipefail
git fetch --no-tags origin \
+refs/heads/main:refs/remotes/origin/main \
'+refs/heads/release/*:refs/remotes/origin/release/*'
if [[ -n "${TARGET_REF}" ]]; then
if git rev-parse --verify --quiet "${TARGET_REF}^{commit}" >/dev/null; then
target_sha="$(git rev-parse "${TARGET_REF}^{commit}")"
elif git rev-parse --verify --quiet "origin/${TARGET_REF}^{commit}" >/dev/null; then
target_sha="$(git rev-parse "origin/${TARGET_REF}^{commit}")"
else
echo "Unable to resolve requested publish ref: ${TARGET_REF}" >&2
exit 1
fi
git checkout --detach "${target_sha}"
fi
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Validate ref is on a trusted publish branch
env:
TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
run: |
set -euo pipefail
if git merge-base --is-ancestor HEAD origin/main; then
exit 0
fi
while IFS= read -r release_ref; do
if git merge-base --is-ancestor HEAD "${release_ref}"; then
exit 0
fi
done < <(git for-each-ref --format='%(refname)' refs/remotes/origin/release)
if [[ "${TRUSTED_PUBLISH_BRANCH}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
alpha_branch="${TRUSTED_PUBLISH_BRANCH}"
git fetch --no-tags origin "+refs/heads/${alpha_branch}:refs/remotes/origin/${alpha_branch}"
if git merge-base --is-ancestor HEAD "refs/remotes/origin/${alpha_branch}"; then
exit 0
fi
fi
echo "Plugin ClawHub bootstraps must target a commit reachable from main, release/*, or the matching Tideclaw alpha branch." >&2
exit 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
install-bun: "false"
- name: Validate publishable plugin metadata
env:
RELEASE_PLUGINS: ${{ inputs.plugins }}
run: |
set -euo pipefail
if [[ -z "${RELEASE_PLUGINS// }" ]]; then
echo "Plugin ClawHub bootstrap requires at least one package name in plugins." >&2
exit 1
fi
pnpm release:plugins:clawhub:check -- --selection-mode selected --plugins "${RELEASE_PLUGINS}"
- name: Resolve plugin bootstrap plan
id: plan
env:
RELEASE_PLUGINS: ${{ inputs.plugins }}
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
run: |
set -euo pipefail
mkdir -p .local
node --import tsx scripts/plugin-clawhub-release-plan.ts \
--selection-mode selected \
--plugins "${RELEASE_PLUGINS}" > .local/plugin-clawhub-release-plan.json
cat .local/plugin-clawhub-release-plan.json
bootstrap_candidate_count="$(jq -r '(.bootstrapCandidates | length) + (.missingTrustedPublisher | length)' .local/plugin-clawhub-release-plan.json)"
selected_count="$(jq -r '.all | length' .local/plugin-clawhub-release-plan.json)"
matrix_json="$(
jq -c '
[
.bootstrapCandidates[]? + {
bootstrapMode: "publish",
requiresManualOverride: false
},
.missingTrustedPublisher[]? + {
bootstrapMode: (if .alreadyPublished then "configure-only" else "publish" end),
requiresManualOverride: true
}
]
' .local/plugin-clawhub-release-plan.json
)"
has_bootstrap_candidates="false"
if [[ "${bootstrap_candidate_count}" != "0" ]]; then
has_bootstrap_candidates="true"
fi
invalid_scope="$(
jq -r '
(.bootstrapCandidates[]?, .missingTrustedPublisher[]?)
| select(.packageName | startswith("@openclaw/") | not)
| "- \(.packageName)@\(.version)"
' .local/plugin-clawhub-release-plan.json
)"
if [[ -n "${invalid_scope}" ]]; then
echo "Plugin ClawHub bootstrap only supports @openclaw/* packages." >&2
printf '%s\n' "${invalid_scope}" >&2
exit 1
fi
not_bootstrap="$(
jq -r '
(.bootstrapCandidates | map(.packageName)) as $bootstrapNames
| (.missingTrustedPublisher | map(.packageName)) as $repairNames
| .all[]?
| select(.packageName as $name | ($bootstrapNames + $repairNames | index($name) | not))
| "- \(.packageName)@\(.version)"
' .local/plugin-clawhub-release-plan.json
)"
if [[ -n "${not_bootstrap}" ]]; then
echo "Selected packages must all be first-publish bootstrap candidates or trusted-publisher repair candidates." >&2
printf '%s\n' "${not_bootstrap}" >&2
exit 1
fi
if [[ "${selected_count}" == "0" || "${bootstrap_candidate_count}" == "0" ]]; then
echo "No selected packages require ClawHub bootstrap." >&2
exit 1
fi
{
echo "bootstrap_candidate_count=${bootstrap_candidate_count}"
echo "has_bootstrap_candidates=${has_bootstrap_candidates}"
echo "matrix=${matrix_json}"
} >> "$GITHUB_OUTPUT"
echo "ClawHub bootstrap candidates:"
jq -r '
.bootstrapCandidates[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"
' .local/plugin-clawhub-release-plan.json
echo "ClawHub trusted-publisher repair candidates:"
jq -r '
.missingTrustedPublisher[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir), alreadyPublished=\(.alreadyPublished)"
' .local/plugin-clawhub-release-plan.json
- name: Validate Tideclaw alpha plugin channels
env:
TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
run: |
set -euo pipefail
if [[ ! "${TRUSTED_PUBLISH_BRANCH}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
exit 0
fi
invalid="$(
jq -r '
(.bootstrapCandidates[]?, .missingTrustedPublisher[]?)
| select(.publishTag != "alpha" or .channel != "alpha")
| "- \(.packageName)@\(.version) [\(.publishTag)]"
' .local/plugin-clawhub-release-plan.json
)"
if [[ -n "${invalid}" ]]; then
echo "Tideclaw alpha ClawHub bootstraps may only publish alpha plugin versions." >&2
printf '%s\n' "${invalid}" >&2
exit 1
fi
validate_release_publish_approval:
name: Validate release publish approval
needs: resolve_bootstrap_plan
if: github.event_name == 'workflow_dispatch' && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true'
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Validate release publish approval run
env:
GH_TOKEN: ${{ github.token }}
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
EXPECTED_WORKFLOW_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
run: |
set -euo pipefail
if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" ]]; then
if [[ "${GITHUB_ACTOR}" == "github-actions[bot]" ]]; then
echo "Plugin ClawHub bootstrap dispatched by another workflow must include release_publish_run_id." >&2
exit 1
fi
echo "Direct Plugin ClawHub New dispatch; relying on this workflow's clawhub-plugin-bootstrap environment approval."
exit 0
fi
direct_recovery=false
if [[ "${GITHUB_ACTOR}" != "github-actions[bot]" ]]; then
direct_recovery=true
echo "Direct Plugin ClawHub New recovery with release_publish_run_id; relying on this workflow's clawhub-plugin-bootstrap environment approval."
fi
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
printf '%s' "$RUN_JSON" | DIRECT_RELEASE_RECOVERY="${direct_recovery}" node scripts/validate-release-publish-approval.mjs
validate_bootstrap_trusted_publisher_cli:
needs: [resolve_bootstrap_plan, validate_release_publish_approval]
if: always() && github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true' && needs.validate_release_publish_approval.result == 'success'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Validate pinned ClawHub trusted publisher CLI support
env:
CLAWHUB_CLI_PACKAGE: ${{ env.CLAWHUB_CLI_PACKAGE }}
run: |
set -euo pipefail
help_output="$(
npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}" -- \
clawhub package trusted-publisher set --help 2>&1 || true
)"
printf '%s\n' "${help_output}"
if ! grep -Fq "Usage: clawhub package trusted-publisher set" <<<"${help_output}"; then
echo "::error::CLAW-277 03 - Split OpenClaw plugin ClawHub publishing into OIDC release and token bootstrap workflows requires ${CLAWHUB_CLI_PACKAGE} to expose 'package trusted-publisher set' before token bootstrap publish can run. The pinned CLI returned parent help or no set command, so this workflow is stopping before creating a ClawHub package row."
exit 1
fi
for required_flag in --repository --workflow-filename; do
if ! grep -Fq -- "${required_flag}" <<<"${help_output}"; then
echo "::error::CLAW-277 03 - Split OpenClaw plugin ClawHub publishing into OIDC release and token bootstrap workflows requires ${CLAWHUB_CLI_PACKAGE} trusted-publisher set help to include ${required_flag}."
exit 1
fi
done
publish_bootstrap_plugins:
needs:
[
resolve_bootstrap_plan,
validate_release_publish_approval,
validate_bootstrap_trusted_publisher_cli,
]
if: always() && github.event_name == 'workflow_dispatch' && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true' && needs.validate_release_publish_approval.result == 'success' && (inputs.dry_run == true || needs.validate_bootstrap_trusted_publisher_cli.result == 'success')
runs-on: ubuntu-latest
environment: clawhub-plugin-bootstrap
permissions:
contents: read
strategy:
fail-fast: false
max-parallel: 8
matrix:
plugin: ${{ fromJson(needs.resolve_bootstrap_plan.outputs.matrix) }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ github.ref }}
fetch-depth: 0
- name: Checkout target revision
env:
TARGET_SHA: ${{ needs.resolve_bootstrap_plan.outputs.ref_revision }}
run: |
set -euo pipefail
git fetch --no-tags origin \
+refs/heads/main:refs/remotes/origin/main \
'+refs/heads/release/*:refs/remotes/origin/release/*'
git checkout --detach "${TARGET_SHA}"
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
install-bun: "true"
install-deps: "true"
- name: Verify package-local runtime build
run: node scripts/check-plugin-npm-runtime-builds.mjs --package "${{ matrix.plugin.packageDir }}"
- name: Install pinned ClawHub CLI wrapper
run: |
set -euo pipefail
cat > "${RUNNER_TEMP}/clawhub" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
exec npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}" -- clawhub "$@"
EOF
chmod +x "${RUNNER_TEMP}/clawhub"
echo "${RUNNER_TEMP}" >> "${GITHUB_PATH}"
- name: Write ClawHub token config
if: inputs.dry_run != true
env:
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }}
run: |
set -euo pipefail
config_path="${RUNNER_TEMP}/clawhub-config.json"
CONFIG_PATH="${config_path}" node --input-type=module <<'NODE'
import { writeFileSync } from "node:fs";
const registry = process.env.CLAWHUB_REGISTRY?.trim();
const token = process.env.CLAWHUB_TOKEN?.trim();
const configPath = process.env.CONFIG_PATH;
if (!registry) {
throw new Error("CLAWHUB_REGISTRY is required for token-gated ClawHub bootstrap.");
}
if (!token) {
throw new Error("CLAWHUB_TOKEN is required for token-gated ClawHub bootstrap.");
}
if (!configPath) {
throw new Error("CONFIG_PATH is required.");
}
writeFileSync(configPath, `${JSON.stringify({ registry, token }, null, 2)}\n`, {
encoding: "utf8",
mode: 0o600,
});
NODE
echo "CLAWHUB_CONFIG_PATH=${config_path}" >> "${GITHUB_ENV}"
- name: Publish ClawHub bootstrap package
env:
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
SOURCE_REPO: ${{ github.repository }}
SOURCE_COMMIT: ${{ needs.resolve_bootstrap_plan.outputs.ref_revision }}
SOURCE_REF: ${{ github.ref }}
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
BOOTSTRAP_MODE: ${{ matrix.plugin.bootstrapMode }}
REQUIRES_MANUAL_OVERRIDE: ${{ matrix.plugin.requiresManualOverride && 'true' || 'false' }}
DRY_RUN: ${{ inputs.dry_run && 'true' || 'false' }}
OPENCLAW_PLUGIN_NPM_RUNTIME_BUILD: "0"
run: |
set -euo pipefail
if [[ "${BOOTSTRAP_MODE}" == "configure-only" ]]; then
echo "Skipping bootstrap publish because ${PACKAGE_DIR} version is already present on ClawHub; configuring trusted publisher only."
elif [[ "${DRY_RUN}" == "true" ]]; then
bash scripts/plugin-clawhub-publish.sh --dry-run "${PACKAGE_DIR}"
else
if [[ "${REQUIRES_MANUAL_OVERRIDE}" == "true" ]]; then
export OPENCLAW_CLAWHUB_MANUAL_OVERRIDE_REASON="GitHub Actions trusted publisher repair before OIDC migration"
fi
bash scripts/plugin-clawhub-publish.sh --publish "${PACKAGE_DIR}"
fi
- name: Configure trusted publisher for normal OIDC releases
if: inputs.dry_run != true
env:
CLAWHUB_CLI_PACKAGE: ${{ env.CLAWHUB_CLI_PACKAGE }}
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
run: |
set -euo pipefail
npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}" -- \
clawhub package trusted-publisher set "${PACKAGE_NAME}" \
--repository openclaw/openclaw \
--workflow-filename plugin-clawhub-release.yml
verify_bootstrap_clawhub_package:
needs: [resolve_bootstrap_plan, publish_bootstrap_plugins]
if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
fail-fast: false
max-parallel: 8
matrix:
plugin: ${{ fromJson(needs.resolve_bootstrap_plan.outputs.matrix) }}
steps:
- name: Verify bootstrap ClawHub package and trusted publisher
env:
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
PACKAGE_VERSION: ${{ matrix.plugin.version }}
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
run: |
set -euo pipefail
node --input-type=module <<'EOF'
const registry = (process.env.CLAWHUB_REGISTRY ?? "https://clawhub.ai").replace(/\/+$/, "");
const packageName = process.env.PACKAGE_NAME;
const packageVersion = process.env.PACKAGE_VERSION;
const packageTag = process.env.PACKAGE_TAG;
if (!packageName || !packageVersion || !packageTag) {
throw new Error("Missing ClawHub bootstrap verification env.");
}
const encodedName = encodeURIComponent(packageName);
const encodedVersion = encodeURIComponent(packageVersion);
const detailUrl = `${registry}/api/v1/packages/${encodedName}`;
const trustedPublisherUrl = `${detailUrl}/trusted-publisher`;
const versionUrl = `${detailUrl}/versions/${encodedVersion}`;
const artifactUrl = `${versionUrl}/artifact/download`;
async function fetchWithRetry(url, options = {}) {
let lastStatus = "unknown";
for (let attempt = 1; attempt <= 12; attempt += 1) {
try {
const response = await fetch(url, { redirect: "manual", ...options });
lastStatus = response.status;
if (response.status !== 429 && response.status < 500) {
return response;
}
} catch (error) {
lastStatus = error instanceof Error ? error.message : String(error);
}
await new Promise((resolve) => setTimeout(resolve, attempt * 5000));
}
throw new Error(`${url} did not stabilize; last status ${lastStatus}.`);
}
const detailResponse = await fetchWithRetry(detailUrl, {
headers: { accept: "application/json" },
});
if (!detailResponse.ok) {
throw new Error(`${detailUrl} returned HTTP ${detailResponse.status}.`);
}
const detail = await detailResponse.json();
const tags = detail?.package?.tags ?? {};
if (tags[packageTag] !== packageVersion) {
throw new Error(
`${packageName}: ClawHub tag ${packageTag} points to ${tags[packageTag] ?? "<missing>"}, expected ${packageVersion}.`,
);
}
const trustedPublisherResponse = await fetchWithRetry(trustedPublisherUrl, {
headers: { accept: "application/json" },
});
if (!trustedPublisherResponse.ok) {
throw new Error(`${trustedPublisherUrl} returned HTTP ${trustedPublisherResponse.status}.`);
}
const trustedPublisherDetail = await trustedPublisherResponse.json();
const trustedPublisher = trustedPublisherDetail?.trustedPublisher;
if (
trustedPublisher?.repository !== "openclaw/openclaw" ||
trustedPublisher?.workflowFilename !== "plugin-clawhub-release.yml" ||
trustedPublisher?.environment != null
) {
throw new Error(
`${packageName}: trusted publisher config did not match openclaw/openclaw plugin-clawhub-release.yml without an environment pin.`,
);
}
const versionResponse = await fetchWithRetry(versionUrl);
if (!versionResponse.ok) {
throw new Error(`${versionUrl} returned HTTP ${versionResponse.status}.`);
}
const artifactResponse = await fetchWithRetry(artifactUrl, { method: "HEAD" });
if (artifactResponse.status < 200 || artifactResponse.status >= 400) {
throw new Error(`${artifactUrl} returned HTTP ${artifactResponse.status}.`);
}
console.log(`${packageName}@${packageVersion} bootstrap verified on ClawHub.`);
EOF

View File

@@ -16,7 +16,7 @@ on:
required: false
type: string
ref:
description: Dry-run target ref to validate; real OIDC publishes must dispatch the workflow with --ref set to the target release tag/ref
description: Commit SHA on main, a release branch, or the matching Tideclaw alpha branch to publish from; defaults to the workflow ref
required: false
default: ""
type: string
@@ -24,10 +24,6 @@ on:
description: Approved OpenClaw Release Publish workflow run id
required: false
type: string
release_publish_branch:
description: Branch name of the approving OpenClaw Release Publish workflow run
required: false
type: string
dry_run:
description: Validate the full ClawHub artifact handoff without publishing.
required: false
@@ -42,7 +38,9 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24.15.0"
CLAWHUB_REGISTRY: "https://clawhub.ai"
CLAWHUB_CLI_PACKAGE: "clawhub@0.21.0"
CLAWHUB_REPOSITORY: "openclaw/clawhub"
# Pinned to a reviewed ClawHub commit so release behavior stays reproducible.
CLAWHUB_REF: "c9bb13023598dcc547fdf4a93b9d42512b8c8854"
jobs:
preview_plugins_clawhub:
@@ -52,15 +50,9 @@ jobs:
outputs:
ref_revision: ${{ steps.ref.outputs.sha }}
has_candidates: ${{ steps.plan.outputs.has_candidates }}
has_bootstrap_candidates: ${{ steps.plan.outputs.has_bootstrap_candidates }}
has_missing_trusted_publisher: ${{ steps.plan.outputs.has_missing_trusted_publisher }}
candidate_count: ${{ steps.plan.outputs.candidate_count }}
bootstrap_candidate_count: ${{ steps.plan.outputs.bootstrap_candidate_count }}
missing_trusted_publisher_count: ${{ steps.plan.outputs.missing_trusted_publisher_count }}
skipped_published_count: ${{ steps.plan.outputs.skipped_published_count }}
matrix: ${{ steps.plan.outputs.matrix }}
bootstrap_matrix: ${{ steps.plan.outputs.bootstrap_matrix }}
missing_trusted_publisher_matrix: ${{ steps.plan.outputs.missing_trusted_publisher_matrix }}
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -91,27 +83,9 @@ jobs:
fi
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Validate OIDC source matches workflow ref
env:
TARGET_SHA: ${{ steps.ref.outputs.sha }}
WORKFLOW_SHA: ${{ github.sha }}
DRY_RUN: ${{ inputs.dry_run && 'true' || 'false' }}
run: |
set -euo pipefail
if [[ "${TARGET_SHA}" != "${WORKFLOW_SHA}" ]]; then
if [[ "${DRY_RUN}" == "true" ]]; then
echo "Dry-run publish target differs from workflow ref; allowing validation-only dispatch."
exit 0
fi
echo "Plugin ClawHub OIDC publishes must run from the same ref that is being published." >&2
echo "The ref input is only supported for dry_run=true." >&2
echo "For real publishes, dispatch this workflow with --ref pointing at the target release tag/ref and omit the ref input." >&2
exit 1
fi
- name: Validate ref is on a trusted publish branch
env:
TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
WORKFLOW_REF: ${{ github.ref }}
run: |
set -euo pipefail
if git merge-base --is-ancestor HEAD origin/main; then
@@ -122,8 +96,8 @@ jobs:
exit 0
fi
done < <(git for-each-ref --format='%(refname)' refs/remotes/origin/release)
if [[ "${TRUSTED_PUBLISH_BRANCH}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
alpha_branch="${TRUSTED_PUBLISH_BRANCH}"
if [[ "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
alpha_branch="${WORKFLOW_REF#refs/heads/}"
git fetch --no-tags origin "+refs/heads/${alpha_branch}:refs/remotes/origin/${alpha_branch}"
if git merge-base --is-ancestor HEAD "refs/remotes/origin/${alpha_branch}"; then
exit 0
@@ -184,78 +158,36 @@ jobs:
cat .local/plugin-clawhub-release-plan.json
candidate_count="$(jq -r '.candidates | length' .local/plugin-clawhub-release-plan.json)"
bootstrap_candidate_count="$(jq -r '.bootstrapCandidates | length' .local/plugin-clawhub-release-plan.json)"
missing_trusted_publisher_count="$(jq -r '.missingTrustedPublisher | length' .local/plugin-clawhub-release-plan.json)"
skipped_published_count="$(jq -r '.skippedPublished | length' .local/plugin-clawhub-release-plan.json)"
has_candidates="false"
if [[ "${candidate_count}" != "0" ]]; then
has_candidates="true"
fi
has_bootstrap_candidates="false"
if [[ "${bootstrap_candidate_count}" != "0" ]]; then
has_bootstrap_candidates="true"
fi
has_missing_trusted_publisher="false"
if [[ "${missing_trusted_publisher_count}" != "0" ]]; then
has_missing_trusted_publisher="true"
fi
matrix_json="$(jq -c '.candidates' .local/plugin-clawhub-release-plan.json)"
bootstrap_matrix_json="$(jq -c '.bootstrapCandidates' .local/plugin-clawhub-release-plan.json)"
missing_trusted_publisher_matrix_json="$(jq -c '.missingTrustedPublisher' .local/plugin-clawhub-release-plan.json)"
{
echo "candidate_count=${candidate_count}"
echo "bootstrap_candidate_count=${bootstrap_candidate_count}"
echo "missing_trusted_publisher_count=${missing_trusted_publisher_count}"
echo "skipped_published_count=${skipped_published_count}"
echo "has_candidates=${has_candidates}"
echo "has_bootstrap_candidates=${has_bootstrap_candidates}"
echo "has_missing_trusted_publisher=${has_missing_trusted_publisher}"
echo "matrix=${matrix_json}"
echo "bootstrap_matrix=${bootstrap_matrix_json}"
echo "missing_trusted_publisher_matrix=${missing_trusted_publisher_matrix_json}"
} >> "$GITHUB_OUTPUT"
echo "Plugin release candidates:"
jq -r '.candidates[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"' .local/plugin-clawhub-release-plan.json
echo "Bootstrap candidates requiring token bootstrap:"
jq -r '.bootstrapCandidates[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"' .local/plugin-clawhub-release-plan.json
echo "Missing trusted publisher candidates:"
jq -r '.missingTrustedPublisher[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"' .local/plugin-clawhub-release-plan.json
echo "Already published / skipped:"
jq -r '.skippedPublished[]? | "- \(.packageName)@\(.version)"' .local/plugin-clawhub-release-plan.json
- name: Fail when trusted publisher is missing
if: steps.plan.outputs.missing_trusted_publisher_count != '0'
run: |
echo "::error::One or more ClawHub packages exist but do not have trusted publishing configured. Configure trusted publishing before running the normal OIDC publish workflow."
jq -r '.missingTrustedPublisher[]? | "::error::Missing trusted publisher: \(.packageName)@\(.version). Configure trusted publishing for openclaw/openclaw, workflow plugin-clawhub-release.yml."' .local/plugin-clawhub-release-plan.json
exit 1
- name: Fail normal publish when bootstrap is required
if: steps.plan.outputs.bootstrap_candidate_count != '0'
run: |
echo "::error::One or more ClawHub packages do not exist yet and require the token-gated Plugin ClawHub New bootstrap workflow before normal OIDC publish can run."
jq -r '.bootstrapCandidates[]? | "::error::Bootstrap required: \(.packageName)@\(.version). Dispatch plugin-clawhub-new.yml for this package, then rerun the normal release."' .local/plugin-clawhub-release-plan.json
exit 1
- name: Fail manual publish when target versions already exist
if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && inputs.publish_scope == 'selected' && steps.plan.outputs.skipped_published_count != '0'
if: github.event_name == 'workflow_dispatch' && inputs.publish_scope == 'selected' && steps.plan.outputs.skipped_published_count != '0'
run: |
echo "::error::One or more selected plugin versions already exist on ClawHub. Bump the version before running a real publish."
exit 1
- name: Validate Tideclaw alpha plugin channels
env:
TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
if: startsWith(github.ref, 'refs/heads/tideclaw/alpha/')
run: |
set -euo pipefail
if [[ ! "${TRUSTED_PUBLISH_BRANCH}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
exit 0
fi
invalid="$(
jq -r '.candidates[]? | select(.publishTag != "alpha" or .channel != "alpha") | "- \(.packageName)@\(.version) [\(.publishTag)]"' .local/plugin-clawhub-release-plan.json
)"
@@ -265,6 +197,12 @@ jobs:
exit 1
fi
- name: Verify OpenClaw ClawHub package ownership
if: steps.plan.outputs.has_candidates == 'true'
env:
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
run: node --import tsx scripts/plugin-clawhub-owner-preflight.ts .local/plugin-clawhub-release-plan.json
validate_release_publish_approval:
name: Validate release publish approval
needs: preview_plugins_clawhub
@@ -283,7 +221,7 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
EXPECTED_WORKFLOW_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
EXPECTED_WORKFLOW_BRANCH: ${{ github.ref_name }}
run: |
set -euo pipefail
if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" ]]; then
@@ -302,8 +240,99 @@ jobs:
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
printf '%s' "$RUN_JSON" | DIRECT_RELEASE_RECOVERY="${direct_recovery}" node scripts/validate-release-publish-approval.mjs
preview_plugin_pack:
needs: preview_plugins_clawhub
if: needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
fail-fast: false
max-parallel: 12
matrix:
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ github.ref }}
fetch-depth: 0
- name: Checkout target revision
env:
TARGET_SHA: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
run: |
set -euo pipefail
git fetch --no-tags origin \
+refs/heads/main:refs/remotes/origin/main \
'+refs/heads/release/*:refs/remotes/origin/release/*'
git checkout --detach "${TARGET_SHA}"
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
install-bun: "true"
install-deps: "true"
- name: Checkout ClawHub CLI source
uses: actions/checkout@v6
with:
persist-credentials: false
repository: ${{ env.CLAWHUB_REPOSITORY }}
ref: main
path: clawhub-source
fetch-depth: 0
- name: Checkout pinned ClawHub CLI revision
working-directory: clawhub-source
env:
CLAWHUB_REF: ${{ env.CLAWHUB_REF }}
run: git checkout --detach "${CLAWHUB_REF}"
- name: Install ClawHub CLI dependencies
working-directory: clawhub-source
run: |
set -euo pipefail
for attempt in 1 2 3; do
if bun install --frozen-lockfile; then
exit 0
fi
status="$?"
if [[ "${attempt}" == "3" ]]; then
exit "${status}"
fi
echo "bun install failed while preparing ClawHub CLI; retrying (${attempt}/3)."
rm -rf node_modules "${RUNNER_TEMP}/bun-install-cache" || true
sleep $((attempt * 15))
done
- name: Bootstrap ClawHub CLI
run: |
cat > "$RUNNER_TEMP/clawhub" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
exec bun "$GITHUB_WORKSPACE/clawhub-source/packages/clawhub/src/cli.ts" "$@"
EOF
chmod +x "$RUNNER_TEMP/clawhub"
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
- name: Verify package-local runtime build
run: node scripts/check-plugin-npm-runtime-builds.mjs --package "${{ matrix.plugin.packageDir }}"
- name: Preview publish command
env:
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
SOURCE_REPO: ${{ github.repository }}
SOURCE_COMMIT: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
SOURCE_REF: ${{ github.ref }}
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
run: bash scripts/plugin-clawhub-publish.sh --dry-run "${PACKAGE_DIR}"
pack_plugins_clawhub_artifacts:
needs: [preview_plugins_clawhub, validate_release_publish_approval]
needs: [preview_plugins_clawhub, preview_plugin_pack, validate_release_publish_approval]
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
runs-on: ubuntu-latest
permissions:
@@ -338,19 +367,47 @@ jobs:
install-bun: "true"
install-deps: "true"
- name: Verify package-local runtime build
run: node scripts/check-plugin-npm-runtime-builds.mjs --package "${{ matrix.plugin.packageDir }}"
- name: Checkout ClawHub CLI source
uses: actions/checkout@v6
with:
persist-credentials: false
repository: ${{ env.CLAWHUB_REPOSITORY }}
ref: main
path: clawhub-source
fetch-depth: 0
- name: Install pinned ClawHub CLI wrapper
- name: Checkout pinned ClawHub CLI revision
working-directory: clawhub-source
env:
CLAWHUB_REF: ${{ env.CLAWHUB_REF }}
run: git checkout --detach "${CLAWHUB_REF}"
- name: Install ClawHub CLI dependencies
working-directory: clawhub-source
run: |
set -euo pipefail
cat > "${RUNNER_TEMP}/clawhub" <<'EOF'
for attempt in 1 2 3; do
if bun install --frozen-lockfile; then
exit 0
fi
status="$?"
if [[ "${attempt}" == "3" ]]; then
exit "${status}"
fi
echo "bun install failed while preparing ClawHub CLI; retrying (${attempt}/3)."
rm -rf node_modules "${RUNNER_TEMP}/bun-install-cache" || true
sleep $((attempt * 15))
done
- name: Bootstrap ClawHub CLI
run: |
cat > "$RUNNER_TEMP/clawhub" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
exec npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}" -- clawhub "$@"
exec bun "$GITHUB_WORKSPACE/clawhub-source/packages/clawhub/src/cli.ts" "$@"
EOF
chmod +x "${RUNNER_TEMP}/clawhub"
echo "${RUNNER_TEMP}" >> "${GITHUB_PATH}"
chmod +x "$RUNNER_TEMP/clawhub"
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
- name: Pack ClawHub package artifact
env:
@@ -371,23 +428,19 @@ jobs:
if-no-files-found: error
retention-days: 7
approve_plugins_clawhub_release:
approve_plugin_clawhub_release:
needs: [preview_plugins_clawhub, pack_plugins_clawhub_artifacts]
if: always() && github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' && needs.pack_plugins_clawhub_artifacts.result == 'success'
if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
runs-on: ubuntu-latest
environment: clawhub-plugin-release
permissions:
contents: read
permissions: {}
steps:
- name: Approve Plugin ClawHub release publish
run: |
echo "Approved CLAW-277 03 - Split OpenClaw plugin ClawHub publishing into OIDC release and token bootstrap workflows release publish gate."
- name: Approve ClawHub package publish
run: echo "ClawHub package publish approved."
publish_plugins_clawhub:
needs:
[preview_plugins_clawhub, pack_plugins_clawhub_artifacts, approve_plugins_clawhub_release]
if: always() && github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' && needs.pack_plugins_clawhub_artifacts.result == 'success' && (inputs.dry_run == true || needs.approve_plugins_clawhub_release.result == 'success')
uses: openclaw/clawhub/.github/workflows/package-publish.yml@9d49df109d4ad3dc8a6ecf05d26b39f46d294721
needs: [preview_plugins_clawhub, pack_plugins_clawhub_artifacts, approve_plugin_clawhub_release]
if: always() && github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' && needs.pack_plugins_clawhub_artifacts.result == 'success' && (inputs.dry_run == true || needs.approve_plugin_clawhub_release.result == 'success')
permissions:
actions: read
contents: read
@@ -397,18 +450,19 @@ jobs:
max-parallel: 32
matrix:
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
uses: openclaw/clawhub/.github/workflows/package-publish.yml@c9bb13023598dcc547fdf4a93b9d42512b8c8854
with:
package_artifact_name: ${{ matrix.plugin.artifactName }}
dry_run: ${{ inputs.dry_run }}
json: true
package_artifact_name: ${{ matrix.plugin.artifactName }}
registry: https://clawhub.ai
site: https://clawhub.ai
tags: ${{ matrix.plugin.publishTag }}
source_repo: ${{ github.repository }}
source_commit: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
source_ref: ${{ github.ref }}
source_path: ${{ matrix.plugin.packageDir }}
inspector_artifact_name: ${{ matrix.plugin.artifactName }}-inspector
publish_json_artifact_name: ${{ matrix.plugin.artifactName }}-publish-json
tags: ${{ matrix.plugin.publishTag }}
secrets:
clawhub_token: ${{ secrets.CLAWHUB_TOKEN }}
verify_published_clawhub_package:
needs: [preview_plugins_clawhub, publish_plugins_clawhub]

View File

@@ -288,7 +288,6 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
OPENCLAW_NPM_PUBLISH_AUTH_MODE: trusted-publisher
run: bash scripts/plugin-npm-publish.sh --publish "${{ matrix.plugin.packageDir }}"
- name: Verify published runtime

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

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

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

2
.gitignore vendored
View File

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

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

View File

@@ -2,38 +2,6 @@
Docs: https://docs.openclaw.ai
## 2026.6.8
### Highlights
- Telegram and WhatsApp channel delivery are richer and less brittle: Telegram can send structured rich text with tables, lists, expandable blockquotes, prompt-preserving CLI backend delivery, retired native draft migration, and safer rich-media boundaries, while WhatsApp now honors configured ACP bindings. (#92679, #84082, #89421, #92513) Thanks @obviyus, @jzakirov, @spacegeologist, and @TurboTheTurtle.
- Agent and Gateway recovery is sharper across account-scoped DM sends, generated media completions, restart shutdown aborts, yielded subagent pauses, yielded cron media, heartbeat dedupe, session identity prompts, and unknown OpenAI agent selector rejection. (#92788, #91246, #91357, #92631, #92146, #91287, #92468, #92510) Thanks @yetval, @TurboTheTurtle, @ooiuuii, @openperf, @IWhatsskill, @ZengWen-DT, and @zhangguiping-xydt.
- 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, bounded model browse discovery, storeless OpenAI Responses replay gating, and Claude 4.5 Copilot tool-streaming safety. (#92796, #90116, #92627, #91218, #90686, #92247, #90706, #75393) Thanks @arkyu2077, @liuhao1024, @bymle, @rohitjavvadi, @samson910022, @snowzlm, and @Kailigithub.
- `/usage` 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.
- 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.
- 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, and Infinity chunk limits stay genuinely unbounded. (#92650, #92618, #92639, #91247, #92752, #92735) Thanks @mushuiyu886, @TurboTheTurtle, @849261680, @gnanam1990, and @yhterrance.
### Changes
- 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.
- Channel plugins: ship Telegram rich-message delivery and WhatsApp ACP binding support, including rich prompt handoff to CLI backends and transport fixtures for richer drafts. (#92679, #92513) Thanks @obviyus and @TurboTheTurtle.
- Agent commands: support `/btw` in CLI-backed sessions and keep CLI usage-error exits classified as usage failures instead of successful runs. (#92669, #92162) Thanks @joshavant and @Pandah97.
- 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.
- 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.
### Fixes
- Channels and delivery: preserve account-scoped DM channel send policy, rich Telegram final replies, rich Telegram tables and lists, Telegram thread-create CLI remapping, Slack outbound `message_sent` hooks, contributed message-tool schema optionality, same-channel generated media completions, and channel chunking around surrogate pairs and Infinity limits. (#92788, #92679, #89421, #89943, #91137, #91246, #92735) Thanks @yetval, @obviyus, @spacegeologist, @rishitamrakar, @lundog, @TurboTheTurtle, and @yhterrance.
- 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.
- Agent, cron, and Gateway runtime: mark active main sessions before restart shutdown aborts, pause yielded subagent runs whose terminal also signals abort, preserve yielded media completions, de-duplicate main-session heartbeat events, expose session identity in runtime prompts, reject unknown OpenAI agent selectors, keep generated media completions and slash-command block replies in WebChat, preserve fresh post-compaction usage while clearing stale usage snapshots, and require admin privileges for HTTP session/model override surfaces. (#91357, #92631, #92146, #91287, #92468, #92510, #91246, #50795, #50845, #82874, #92651, #92646) Thanks @ooiuuii, @openperf, @IWhatsskill, @ZengWen-DT, @zhangguiping-xydt, @Hollychou924, @leno23, and @TurboTheTurtle.
- Providers and model replay: preserve storeless OpenAI Responses replay compatibility, avoid eager tool streaming for Claude 4.5 in Copilot, 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, #75393, #90686, #92247, #92627, #91218, #92628) Thanks @snowzlm, @Kailigithub, @rohitjavvadi, @samson910022, @liuhao1024, @bymle, and @mushuiyu886.
- 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, and keep shell environment fallbacks contained in config write tests. (#92650, #92618, #92639, #91247, #92752) Thanks @mushuiyu886, @TurboTheTurtle, @849261680, and @gnanam1990.
- UI/mobile/TUI: preserve dashboard session parent lineage, WebChat backscroll, reset soft command args, sidebar session picker interactivity, collapsed workspace files, resolved `/model` confirmation refs, and stale foreground iOS Gateway reconnects. (#90658, #92622, #91353, #92705, #92779, #92773, #92552) Thanks @luoyanglang, @TurboTheTurtle, @zhouhe-xydt, @NianJiuZst, @shakkernerd, @NarahariRaghava, and @Solvely-Colin.
- 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, and keep QA Lab bootstrap selection assertions aligned with flow-only scenarios. (#92652)
- macOS Peekaboo bridge: update the embedded Peekaboo package to 3.5.2 and route bundled-skill CLI commands through the OpenClaw app bridge so they inherit its Screen Recording and Accessibility grants.
- Agent routing: route subagent RPC callbacks addressed to an agent-shaped `--to` target to the correct session key instead of falling back to the main session, so WeChat (and other channel) session-key callbacks reach the intended subagent session. (#90231) Thanks @zhangguiping-xydt.
- QQBot delivery: keep markdown table chunks self-contained across message boundaries by preserving table state across block deliveries, flushing unfinished table-row fragments as plain text, and detecting short pipe-terminated rows by column count so split rows are not sent as malformed markdown. (#92428) Thanks @sliverp.
## 2026.6.6
### Highlights
@@ -96,7 +64,6 @@ Docs: https://docs.openclaw.ai
### Fixes
- Agents: `sessions_send` now honors an explicit `sessionKey` when stale label metadata is also present, and denied session-id sends no longer echo the resolved canonical session key. Fixes #64699; refs #74009 and #41199. Thanks @Mintalix, @RevisitMoon, and @Mocha-s.
- Channel content boundaries: QQBot now strips reasoning/thinking tags before sending, preserving final answers while hiding internal model narration from users. (#89913, #90132) Thanks @openperf.
- Agents/MCP/providers: coerce non-text/image MCP tool-result blocks before they reach provider converters, preserving valid images and turning richer MCP content into text instead of malformed image blocks. (#90710, #90728) Thanks @RanSHammer and @849261680.
- Anthropic/Codex/ACP/agent recovery: defer Anthropic stream start events until `message_start`, strip stale compaction thinking signatures before Anthropic replay, detect unsigned thinking-only stalls, refresh prompt fences after compaction writes, reject empty completion handoffs, preserve parent streaming-off overrides/shared progress commentary, forward heartbeat metadata to context-engine hooks, and cover Codex session/thread migration edge cases. (#90667, #90697, #90163, #90108, #89874, #89505, #90632, #89302, #90729, #90317, #90319) Thanks @openperf, @100yenadmin, and @ooiuuii.

View File

@@ -116,19 +116,11 @@ RUN pnpm_config_verify_deps_before_run=false pnpm canvas:a2ui:bundle || \
echo "/* A2UI bundle unavailable in this build */" > extensions/canvas/src/host/a2ui/a2ui.bundle.js && \
echo "stub" > extensions/canvas/src/host/a2ui/.bundle.hash && \
rm -rf vendor/a2ui apps/shared/OpenClawKit/Tools/CanvasA2UI)
RUN if printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' ' | tr ' ' '\n' | grep -qx 'qa-lab'; then \
export OPENCLAW_BUILD_PRIVATE_QA=1 OPENCLAW_ENABLE_PRIVATE_QA_CLI=1; \
fi && \
NODE_OPTIONS=--max-old-space-size=8192 pnpm_config_verify_deps_before_run=false pnpm build:docker
RUN NODE_OPTIONS=--max-old-space-size=8192 pnpm_config_verify_deps_before_run=false pnpm build:docker
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
ENV OPENCLAW_PREFER_PNPM=1
RUN pnpm_config_verify_deps_before_run=false pnpm ui:build
RUN if printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' ' | tr ' ' '\n' | grep -qx 'qa-lab'; then \
pnpm_config_verify_deps_before_run=false pnpm qa:lab:build && \
mkdir -p dist/extensions/qa-lab/web && \
rm -rf dist/extensions/qa-lab/web/dist && \
cp -R extensions/qa-lab/web/dist dist/extensions/qa-lab/web/dist; \
fi
RUN pnpm_config_verify_deps_before_run=false pnpm qa:lab:build
# Prune dev dependencies, omitted plugin runtime packages, and build-only
# metadata before copying runtime assets into the final image.
@@ -138,7 +130,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 +139,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

@@ -443,7 +443,6 @@ class NodeRuntime(
updateStatus()
micCapture.onGatewayConnectionChanged(true)
scope.launch {
subscribeOperatorSessionEvents()
refreshHomeCanvasOverviewIfConnected()
if (voiceReplySpeakerLazy.isInitialized()) {
voiceReplySpeaker.refreshConfig()
@@ -486,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,

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

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

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

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

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

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

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

View File

@@ -5,6 +5,7 @@ enum OpenClawProMetric {
static let cardRadius: CGFloat = 10
static let controlRadius: CGFloat = 8
static let bottomScrollInset: CGFloat = 96
static let heroRadius: CGFloat = 12
}
struct OpenClawProBackground: View {
@@ -249,6 +250,13 @@ struct OpenClawSidebarRevealButton: View {
self.headerAction = action
}
init(action: @escaping () -> Void) {
self.headerAction = OpenClawSidebarHeaderAction(
systemName: "sidebar.left",
accessibilityLabel: "Show Sidebar",
action: action)
}
var body: some View {
let button = Button(action: self.headerAction.action) {
Image(systemName: self.headerAction.systemName)
@@ -422,6 +430,46 @@ struct ProProgressBar: View {
}
}
struct ProWorkRow: View {
let icon: String
let title: String
let detail: String
let state: String
let trailing: String
let color: Color
var progress: Double?
var body: some View {
HStack(alignment: .top, spacing: 12) {
ProIconBadge(systemName: self.icon, color: self.color)
VStack(alignment: .leading, spacing: 5) {
HStack(alignment: .firstTextBaseline) {
Text(self.title)
.font(.subheadline.weight(.semibold))
Spacer(minLength: 8)
Text(self.trailing)
.font(.caption2)
.foregroundStyle(.secondary)
}
Text(self.detail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
HStack(spacing: 8) {
if let progress {
ProProgressBar(progress: progress, color: self.color)
.frame(maxWidth: 120)
}
Text(self.state)
.font(.caption2.weight(.semibold))
.foregroundStyle(self.color)
}
}
}
.padding(.vertical, 9)
}
}
struct ProCapsule: View {
@Environment(\.colorScheme) private var colorScheme
let title: String
@@ -505,6 +553,94 @@ struct OpenClawGatewayCompactPill: View {
}
}
struct ProSegmentedControl: View {
@Environment(\.colorScheme) private var colorScheme
let labels: [String]
@Binding var selection: Int
var body: some View {
HStack(spacing: 4) {
ForEach(Array(self.labels.enumerated()), id: \.offset) { index, label in
Button {
self.selection = index
} label: {
Text(label)
.font(.subheadline.weight(self.selection == index ? .semibold : .regular))
.frame(maxWidth: .infinity)
.padding(.vertical, 9)
.background(self.segmentFill(isSelected: self.selection == index), in: Capsule())
}
.buttonStyle(.plain)
}
}
.padding(4)
.background {
Capsule()
.fill(self.trackFill)
.overlay {
Capsule().strokeBorder(self.trackStroke, lineWidth: 1)
}
}
}
private func segmentFill(isSelected: Bool) -> Color {
guard isSelected else { return .clear }
return self.colorScheme == .dark ? Color.white.opacity(0.12) : Color.primary.opacity(0.08)
}
private var trackFill: Color {
self.colorScheme == .dark ? Color.white.opacity(0.045) : Color.white.opacity(0.72)
}
private var trackStroke: Color {
self.colorScheme == .dark ? Color.white.opacity(0.10) : Color.black.opacity(0.06)
}
}
struct ProHeroActionButton: View {
@Environment(\.colorScheme) private var colorScheme
let title: String
let detail: String
let systemImage: String
let action: () -> Void
var body: some View {
Button(action: self.action) {
HStack(spacing: 12) {
Image(systemName: self.systemImage)
.font(.headline.weight(.semibold))
.foregroundStyle(.white)
.frame(width: 42, height: 42)
.background(OpenClawBrand.accentHot, in: RoundedRectangle(cornerRadius: 13, style: .continuous))
VStack(alignment: .leading, spacing: 3) {
Text(self.title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
Text(self.detail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 8)
Image(systemName: "arrow.right")
.font(.subheadline.weight(.bold))
.foregroundStyle(OpenClawBrand.accentHot)
}
.padding(12)
.proGlassSurface(
fill: self.colorScheme == .dark ? Color.white.opacity(0.045) : Color.white.opacity(0.68),
stroke: OpenClawBrand.accent.opacity(self.colorScheme == .dark ? 0.22 : 0.14),
radius: 18,
isProminent: true,
interactive: true)
}
.buttonStyle(.plain)
}
}
struct ProMetricTile: View {
@Environment(\.colorScheme) private var colorScheme
let title: String
@@ -659,3 +795,24 @@ struct ProStatusRow: View {
.padding(.vertical, 10)
}
}
struct ProTimelineRow: View {
let done: Bool
let title: String
let detail: String
var body: some View {
HStack(alignment: .top, spacing: 10) {
ProIconBadge(
systemName: self.done ? "checkmark.circle.fill" : "clock.fill",
color: self.done ? OpenClawBrand.ok : OpenClawBrand.warn)
VStack(alignment: .leading, spacing: 3) {
Text(self.title)
.font(.subheadline.weight(.medium))
Text(self.detail)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}

View File

@@ -0,0 +1,3 @@
import SwiftUI
// Pro UI surfaces are split by tab to keep SwiftLint file-length signal useful.

View File

@@ -332,6 +332,65 @@ struct SettingsChannelsDestination: View {
}
}
struct SettingsChannelsScreen: View {
let headerLeadingAction: OpenClawSidebarHeaderAction?
let gatewayAction: (() -> Void)?
init(headerLeadingAction: OpenClawSidebarHeaderAction? = nil, gatewayAction: (() -> Void)? = nil) {
self.headerLeadingAction = headerLeadingAction
self.gatewayAction = gatewayAction
}
var body: some View {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 14) {
self.header
SettingsChannelsDestination(showsSummaryCard: false)
}
.padding(.top, 18)
.padding(.bottom, OpenClawProMetric.bottomScrollInset)
}
}
.navigationTitle("Channels")
.navigationBarTitleDisplayMode(.inline)
}
private var header: some View {
HStack(alignment: .top, spacing: 12) {
if let headerLeadingAction {
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
}
VStack(alignment: .leading, spacing: 5) {
Text("Channels / Integrations")
.font(.title3.weight(.semibold))
Text("Message routing and external channel clients.")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 8)
self.gatewayPill
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
@ViewBuilder
private var gatewayPill: some View {
if let gatewayAction {
Button(action: gatewayAction) {
OpenClawGatewayCompactPill()
}
.buttonStyle(.plain)
.accessibilityHint("Opens Settings / Gateway")
} else {
OpenClawGatewayCompactPill()
}
}
}
private struct SettingsChannelRow: View {
let entry: SettingsChannelEntry
let canAdmin: Bool

View File

@@ -139,6 +139,15 @@ extension SettingsProTab {
await self.gatewayController.connectLastKnown()
}
func refreshGateway() async {
guard !self.isRefreshingGateway else { return }
self.isRefreshingGateway = true
defer { self.isRefreshingGateway = false }
self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
self.gatewayController.restartDiscovery()
await self.appModel.refreshGatewayOverviewIfConnected()
}
@MainActor
func runDiagnostics() async {
guard !self.isRefreshingGateway else { return }
@@ -191,7 +200,7 @@ extension SettingsProTab {
self.setupStatusText = "Failed: invalid port"
return
}
guard await self.preflightGateway(host: host, port: port) else { return }
guard await self.preflightGateway(host: host, port: port, useTLS: self.manualGatewayTLS) else { return }
self.setupStatusText = "Setup code applied. Connecting..."
await self.connectManual()
}
@@ -289,7 +298,7 @@ extension SettingsProTab {
self.setupStatusText = "Failed: invalid port"
return
}
guard await self.preflightGateway(host: host, port: port) else { return }
guard await self.preflightGateway(host: host, port: port, useTLS: self.manualGatewayTLS) else { return }
await self.connectManual()
}
@@ -318,7 +327,7 @@ extension SettingsProTab {
authOverride: authOverride)
}
func preflightGateway(host: String, port: Int) async -> Bool {
func preflightGateway(host: String, port: Int, useTLS: Bool) async -> Bool {
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
if Self.isTailnetHostOrIP(trimmed), !Self.hasTailnetIPv4() {

View File

@@ -3,6 +3,7 @@ import Contacts
import CoreLocation
import CoreMotion
import CryptoKit
import Darwin
import EventKit
import Foundation
import Network
@@ -168,6 +169,11 @@ final class GatewayConnectionController {
}
}
func allowAutoConnectAgain() {
self.didAutoConnect = false
self.maybeAutoConnect()
}
func restartDiscovery() {
self.discovery.stop()
self.didAutoConnect = false
@@ -516,7 +522,8 @@ final class GatewayConnectionController {
let stableID = self.manualStableID(host: manualHost, port: resolvedPort)
let tlsParams = self.resolveManualTLSParams(
stableID: stableID,
tlsEnabled: resolvedUseTLS)
tlsEnabled: resolvedUseTLS,
allowTOFUReset: self.shouldRequireTLS(host: manualHost))
guard let url = self.buildGatewayURL(
host: manualHost,
@@ -712,7 +719,8 @@ final class GatewayConnectionController {
}
private func resolveDiscoveredTLSParams(
gateway: GatewayDiscoveryModel.DiscoveredGateway) -> GatewayTLSParams?
gateway: GatewayDiscoveryModel.DiscoveredGateway,
allowTOFU: Bool) -> GatewayTLSParams?
{
let stableID = gateway.stableID
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
@@ -739,7 +747,8 @@ final class GatewayConnectionController {
private func resolveManualTLSParams(
stableID: String,
tlsEnabled: Bool) -> GatewayTLSParams?
tlsEnabled: Bool,
allowTOFUReset: Bool = false) -> GatewayTLSParams?
{
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
if tlsEnabled || stored != nil {
@@ -776,6 +785,126 @@ final class GatewayConnectionController {
resolver.start()
}
}
private func resolveHostPortFromBonjourEndpoint(_ endpoint: NWEndpoint) async -> (host: String, port: Int)? {
switch endpoint {
case let .hostPort(host, port):
(host: host.debugDescription, port: Int(port.rawValue))
case let .service(name, type, domain, _):
await Self.resolveBonjourServiceToHostPort(name: name, type: type, domain: domain)
default:
nil
}
}
private static func resolveBonjourServiceToHostPort(
name: String,
type: String,
domain: String,
timeoutSeconds: TimeInterval = 3.0) async -> (host: String, port: Int)?
{
// NetService callbacks are delivered via a run loop. If we resolve from a thread without one,
// we can end up never receiving callbacks, which in turn leaks the continuation and leaves
// the UI stuck "connecting". Keep the whole lifecycle on the main run loop and always
// resume the continuation exactly once (timeout/cancel safe).
@MainActor
final class Resolver: NSObject, @preconcurrency NetServiceDelegate {
private var cont: CheckedContinuation<(host: String, port: Int)?, Never>?
private let service: NetService
private var timeoutTask: Task<Void, Never>?
private var finished = false
init(cont: CheckedContinuation<(host: String, port: Int)?, Never>, service: NetService) {
self.cont = cont
self.service = service
super.init()
}
func start(timeoutSeconds: TimeInterval) {
self.service.delegate = self
self.service.schedule(in: .main, forMode: .default)
// NetService has its own timeout, but we keep a manual one as a backstop in case
// callbacks never arrive (e.g. local network permission issues).
self.timeoutTask = Task { @MainActor [weak self] in
guard let self else { return }
let ns = UInt64(max(0.1, timeoutSeconds) * 1_000_000_000)
try? await Task.sleep(nanoseconds: ns)
self.finish(nil)
}
self.service.resolve(withTimeout: timeoutSeconds)
}
func netServiceDidResolveAddress(_ sender: NetService) {
self.finish(Self.extractHostPort(sender))
}
func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
_ = errorDict // currently best-effort; callers surface a generic failure
self.finish(nil)
}
private func finish(_ result: (host: String, port: Int)?) {
guard !self.finished else { return }
self.finished = true
self.timeoutTask?.cancel()
self.timeoutTask = nil
self.service.stop()
self.service.remove(from: .main, forMode: .default)
let c = self.cont
self.cont = nil
c?.resume(returning: result)
}
private static func extractHostPort(_ svc: NetService) -> (host: String, port: Int)? {
let port = svc.port
if let host = svc.hostName?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty {
return (host: host, port: port)
}
guard let addrs = svc.addresses else { return nil }
for addrData in addrs {
let host = addrData.withUnsafeBytes { ptr -> String? in
guard let base = ptr.baseAddress, !ptr.isEmpty else { return nil }
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
let rc = getnameinfo(
base.assumingMemoryBound(to: sockaddr.self),
socklen_t(ptr.count),
&buffer,
socklen_t(buffer.count),
nil,
0,
NI_NUMERICHOST)
guard rc == 0 else { return nil }
let bytes = buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }
return String(bytes: bytes, encoding: .utf8)
}
if let host, !host.isEmpty {
return (host: host, port: port)
}
}
return nil
}
}
return await withCheckedContinuation { cont in
Task { @MainActor in
let service = NetService(domain: domain, type: type, name: name)
let resolver = Resolver(cont: cont, service: service)
// Keep the resolver alive for the lifetime of the NetService resolve.
objc_setAssociatedObject(service, "resolver", resolver, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
resolver.start(timeoutSeconds: timeoutSeconds)
}
}
}
}
extension GatewayConnectionController {
@@ -1033,10 +1162,30 @@ extension GatewayConnectionController {
self.currentCommands()
}
func _test_currentPermissions() async -> [String: Bool] {
await self.currentPermissions()
}
static func _test_isLocationAvailable(servicesEnabled: Bool, status: CLAuthorizationStatus) -> Bool {
self.isLocationAvailable(servicesEnabled: servicesEnabled, status: status)
}
func _test_platformString() -> String {
DeviceInfoHelper.platformString()
}
func _test_deviceFamily() -> String {
DeviceInfoHelper.deviceFamily()
}
func _test_modelIdentifier() -> String {
DeviceInfoHelper.modelIdentifier()
}
func _test_appVersion() -> String {
DeviceInfoHelper.appVersion()
}
func _test_setGateways(_ gateways: [GatewayDiscoveryModel.DiscoveredGateway]) {
self.gateways = gateways
}
@@ -1050,9 +1199,10 @@ extension GatewayConnectionController {
}
func _test_resolveDiscoveredTLSParams(
gateway: GatewayDiscoveryModel.DiscoveredGateway) -> GatewayTLSParams?
gateway: GatewayDiscoveryModel.DiscoveredGateway,
allowTOFU: Bool) -> GatewayTLSParams?
{
self.resolveDiscoveredTLSParams(gateway: gateway)
self.resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: allowTOFU)
}
func _test_resolveManualUseTLS(host: String, useTLS: Bool) -> Bool {

View File

@@ -9,6 +9,11 @@ enum GatewaySettingsStore {
private static let instanceIdDefaultsKey = "node.instanceId"
private static let preferredGatewayStableIDDefaultsKey = "gateway.preferredStableID"
private static let lastDiscoveredGatewayStableIDDefaultsKey = "gateway.lastDiscoveredStableID"
private static let manualEnabledDefaultsKey = "gateway.manual.enabled"
private static let manualHostDefaultsKey = "gateway.manual.host"
private static let manualPortDefaultsKey = "gateway.manual.port"
private static let manualTlsDefaultsKey = "gateway.manual.tls"
private static let discoveryDebugLogsDefaultsKey = "gateway.discovery.debugLogs"
private static let lastGatewayKindDefaultsKey = "gateway.last.kind"
private static let lastGatewayHostDefaultsKey = "gateway.last.host"
private static let lastGatewayPortDefaultsKey = "gateway.last.port"
@@ -179,6 +184,24 @@ enum GatewaySettingsStore {
enum LastGatewayConnection: Equatable {
case manual(host: String, port: Int, useTLS: Bool, stableID: String)
case discovered(stableID: String, useTLS: Bool)
var stableID: String {
switch self {
case let .manual(_, _, _, stableID):
stableID
case let .discovered(stableID, _):
stableID
}
}
var useTLS: Bool {
switch self {
case let .manual(_, _, useTLS, _):
useTLS
case let .discovered(_, useTLS):
useTLS
}
}
}
private enum LastGatewayKind: String, Codable {
@@ -206,6 +229,17 @@ enum GatewaySettingsStore {
return nil
}
static func saveTalkProviderApiKey(_ apiKey: String?, provider: String) {
guard let providerId = self.normalizedTalkProviderID(provider) else { return }
let account = self.talkProviderApiKeyAccount(providerId: providerId)
let trimmed = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmed.isEmpty {
_ = KeychainStore.delete(service: self.talkService, account: account)
return
}
_ = KeychainStore.saveString(trimmed, service: self.talkService, account: account)
}
static func saveLastGatewayConnectionManual(host: String, port: Int, useTLS: Bool, stableID: String) {
let payload = LastGatewayConnectionData(
kind: .manual, stableID: stableID, useTLS: useTLS, host: host, port: port)
@@ -546,4 +580,11 @@ enum GatewayDiagnostics {
}
}
}
static func reset() {
guard let url = fileURL else { return }
self.queue.async {
try? FileManager.default.removeItem(at: url)
}
}
}

View File

@@ -17,6 +17,15 @@ final class LiveActivityManager {
self.hydrateCurrentAndPruneDuplicates()
}
var isActive: Bool {
guard let activity = self.currentActivity else { return false }
guard activity.activityState == .active else {
self.currentActivity = nil
return false
}
return true
}
func showConnecting(statusText: String = "Connecting...", agentName: String, sessionKey: String) {
self.hydrateCurrentAndPruneDuplicates()
@@ -87,6 +96,10 @@ final class LiveActivityManager {
self.endActivity(reason: "connected")
}
func handleDisconnect() {
self.endActivity(reason: "disconnected")
}
func endActivity(reason: String) {
guard let activity = self.currentActivity else { return }
self.currentActivity = nil
@@ -170,6 +183,15 @@ final class LiveActivityManager {
startedAt: self.activityStartDate)
}
private func idleState() -> OpenClawActivityAttributes.ContentState {
OpenClawActivityAttributes.ContentState(
statusText: "Idle",
isIdle: true,
isDisconnected: false,
isConnecting: false,
startedAt: self.activityStartDate)
}
private func disconnectedState() -> OpenClawActivityAttributes.ContentState {
OpenClawActivityAttributes.ContentState(
statusText: "Disconnected",

View File

@@ -14,3 +14,39 @@ struct OpenClawActivityAttributes: ActivityAttributes {
var startedAt: Date
}
}
#if DEBUG
extension OpenClawActivityAttributes {
static let preview = OpenClawActivityAttributes(agentName: "main", sessionKey: "main")
}
extension OpenClawActivityAttributes.ContentState {
static let connecting = OpenClawActivityAttributes.ContentState(
statusText: "Connecting...",
isIdle: false,
isDisconnected: false,
isConnecting: true,
startedAt: .now)
static let idle = OpenClawActivityAttributes.ContentState(
statusText: "Idle",
isIdle: true,
isDisconnected: false,
isConnecting: false,
startedAt: .now)
static let disconnected = OpenClawActivityAttributes.ContentState(
statusText: "Disconnected",
isIdle: false,
isDisconnected: true,
isConnecting: false,
startedAt: .now)
static let attention = OpenClawActivityAttributes.ContentState(
statusText: "Approval needed",
isIdle: false,
isDisconnected: false,
isConnecting: false,
startedAt: .now)
}
#endif

View File

@@ -12,6 +12,8 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic
private let manager = CLLocationManager()
private var authContinuation: CheckedContinuation<CLAuthorizationStatus, Never>?
private var locationContinuation: CheckedContinuation<CLLocation, Swift.Error>?
private var updatesContinuation: AsyncStream<CLLocation>.Continuation?
private var isStreaming = false
private var significantLocationCallback: (@Sendable (CLLocation) -> Void)?
private var isMonitoringSignificantChanges = false
@@ -82,6 +84,42 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic
try await AsyncTimeout.withTimeoutMs(timeoutMs: timeoutMs, onTimeout: { Error.timeout }, operation: operation)
}
func startLocationUpdates(
desiredAccuracy: OpenClawLocationAccuracy,
significantChangesOnly: Bool) -> AsyncStream<CLLocation>
{
self.stopLocationUpdates()
self.manager.desiredAccuracy = LocationCurrentRequest.accuracyValue(desiredAccuracy)
self.manager.pausesLocationUpdatesAutomatically = true
self.manager.allowsBackgroundLocationUpdates = true
self.isStreaming = true
if significantChangesOnly {
self.manager.startMonitoringSignificantLocationChanges()
} else {
self.manager.startUpdatingLocation()
}
return AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in
self.updatesContinuation = continuation
continuation.onTermination = { @Sendable _ in
Task { @MainActor in
self.stopLocationUpdates()
}
}
}
}
func stopLocationUpdates() {
guard self.isStreaming else { return }
self.isStreaming = false
self.manager.stopUpdatingLocation()
self.manager.stopMonitoringSignificantLocationChanges()
self.updatesContinuation?.finish()
self.updatesContinuation = nil
}
func startMonitoringSignificantLocationChanges(onUpdate: @escaping @Sendable (CLLocation) -> Void) {
self.significantLocationCallback = onUpdate
guard !self.isMonitoringSignificantChanges else { return }
@@ -89,6 +127,13 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic
self.manager.startMonitoringSignificantLocationChanges()
}
func stopMonitoringSignificantLocationChanges() {
guard self.isMonitoringSignificantChanges else { return }
self.isMonitoringSignificantChanges = false
self.significantLocationCallback = nil
self.manager.stopMonitoringSignificantLocationChanges()
}
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
let status = manager.authorizationStatus
Task { @MainActor in
@@ -116,6 +161,9 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic
if let callback = self.significantLocationCallback, let latest = locs.last {
callback(latest)
}
if let latest = locs.last, let updates = self.updatesContinuation {
updates.yield(latest)
}
}
}

View File

@@ -133,6 +133,7 @@ final class NodeAppModel {
self.lastGatewayProblem?.statusText ?? self.gatewayStatusText
}
var seamColorHex: String?
private var mainSessionBaseKey: String = "main"
private var focusedChatSessionKey: String?
var selectedAgentId: String?
@@ -187,7 +188,6 @@ final class NodeAppModel {
@ObservationIgnored private var backgroundGraceTaskTimer: Task<Void, Never>?
private var backgroundReconnectSuppressed = false
private var backgroundReconnectLeaseUntil: Date?
@ObservationIgnored private var foregroundGatewayResumeCheckInFlight = false
private var lastSignificantLocationWakeAt: Date?
@ObservationIgnored private let watchReplyCoordinator = WatchReplyCoordinator()
private var watchExecApprovalPromptsByID: [String: ExecApprovalPrompt] = [:]
@@ -201,6 +201,9 @@ final class NodeAppModel {
private var apnsDeviceTokenHex: String?
private var apnsLastRegisteredTokenHex: String?
@ObservationIgnored private let pushRegistrationManager = PushRegistrationManager()
var gatewaySession: GatewayNodeSession {
self.nodeGateway
}
var operatorSession: GatewayNodeSession {
self.operatorGateway
@@ -211,7 +214,6 @@ final class NodeAppModel {
private static let watchExecApprovalBridgeStateKey = "watch.execApproval.bridge.state.v1"
private static let backgroundAliveLastSuccessAtMsKey = "gateway.backgroundAlive.lastSuccessAtMs"
private static let backgroundAliveLastTriggerKey = "gateway.backgroundAlive.lastTrigger"
private static let foregroundResumeHealthTimeoutSeconds = 1
var cameraHUDText: String?
var cameraHUDKind: CameraHUDKind?
@@ -415,7 +417,9 @@ final class NodeAppModel {
self.isBackgrounded = false
self.endBackgroundConnectionGracePeriod(reason: "scene_foreground")
self.clearBackgroundReconnectSuppression(reason: "scene_foreground")
var shouldStartGatewayHealthMonitor = self.operatorConnected
if self.operatorConnected {
self.startGatewayHealthMonitor()
}
if phase == .active {
self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: self.backgroundVoiceWakeSuspended)
self.backgroundVoiceWakeSuspended = false
@@ -440,8 +444,6 @@ final class NodeAppModel {
// iOS may suspend network sockets in background without a clean close.
// On foreground, force a fresh handshake to avoid "connected but dead" states.
if backgroundedFor >= 3.0 {
shouldStartGatewayHealthMonitor = false
self.foregroundGatewayResumeCheckInFlight = true
Task { [weak self] in
guard let self else { return }
let operatorWasConnected = await MainActor.run { self.operatorConnected }
@@ -450,26 +452,31 @@ final class NodeAppModel {
let healthy = await (try? self.operatorGateway.request(
method: "health",
paramsJSON: nil,
timeoutSeconds: Self.foregroundResumeHealthTimeoutSeconds)) != nil
timeoutSeconds: 2)) != nil
if healthy {
await MainActor.run {
self.foregroundGatewayResumeCheckInFlight = false
self.startGatewayHealthMonitor()
}
await MainActor.run { self.startGatewayHealthMonitor() }
return
}
}
await self.operatorGateway.disconnect()
await self.nodeGateway.disconnect()
await MainActor.run {
self.foregroundGatewayResumeCheckInFlight = false
guard !self.isAppleReviewDemoModeEnabled else { return }
self.setOperatorConnected(false)
self.gatewayConnected = false
// Foreground recovery must actively restart the saved gateway config.
// Disconnecting stale sockets alone can leave us idle if the old
// reconnect tasks were suppressed or otherwise got stuck in background.
self.gatewayStatusText = "Reconnecting…"
self.talkMode.updateGatewayConnected(false)
if let cfg = self.activeGatewayConnectConfig {
self.applyGatewayConnectConfig(cfg)
}
}
await self.restartGatewaySessionsAfterForegroundStaleConnection()
}
}
}
if shouldStartGatewayHealthMonitor {
self.startGatewayHealthMonitor()
}
@unknown default:
self.isBackgrounded = false
self.endBackgroundConnectionGracePeriod(reason: "scene_unknown")
@@ -720,6 +727,11 @@ final class NodeAppModel {
}
}
var seamColor: Color {
Self.color(fromHex: self.seamColorHex) ?? Self.defaultSeamColor
}
private static let defaultSeamColor = Color(red: 79 / 255.0, green: 122 / 255.0, blue: 154 / 255.0)
private static let apnsDeviceTokenUserDefaultsKey = "push.apns.deviceTokenHex"
private static let deepLinkKeyUserDefaultsKey = "deeplink.agent.key"
private static let canvasUnattendedDeepLinkKey: String = NodeAppModel.generateDeepLinkKey()
@@ -729,9 +741,12 @@ final class NodeAppModel {
let res = try await self.operatorGateway.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8)
guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return }
guard let config = json["config"] as? [String: Any] else { return }
let ui = config["ui"] as? [String: Any]
let raw = (ui?["seamColor"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let session = config["session"] as? [String: Any]
let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
await MainActor.run {
self.seamColorHex = raw.isEmpty ? nil : raw
self.mainSessionBaseKey = mainKey
self.talkMode.updateMainSessionKey(self.mainSessionKey)
self.homeCanvasRevision &+= 1
@@ -771,12 +786,6 @@ final class NodeAppModel {
func refreshGatewayOverviewIfConnected() async {
guard await self.isOperatorConnected() else { return }
if self.foregroundGatewayResumeCheckInFlight {
GatewayDiagnostics.log("gateway overview refresh deferred reason=foreground_resume_check")
try? await Task.sleep(
nanoseconds: UInt64(Self.foregroundResumeHealthTimeoutSeconds) * 1_000_000_000)
guard await self.isOperatorConnected(), !self.foregroundGatewayResumeCheckInFlight else { return }
}
await self.refreshBrandingFromGateway()
await self.refreshAgentsFromGateway()
}
@@ -1936,7 +1945,7 @@ extension NodeAppModel {
}
self.activeGatewayConnectConfig = nextConfig
self.prepareForGatewayConnect(stableID: effectiveStableID)
self.prepareForGatewayConnect(url: url, stableID: effectiveStableID)
if operatorLoopRequired {
self.startOperatorGatewayLoop(
url: url,
@@ -1977,33 +1986,12 @@ extension NodeAppModel {
}
func resetGatewaySessionsForForcedReconnect() async {
let nodeGatewayTask = self.nodeGatewayTask
let operatorGatewayTask = self.operatorGatewayTask
nodeGatewayTask?.cancel()
self.nodeGatewayTask?.cancel()
self.nodeGatewayTask = nil
operatorGatewayTask?.cancel()
self.operatorGatewayTask?.cancel()
self.operatorGatewayTask = nil
await self.operatorGateway.disconnect()
await self.nodeGateway.disconnect()
// Foreground recovery reuses the same config immediately after reset.
// Wait for canceled loops so their shutdown cleanup cannot clobber the new reconnect state.
if let operatorGatewayTask {
await operatorGatewayTask.value
}
if let nodeGatewayTask {
await nodeGatewayTask.value
}
}
private func restartGatewaySessionsAfterForegroundStaleConnection() async {
await self.resetGatewaySessionsForForcedReconnect()
guard !self.isAppleReviewDemoModeEnabled else { return }
self.setOperatorConnected(false)
self.gatewayConnected = false
self.gatewayStatusText = "Reconnecting…"
self.talkMode.updateGatewayConnected(false)
guard let cfg = self.activeGatewayConnectConfig else { return }
self.applyGatewayConnectConfig(cfg, forceReconnect: true)
}
func disconnectGateway() {
@@ -2033,6 +2021,7 @@ extension NodeAppModel {
self.gatewayConnected = false
self.setOperatorConnected(false)
self.talkMode.updateGatewayConnected(false)
self.seamColorHex = nil
self.mainSessionBaseKey = "main"
self.talkMode.updateMainSessionKey(self.mainSessionKey)
ShareGatewayRelaySettings.clearConfig()
@@ -2041,7 +2030,7 @@ extension NodeAppModel {
}
extension NodeAppModel {
private func prepareForGatewayConnect(stableID: String) {
private func prepareForGatewayConnect(url: URL, stableID: String) {
self.isAppleReviewDemoModeEnabled = false
self.gatewayAutoReconnectEnabled = true
self.gatewayPairingPaused = false
@@ -2645,6 +2634,7 @@ extension NodeAppModel {
self.gatewayConnected = false
self.setOperatorConnected(false)
self.talkMode.updateGatewayConnected(false)
self.seamColorHex = nil
self.mainSessionBaseKey = "main"
self.talkMode.updateMainSessionKey(self.mainSessionKey)
self.showLocalCanvasOnDisconnect()
@@ -2817,6 +2807,7 @@ extension NodeAppModel {
self.talkMode.updateGatewayConnected(false)
self.talkMode.setEnabled(false)
self.talkMode.statusText = "Demo mode only"
self.seamColorHex = nil
self.mainSessionBaseKey = "main"
self.selectedAgentId = nil
self.gatewayDefaultAgentId = "main"
@@ -2931,6 +2922,14 @@ extension NodeAppModel {
self.refreshLastShareEventFromRelay()
}
func reloadTalkConfig() {
Task { [weak self] in
guard let self else { return }
await self.talkMode.reloadConfig()
await self.talkMode.prefetchRealtimeSessionIfReady(reason: "config_reload")
}
}
/// Back-compat hook retained for older gateway-connect flows.
func onNodeGatewayConnected() async {
await self.registerAPNsTokenIfNeeded()
@@ -3891,6 +3890,32 @@ extension NodeAppModel {
}
}
func handleExecApprovalNotificationDecision(
approvalId: String,
decision: String) async
{
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedApprovalID.isEmpty else { return }
if self.pendingExecApprovalPrompt?.id == normalizedApprovalID {
self.pendingExecApprovalPromptResolving = true
self.pendingExecApprovalPromptErrorText = nil
}
let outcome = await self.resolveExecApprovalNotificationDecision(
approvalId: normalizedApprovalID,
decision: decision)
switch outcome {
case .resolved, .stale, .unavailable:
break
case let .failed(message):
if self.pendingExecApprovalPrompt?.id == normalizedApprovalID {
self.pendingExecApprovalPromptResolving = false
self.pendingExecApprovalPromptErrorText = message
}
}
}
private func resolveExecApprovalNotificationDecision(
approvalId: String,
decision: String,
@@ -4427,6 +4452,17 @@ extension NodeAppModel {
self.talkMode.updateMainSessionKey(self.mainSessionKey)
}
private static func color(fromHex raw: String?) -> Color? {
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed
guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil }
let r = Double((value >> 16) & 0xFF) / 255.0
let g = Double((value >> 8) & 0xFF) / 255.0
let b = Double(value & 0xFF) / 255.0
return Color(red: r, green: g, blue: b)
}
func approvePendingAgentDeepLinkPrompt() async {
guard let prompt = self.pendingAgentDeepLinkPrompt else { return }
self.pendingAgentDeepLinkPrompt = nil
@@ -4576,10 +4612,30 @@ extension NodeAppModel {
try self.encodePayload(obj)
}
func _test_isCameraEnabled() -> Bool {
self.isCameraEnabled()
}
func _test_triggerCameraFlash() {
self.triggerCameraFlash()
}
func _test_showCameraHUD(text: String, kind: CameraHUDKind, autoHideSeconds: Double? = nil) {
self.showCameraHUD(text: text, kind: kind, autoHideSeconds: autoHideSeconds)
}
func _test_handleCanvasA2UIAction(body: [String: Any]) async {
await self.handleCanvasA2UIAction(body: body)
}
func _test_showLocalCanvasOnDisconnect() {
self.showLocalCanvasOnDisconnect()
}
func _test_applyTalkModeSync(enabled: Bool, phase: String? = nil) {
self.applyTalkModeSync(enabled: enabled, phase: phase)
}
func _test_queuedWatchReplyCount() -> Int {
self.watchReplyCoordinator.queuedCount
}
@@ -4770,10 +4826,6 @@ extension NodeAppModel {
(self.nodeGatewayTask != nil, self.operatorGatewayTask != nil)
}
func _test_restartGatewaySessionsAfterForegroundStaleConnection() async {
await self.restartGatewaySessionsAfterForegroundStaleConnection()
}
func _test_handleSuccessfulBootstrapGatewayOnboarding() async {
await self.handleSuccessfulBootstrapGatewayOnboarding(
url: URL(string: "wss://gateway.example")!,

View File

@@ -0,0 +1,365 @@
import Foundation
import OpenClawKit
import SwiftUI
struct GatewayOnboardingView: View {
var body: some View {
NavigationStack {
List {
Section {
Text("Connect to your gateway to get started.")
.foregroundStyle(.secondary)
}
Section {
NavigationLink("Auto detect") {
AutoDetectStep()
}
NavigationLink("Manual entry") {
ManualEntryStep()
}
}
}
.navigationTitle("Connect Gateway")
}
.gatewayTrustPromptAlert()
}
}
private struct AutoDetectStep: View {
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
@AppStorage("gateway.lastDiscoveredStableID") private var lastDiscoveredGatewayStableID: String = ""
@State private var connectingGatewayID: String?
@State private var connectStatusText: String?
var body: some View {
Form {
Section {
Text("Well scan for gateways on your network and connect automatically when we find one.")
.foregroundStyle(.secondary)
}
gatewayConnectionStatusSection(
appModel: self.appModel,
gatewayController: self.gatewayController,
secondaryLine: self.connectStatusText)
Section {
Button("Retry") {
resetGatewayConnectionState(
appModel: self.appModel,
connectStatusText: &self.connectStatusText,
connectingGatewayID: &self.connectingGatewayID)
self.triggerAutoConnect()
}
.disabled(self.connectingGatewayID != nil)
}
}
.navigationTitle("Auto detect")
.onAppear { self.triggerAutoConnect() }
.onChange(of: self.gatewayController.gateways) { _, _ in
self.triggerAutoConnect()
}
}
private func triggerAutoConnect() {
guard self.appModel.gatewayServerName == nil else { return }
guard self.connectingGatewayID == nil else { return }
guard let candidate = self.autoCandidate() else { return }
self.connectingGatewayID = candidate.id
Task {
defer { self.connectingGatewayID = nil }
await self.gatewayController.connect(candidate)
}
}
private func autoCandidate() -> GatewayDiscoveryModel.DiscoveredGateway? {
let preferred = self.preferredGatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
let lastDiscovered = self.lastDiscoveredGatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
if !preferred.isEmpty,
let match = self.gatewayController.gateways.first(where: { $0.stableID == preferred })
{
return match
}
if !lastDiscovered.isEmpty,
let match = self.gatewayController.gateways.first(where: { $0.stableID == lastDiscovered })
{
return match
}
if self.gatewayController.gateways.count == 1 {
return self.gatewayController.gateways.first
}
return nil
}
}
private struct ManualEntryStep: View {
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
@State private var setupCode: String = ""
@State private var setupStatusText: String?
@State private var manualHost: String = ""
@State private var manualPortText: String = ""
@State private var manualUseTLS: Bool = true
@State private var manualToken: String = ""
@State private var manualPassword: String = ""
@State private var pendingManualAuthOverride: GatewayConnectionController.ManualAuthOverride?
@State private var connectingGatewayID: String?
@State private var connectStatusText: String?
var body: some View {
Form {
Section("Setup code") {
Text("Use /pair in your bot to get a setup code.")
.font(.footnote)
.foregroundStyle(.secondary)
TextField("Paste setup code", text: self.$setupCode)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button("Apply setup code") {
self.applySetupCode()
}
.disabled(self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
if let setupStatusText, !setupStatusText.isEmpty {
Text(setupStatusText)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
Section {
TextField("Host", text: self.$manualHost)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
TextField("Port", text: self.$manualPortText)
.keyboardType(.numberPad)
Toggle("Use TLS", isOn: self.$manualUseTLS)
TextField("Gateway token", text: self.$manualToken)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
SecureField("Gateway password", text: self.$manualPassword)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
}
gatewayConnectionStatusSection(
appModel: self.appModel,
gatewayController: self.gatewayController,
secondaryLine: self.connectStatusText)
Section {
Button {
Task { await self.connectManual() }
} label: {
if self.connectingGatewayID == "manual" {
HStack(spacing: 8) {
ProgressView()
.progressViewStyle(.circular)
Text("Connecting…")
}
} else {
Text("Connect")
}
}
.disabled(self.connectingGatewayID != nil)
Button("Retry") {
resetGatewayConnectionState(
appModel: self.appModel,
connectStatusText: &self.connectStatusText,
connectingGatewayID: &self.connectingGatewayID)
self.resetManualForm()
}
.disabled(self.connectingGatewayID != nil)
}
}
.navigationTitle("Manual entry")
}
private func connectManual() async {
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard !host.isEmpty else {
self.connectStatusText = "Failed: host required"
return
}
if let port = self.manualPortValue(), !(1...65535).contains(port) {
self.connectStatusText = "Failed: invalid port"
return
}
let defaults = UserDefaults.standard
defaults.set(true, forKey: "gateway.manual.enabled")
defaults.set(host, forKey: "gateway.manual.host")
defaults.set(self.manualPortValue() ?? 0, forKey: "gateway.manual.port")
defaults.set(self.manualUseTLS, forKey: "gateway.manual.tls")
let instanceId = GatewaySettingsStore.currentInstanceID()
if !instanceId.isEmpty {
let trimmedToken = self.manualToken.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedPassword = self.manualPassword.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedToken.isEmpty {
GatewaySettingsStore.saveGatewayToken(trimmedToken, instanceId: instanceId)
}
GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: instanceId)
}
self.connectingGatewayID = "manual"
defer { self.connectingGatewayID = nil }
let authOverride = GatewayConnectionController.ManualAuthOverride.currentManualInput(
token: self.manualToken,
pendingOverride: self.pendingManualAuthOverride,
password: self.manualPassword)
self.pendingManualAuthOverride = nil
await self.gatewayController.connectManual(
host: host,
port: self.manualPortValue() ?? 0,
useTLS: self.manualUseTLS,
authOverride: authOverride)
}
private func manualPortValue() -> Int? {
let trimmed = self.manualPortText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
return Int(trimmed.filter(\.isNumber))
}
private func resetManualForm() {
self.setupCode = ""
self.setupStatusText = nil
self.manualHost = ""
self.manualPortText = ""
self.manualUseTLS = true
self.manualToken = ""
self.manualPassword = ""
}
private func applySetupCode() {
let raw = self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines)
guard !raw.isEmpty else {
self.setupStatusText = "Paste a setup code to continue."
return
}
if AppleReviewDemoMode.isSetupCode(raw) {
self.setupCode = ""
self.setupStatusText = "Apple Review demo mode enabled."
self.appModel.enterAppleReviewDemoMode()
return
}
guard let link = GatewayConnectDeepLink.fromSetupInput(raw) else {
self.setupStatusText = "Setup code not recognized or uses an insecure ws:// gateway URL."
return
}
self.manualHost = link.host
self.manualPortText = String(link.port)
self.manualUseTLS = link.tls
let setupAuth = GatewayConnectionController.ManualAuthOverride.setupAuth(from: link)
if setupAuth.shouldApplyTokenField {
self.manualToken = setupAuth.token
}
if setupAuth.shouldApplyPasswordField {
self.manualPassword = setupAuth.password
}
let trimmedInstanceId = GatewaySettingsStore.currentInstanceID()
if !trimmedInstanceId.isEmpty {
if setupAuth.hasBootstrapToken {
GatewayOnboardingReset.prepareForBootstrapPairing(
appModel: self.appModel,
instanceId: trimmedInstanceId)
}
GatewaySettingsStore.saveGatewayBootstrapToken(setupAuth.bootstrapToken, instanceId: trimmedInstanceId)
}
self.pendingManualAuthOverride = setupAuth.manualAuthOverride
self.setupStatusText = "Setup code applied."
}
}
@MainActor
private func gatewayConnectionStatusLines(
appModel: NodeAppModel,
gatewayController: GatewayConnectionController) -> [String]
{
ConnectionStatusBox.defaultLines(appModel: appModel, gatewayController: gatewayController)
}
@MainActor
private func resetGatewayConnectionState(
appModel: NodeAppModel,
connectStatusText: inout String?,
connectingGatewayID: inout String?)
{
appModel.disconnectGateway()
connectStatusText = nil
connectingGatewayID = nil
}
@MainActor
private func gatewayConnectionStatusSection(
appModel: NodeAppModel,
gatewayController: GatewayConnectionController,
secondaryLine: String?) -> some View
{
Section("Connection status") {
ConnectionStatusBox(
statusLines: gatewayConnectionStatusLines(
appModel: appModel,
gatewayController: gatewayController),
secondaryLine: secondaryLine)
}
}
private struct ConnectionStatusBox: View {
let statusLines: [String]
let secondaryLine: String?
var body: some View {
VStack(alignment: .leading, spacing: 6) {
ForEach(self.statusLines, id: \.self) { line in
Text(line)
.font(.system(size: 12, weight: .regular, design: .monospaced))
.foregroundStyle(.secondary)
}
if let secondaryLine, !secondaryLine.isEmpty {
Text(secondaryLine)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
}
static func defaultLines(
appModel: NodeAppModel,
gatewayController: GatewayConnectionController) -> [String]
{
var lines: [String] = [
"gateway: \(appModel.gatewayDisplayStatusText)",
"discovery: \(gatewayController.discoveryStatusText)",
]
lines.append("server: \(appModel.gatewayServerName ?? "")")
lines.append("address: \(appModel.gatewayRemoteAddress ?? "")")
return lines
}
}

View File

@@ -53,6 +53,10 @@ enum OnboardingStateStore {
defaults.set(true, forKey: self.firstRunIntroSeenDefaultsKey)
}
static func markIncomplete(defaults: UserDefaults = .standard) {
defaults.set(false, forKey: self.completedDefaultsKey)
}
static func reset(defaults: UserDefaults = .standard) {
defaults.set(false, forKey: self.completedDefaultsKey)
defaults.set(false, forKey: self.firstRunIntroSeenDefaultsKey)

View File

@@ -17,6 +17,10 @@ private enum OnboardingStep: Int, CaseIterable {
Self(rawValue: self.rawValue - 1)
}
var next: Self? {
Self(rawValue: self.rawValue + 1)
}
/// Progress label for the manual setup flow (mode connect auth success).
var manualProgressTitle: String {
let manualSteps: [OnboardingStep] = [.mode, .connect, .auth, .success]

View File

@@ -39,6 +39,10 @@ struct PushBuildConfig {
self.relayBaseURL = Self.readURL(bundle: bundle, key: "OpenClawPushRelayBaseURL")
}
var usesRelay: Bool {
self.transport == .relay
}
private static func readURL(bundle: Bundle, key: String) -> URL? {
guard let raw = bundle.object(forInfoDictionaryKey: key) as? String else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)

View File

@@ -71,6 +71,11 @@ enum PushRelayRegistrationStore {
return KeychainStore.saveString(raw, service: self.service, account: self.registrationStateAccount)
}
@discardableResult
static func clearRegistrationState() -> Bool {
KeychainStore.delete(service: self.service, account: self.registrationStateAccount)
}
static func loadAppAttestKeyID() -> String? {
let value = KeychainStore.loadString(service: self.service, account: self.appAttestKeyIDAccount)?
.trimmingCharacters(in: .whitespacesAndNewlines)

View File

@@ -7,6 +7,7 @@ extension RootTabs {
980
}
static let sidebarSplitMinimumWidth: CGFloat = 292
static let sidebarSplitIdealWidth: CGFloat = 316
static let sidebarSplitMaximumWidth: CGFloat = 340
static let sidebarDrawerMaximumWidth: CGFloat = 340

View File

@@ -0,0 +1,15 @@
import SwiftUI
struct RootView: View {
@AppStorage(AppAppearancePreference.storageKey) private var appearancePreferenceRaw: String =
AppAppearancePreference.system.rawValue
var body: some View {
RootTabs()
.preferredColorScheme(self.appearancePreference.colorScheme)
}
private var appearancePreference: AppAppearancePreference {
AppAppearancePreference(rawValue: self.appearancePreferenceRaw) ?? .system
}
}

View File

@@ -181,6 +181,16 @@ final class ScreenController {
return try await WebViewJavaScriptSupport.evaluateToString(webView: webView, javaScript: javaScript)
}
func snapshotPNGBase64(maxWidth: CGFloat? = nil) async throws -> String {
let image = try await self.snapshotImage(maxWidth: maxWidth)
guard let data = image.pngData() else {
throw NSError(domain: "Screen", code: 1, userInfo: [
NSLocalizedDescriptionKey: "snapshot encode failed",
])
}
return data.base64EncodedString()
}
func snapshotBase64(
maxWidth: CGFloat? = nil,
format: OpenClawCanvasSnapshotFormat,

View File

@@ -31,7 +31,12 @@ protocol LocationServicing: Sendable {
desiredAccuracy: OpenClawLocationAccuracy,
maxAgeMs: Int?,
timeoutMs: Int?) async throws -> CLLocation
func startLocationUpdates(
desiredAccuracy: OpenClawLocationAccuracy,
significantChangesOnly: Bool) -> AsyncStream<CLLocation>
func stopLocationUpdates()
func startMonitoringSignificantLocationChanges(onUpdate: @escaping @Sendable (CLLocation) -> Void)
func stopMonitoringSignificantLocationChanges()
}
@MainActor

View File

@@ -22,4 +22,11 @@ enum SessionKey {
let agentId = String(parts[1]).trimmingCharacters(in: .whitespacesAndNewlines)
return agentId.isEmpty ? nil : agentId
}
static func isCanonicalMainSessionKey(_ value: String?) -> Bool {
let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return false }
if trimmed == "global" { return true }
return trimmed.hasPrefix("agent:")
}
}

View File

@@ -0,0 +1,40 @@
import Foundation
struct SettingsHostPort: Equatable {
var host: String
var port: Int
}
enum SettingsNetworkingHelpers {
static func parseHostPort(from address: String) -> SettingsHostPort? {
let trimmed = address.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if trimmed.hasPrefix("["),
let close = trimmed.firstIndex(of: "]"),
close < trimmed.endIndex
{
let host = String(trimmed[trimmed.index(after: trimmed.startIndex)..<close])
let portStart = trimmed.index(after: close)
guard portStart < trimmed.endIndex, trimmed[portStart] == ":" else { return nil }
let portString = String(trimmed[trimmed.index(after: portStart)...])
guard let port = Int(portString) else { return nil }
return SettingsHostPort(host: host, port: port)
}
guard let colon = trimmed.lastIndex(of: ":") else { return nil }
let host = String(trimmed[..<colon])
let portString = String(trimmed[trimmed.index(after: colon)...])
guard !host.isEmpty, let port = Int(portString) else { return nil }
return SettingsHostPort(host: host, port: port)
}
static func httpURLString(host: String?, port: Int?, fallback: String) -> String {
if let host, let port {
let needsBrackets = host.contains(":") && !host.hasPrefix("[") && !host.hasSuffix("]")
let hostPart = needsBrackets ? "[\(host)]" : host
return "http://\(hostPart):\(port)"
}
return "http://\(fallback)"
}
}

View File

@@ -3,6 +3,7 @@ import OpenClawKit
enum TalkModeExecutionMode {
case native
case realtimeClient
case realtimeRelay
}

View File

@@ -64,6 +64,22 @@ extension TalkModeManager {
}
}
static func permissionMessage(
kind: String,
status: AVAudioSession.RecordPermission) -> String
{
switch status {
case .denied:
return "\(kind) permission denied"
case .undetermined:
return "\(kind) permission not granted"
case .granted:
return "\(kind) permission denied"
@unknown default:
return "\(kind) permission denied"
}
}
static func permissionMessage(
kind: String,
status: SFSpeechRecognizerAuthorizationStatus) -> String

View File

@@ -70,6 +70,11 @@ final class TalkModeManager: NSObject {
self.gatewayConnected
}
var hasActiveAudioCapture: Bool {
self.isEnabled || self.isListening || self.isPushToTalkActive || self.realtimeRelaySession != nil
|| self.realtimeRelayStartInFlight
}
private enum CaptureMode {
case idle
case continuous
@@ -470,6 +475,13 @@ final class TalkModeManager: NSObject {
return wasActive
}
func setForegroundAudioCaptureAllowed(_ allowed: Bool) {
self.foregroundAudioCaptureAllowed = allowed
if !allowed {
self.cancelPendingStart()
}
}
func resumeAfterBackground(wasSuspended: Bool, wasKeptActive: Bool = false) async {
if wasKeptActive { return }
guard wasSuspended else { return }
@@ -477,6 +489,14 @@ final class TalkModeManager: NSObject {
await self.start()
}
func userTappedOrb() {
if let realtimeSession {
realtimeSession.cancelResponse()
}
self.realtimeRelaySession?.cancelOutput()
self.stopSpeaking()
}
func beginPushToTalk() async throws -> OpenClawTalkPTTStartPayload {
guard self.gatewayConnected else {
self.statusText = "Offline"
@@ -3084,6 +3104,23 @@ extension TalkModeManager {
self.gatewayTalkCurrentFallbackIssue
}
func _test_seedTranscript(_ transcript: String) {
self.lastTranscript = transcript
self.lastHeard = Date()
}
func _test_handleTranscript(_ transcript: String, isFinal: Bool) async {
await self.handleTranscript(transcript: transcript, isFinal: isFinal)
}
func _test_backdateLastHeard(seconds: TimeInterval) {
self.lastHeard = Date().addingTimeInterval(-seconds)
}
func _test_runSilenceCheck() async {
await self.checkSilence()
}
func _test_incrementalReset() {
self.incrementalSpeechBuffer = IncrementalSpeechBuffer()
}

View File

@@ -3,6 +3,7 @@ import SwiftUI
struct TalkPermissionPromptView: View {
enum Style {
case card
case settings
case sheet
}

View File

@@ -61,6 +61,7 @@ final class TalkRealtimeWebRTCSession: NSObject {
let runId: String?
let status: String?
let startedAt: Double?
let endedAt: Double?
let error: String?
let stopReason: String?
let timeoutPhase: String?
@@ -195,6 +196,11 @@ final class TalkRealtimeWebRTCSession: NSObject {
Self.logger.info("timeline +\(self.elapsedMs(), privacy: .public)ms \(message, privacy: .public)")
}
func cancelResponse() {
self.sendRealtimeEvent(["type": "response.cancel"])
self.cancelActiveToolCalls()
}
private func cancelActiveToolCalls() {
let runIds = Array(Set(activeToolRunIds.values))
for task in self.activeToolTasks.values {

View File

@@ -70,6 +70,14 @@ enum TalkSpeechLocale {
return (recognizer, recognizer?.locale.identifier)
}
static func normalizedExplicitLocaleID(_ raw: String?) -> String? {
TalkConfigParsing.normalizedExplicitSpeechLocaleID(raw, automaticID: self.automaticID)
}
private static func normalizedLocaleID(_ raw: String?) -> String? {
TalkConfigParsing.normalizedSpeechLocaleID(raw)
}
private static func canonicalID(_ raw: String) -> String {
raw.replacingOccurrences(of: "_", with: "-")
}

View File

@@ -16,6 +16,7 @@ Sources/Design/ChatProTab.swift
Sources/Design/CommandCenterTab.swift
Sources/Design/TalkProTab.swift
Sources/Design/OpenClawProComponents.swift
Sources/Design/OpenClawProScreens.swift
Sources/Design/SettingsProTab.swift
Sources/Design/SettingsProTabSupport.swift
Sources/Design/SettingsProTabSections.swift
@@ -66,6 +67,7 @@ Sources/Model/NodeAppModel.swift
Sources/Model/WatchReplyCoordinator.swift
Sources/Motion/MotionService.swift
Sources/Onboarding/GatewayOnboardingReset.swift
Sources/Onboarding/GatewayOnboardingView.swift
Sources/Onboarding/OnboardingStateStore.swift
Sources/Onboarding/OnboardingWizardSteps.swift
Sources/Onboarding/OnboardingWizardView.swift
@@ -81,6 +83,7 @@ Sources/Push/PushRelayKeychainStore.swift
Sources/Reminders/RemindersService.swift
Sources/RootTabs.swift
Sources/RootTabsNavigation.swift
Sources/RootView.swift
Sources/Screen/ScreenController.swift
Sources/Screen/ScreenRecordService.swift
Sources/Screen/ScreenWebView.swift
@@ -91,6 +94,7 @@ Sources/Services/WatchMessagingPayloadCodec.swift
Sources/Services/WatchMessagingService.swift
Sources/SessionKey.swift
Sources/Settings/PrivacyAccessSectionView.swift
Sources/Settings/SettingsNetworkingHelpers.swift
Sources/Settings/VoiceWakeWordsSettingsView.swift
Sources/Status/GatewayStatusBuilder.swift
Sources/Status/VoiceWakeToast.swift

View File

@@ -356,20 +356,6 @@ import UIKit
#expect(!appModel._test_hasGatewayLoopTasks().operator)
}
@Test @MainActor func foregroundStaleConnectionRestartReappliesActiveGatewayConfig() async {
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
let config = Self.makeGatewayConnectConfig()
appModel.applyGatewayConnectConfig(config)
await appModel._test_restartGatewaySessionsAfterForegroundStaleConnection()
#expect(appModel.gatewayStatusText == "Reconnecting…")
#expect(appModel.activeGatewayConnectConfig?.hasSameConnectionInputs(as: config) == true)
#expect(appModel._test_hasGatewayLoopTasks().node)
#expect(appModel._test_hasGatewayLoopTasks().operator)
}
@Test @MainActor func loadLastConnectionReadsSavedValues() {
let prior = KeychainStore.loadString(service: "ai.openclaw.gateway", account: "lastConnection")
defer {

View File

@@ -39,19 +39,19 @@ import Testing
@Test @MainActor func discoveredTLSParams_prefersStoredPinOverAdvertisedTXT() async {
let stableID = "test|\(UUID().uuidString)"
defer { clearTLSFingerprint(stableID: stableID) }
self.clearTLSFingerprint(stableID: stableID)
clearTLSFingerprint(stableID: stableID)
GatewayTLSStore.saveFingerprint("11", stableID: stableID)
let gateway = self.makeDiscoveredGateway(
let gateway = makeDiscoveredGateway(
stableID: stableID,
lanHost: "evil.example.com",
tailnetDns: "evil.example.com",
gatewayPort: 12345,
fingerprint: "22")
let controller = self.makeController()
let controller = makeController()
let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway)
let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: true)
#expect(params?.expectedFingerprint == "11")
#expect(params?.allowTOFU == false)
}
@@ -59,17 +59,17 @@ import Testing
@Test @MainActor func discoveredTLSParams_doesNotTrustAdvertisedFingerprint() async {
let stableID = "test|\(UUID().uuidString)"
defer { clearTLSFingerprint(stableID: stableID) }
self.clearTLSFingerprint(stableID: stableID)
clearTLSFingerprint(stableID: stableID)
let gateway = self.makeDiscoveredGateway(
let gateway = makeDiscoveredGateway(
stableID: stableID,
lanHost: nil,
tailnetDns: nil,
gatewayPort: nil,
fingerprint: "22")
let controller = self.makeController()
let controller = makeController()
let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway)
let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: true)
#expect(params?.expectedFingerprint == nil)
#expect(params?.allowTOFU == false)
}
@@ -77,7 +77,7 @@ import Testing
@Test @MainActor func autoconnectRequiresStoredPinForDiscoveredGateways() async {
let stableID = "test|\(UUID().uuidString)"
defer { clearTLSFingerprint(stableID: stableID) }
self.clearTLSFingerprint(stableID: stableID)
clearTLSFingerprint(stableID: stableID)
let defaults = UserDefaults.standard
defaults.set(true, forKey: "gateway.autoconnect")
@@ -90,13 +90,13 @@ import Testing
defaults.removeObject(forKey: "gateway.preferredStableID")
defaults.set(stableID, forKey: "gateway.lastDiscoveredStableID")
let gateway = self.makeDiscoveredGateway(
let gateway = makeDiscoveredGateway(
stableID: stableID,
lanHost: "test.local",
tailnetDns: nil,
gatewayPort: 18789,
fingerprint: nil)
let controller = self.makeController()
let controller = makeController()
controller._test_setGateways([gateway])
controller._test_triggerAutoConnect()
@@ -104,7 +104,7 @@ import Testing
}
@Test @MainActor func manualConnectionsForceTLSForNonLoopbackHosts() async {
let controller = self.makeController()
let controller = makeController()
#expect(controller._test_resolveManualUseTLS(host: "gateway.example.com", useTLS: false) == true)
#expect(controller._test_resolveManualUseTLS(host: "127.attacker.example", useTLS: false) == true)
@@ -120,7 +120,7 @@ import Testing
}
@Test @MainActor func manualConnectionsAllowPrivateLanPlaintext() async {
let controller = self.makeController()
let controller = makeController()
#expect(controller._test_resolveManualUseTLS(host: "openclaw.local", useTLS: false) == false)
#expect(controller._test_resolveManualUseTLS(host: "192.168.1.20", useTLS: false) == false)
@@ -131,7 +131,7 @@ import Testing
}
@Test @MainActor func manualDefaultPortUses443OnlyForTailnetTLSHosts() async {
let controller = self.makeController()
let controller = makeController()
#expect(controller._test_resolveManualPort(host: "gateway.example.com", port: 0, useTLS: true) == 18789)
#expect(controller._test_resolveManualPort(host: "device.sample.ts.net", port: 0, useTLS: true) == 443)

View File

@@ -9,9 +9,11 @@ private struct KeychainEntry: Hashable {
private let gatewayService = "ai.openclaw.gateway"
private let nodeService = "ai.openclaw.node"
private let talkService = "ai.openclaw.talk"
private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId")
private let preferredGatewayEntry = KeychainEntry(service: gatewayService, account: "preferredStableID")
private let lastGatewayEntry = KeychainEntry(service: gatewayService, account: "lastDiscoveredStableID")
private let talkAcmeProviderEntry = KeychainEntry(service: talkService, account: "provider.apiKey.acme")
private let bootstrapDefaultsKeys = [
"node.instanceId",
"gateway.preferredStableID",
@@ -185,4 +187,17 @@ private func withLastGatewaySnapshot(_ body: () -> Void) {
#expect(defaults.object(forKey: "gateway.last.host") == nil)
}
}
@Test func talkProviderApiKey_genericRoundTrip() {
let keychainSnapshot = snapshotKeychain([talkAcmeProviderEntry])
defer { restoreKeychain(keychainSnapshot) }
_ = KeychainStore.delete(service: talkService, account: talkAcmeProviderEntry.account)
GatewaySettingsStore.saveTalkProviderApiKey("acme-key", provider: "acme")
#expect(GatewaySettingsStore.loadTalkProviderApiKey(provider: "acme") == "acme-key")
GatewaySettingsStore.saveTalkProviderApiKey(nil, provider: "acme")
#expect(GatewaySettingsStore.loadTalkProviderApiKey(provider: "acme") == nil)
}
}

View File

@@ -56,6 +56,12 @@ import Testing
appModel: appModel,
defaults: defaults,
hasSavedGatewayConnection: false))
OnboardingStateStore.markIncomplete(defaults: defaults)
#expect(OnboardingStateStore.shouldPresentOnLaunch(
appModel: appModel,
defaults: defaults,
hasSavedGatewayConnection: false))
}
@Test func firstRunIntroDefaultsToVisibleThenPersists() {

View File

@@ -153,9 +153,13 @@ import Testing
let destinationsSource = try String(contentsOf: Self.agentProTabDestinationsSourceURL(), encoding: .utf8)
let nodesSource = try String(contentsOf: Self.agentProNodesDestinationSourceURL(), encoding: .utf8)
let dreamingSource = try String(contentsOf: Self.agentProDreamingDestinationSourceURL(), encoding: .utf8)
let directDestination = try Self.extract(
source,
from: "private func directDestination(for route: AgentRoute) -> some View",
to: "private func applyInitialRouteIfNeeded()")
#expect(!source.contains("ToolbarItem"))
#expect(source.contains("self.directHeaderLeadingAction(for: route) == nil ? .visible : .hidden"))
#expect(!directDestination.contains("ToolbarItem"))
#expect(directDestination.contains("self.directHeaderLeadingAction(for: route) == nil ? .visible : .hidden"))
#expect(destinationsSource.contains("self.directHeaderLeadingAction(for: .instances)"))
#expect(destinationsSource.contains("self.directHeaderLeadingAction(for: .dreaming)"))
#expect(destinationsSource.contains("self.directHeader(\n for: .usage"))
@@ -494,6 +498,7 @@ import Testing
let chatSource = try String(contentsOf: Self.chatProTabSourceURL(), encoding: .utf8)
let docsSource = try String(contentsOf: Self.docsSourceURL(), encoding: .utf8)
let settingsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
let channelsSource = try String(contentsOf: Self.channelsSourceURL(), encoding: .utf8)
#expect(rootSource.matches(of: /openSettings: \{ self\.selectSidebarDestination\(\.gateway\) \}/).count >= 2)
#expect(rootSource.matches(of: /gatewayAction: \{ self\.selectSidebarDestination\(\.gateway\) \}/).count == 1)
@@ -517,7 +522,9 @@ import Testing
#expect(rootSource.contains("SettingsProTab(initialRoute: self.selectedSidebarDestination.settingsRoute)"))
#expect(settingsSource.contains("title: \"Channels / Integrations\""))
#expect(settingsSource.contains("route: .channels"))
#expect(channelsSource.contains("let gatewayAction: (() -> Void)?"))
#expect(docsSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")"))
#expect(channelsSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")"))
}
@Test func gatewaySettingsKeepsPairingTrustDiagnosticsAndTailscaleActions() throws {

View File

@@ -29,4 +29,50 @@ import Testing
talkConfigLoaded: true,
notificationStatusText: "Allowed") == 0)
}
@Test func parseHostPortParsesIPv4() {
#expect(SettingsNetworkingHelpers.parseHostPort(from: "127.0.0.1:8080") == .init(host: "127.0.0.1", port: 8080))
}
@Test func parseHostPortParsesHostnameAndTrims() {
#expect(SettingsNetworkingHelpers.parseHostPort(from: " example.com:80 \n") == .init(
host: "example.com",
port: 80))
}
@Test func parseHostPortParsesBracketedIPv6() {
#expect(
SettingsNetworkingHelpers.parseHostPort(from: "[2001:db8::1]:443") ==
.init(host: "2001:db8::1", port: 443))
}
@Test func parseHostPortRejectsMissingPort() {
#expect(SettingsNetworkingHelpers.parseHostPort(from: "example.com") == nil)
#expect(SettingsNetworkingHelpers.parseHostPort(from: "[2001:db8::1]") == nil)
}
@Test func parseHostPortRejectsInvalidPort() {
#expect(SettingsNetworkingHelpers.parseHostPort(from: "example.com:lol") == nil)
#expect(SettingsNetworkingHelpers.parseHostPort(from: "[2001:db8::1]:lol") == nil)
}
@Test func httpURLStringFormatsIPv4AndPort() {
#expect(SettingsNetworkingHelpers
.httpURLString(host: "127.0.0.1", port: 8080, fallback: "fallback") == "http://127.0.0.1:8080")
}
@Test func httpURLStringBracketsIPv6() {
#expect(SettingsNetworkingHelpers
.httpURLString(host: "2001:db8::1", port: 8080, fallback: "fallback") == "http://[2001:db8::1]:8080")
}
@Test func httpURLStringLeavesAlreadyBracketedIPv6() {
#expect(SettingsNetworkingHelpers
.httpURLString(host: "[2001:db8::1]", port: 8080, fallback: "fallback") == "http://[2001:db8::1]:8080")
}
@Test func httpURLStringFallsBackWhenMissingHostOrPort() {
#expect(SettingsNetworkingHelpers.httpURLString(host: nil, port: 80, fallback: "x") == "http://x")
#expect(SettingsNetworkingHelpers.httpURLString(host: "example.com", port: nil, fallback: "y") == "http://y")
}
}

View File

@@ -103,6 +103,7 @@ import UIKit
AnyView(CommandCenterTab(openChat: {}, openSettings: {})),
AnyView(IPadActivityScreen(openChat: {}, openSettings: {})),
AnyView(OpenClawDocsScreen()),
AnyView(SettingsChannelsScreen()),
AnyView(IPadWorkboardScreen(openChat: {}, openSettings: {})),
AnyView(IPadSkillWorkshopScreen(openSettings: {})),
AnyView(AgentProTab(directRoute: .agents)),

View File

@@ -1,5 +1,5 @@
{
"originHash" : "4f7b315ce0e0a16d150d8d74dce445628c03d8926485ad2f5595e091b4d33440",
"originHash" : "035a4fe955164c62c1628de75f6437a14443a947eea2a1b0176ba484d6fde6f8",
"pins" : [
{
"identity" : "axorcist",
@@ -42,8 +42,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/Peekaboo.git",
"state" : {
"revision" : "1fa8eead7eeac3ff618a3111fc333ae78db043d2",
"version" : "3.5.2"
"revision" : "3a56ed2aa769bfefb5a78722dfce3c34088cfba1",
"version" : "3.4.0"
}
},
{
@@ -51,8 +51,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/sparkle-project/Sparkle",
"state" : {
"revision" : "d46d456107feacc80711b21847b82b07bd9fb46e",
"version" : "2.9.3"
"revision" : "6276ba2b404829d139c45ff98427cf90e2efc59b",
"version" : "2.9.2"
}
},
{
@@ -78,8 +78,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log.git",
"state" : {
"revision" : "92448c359f00ebe36ae97d3bd9086f13c7692b5a",
"version" : "1.13.2"
"revision" : "2aed77ae5ec9a86d8fe42c12275e4c2653a286ee",
"version" : "1.13.1"
}
},
{

View File

@@ -19,7 +19,7 @@ let package = Package(
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.4.0"),
.package(url: "https://github.com/apple/swift-log.git", from: "1.10.1"),
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.0"),
.package(url: "https://github.com/steipete/Peekaboo.git", exact: "3.5.2"),
.package(url: "https://github.com/steipete/Peekaboo.git", exact: "3.4.0"),
.package(path: "../shared/OpenClawKit"),
.package(path: "../swabble"),
],

View File

@@ -72,7 +72,7 @@ final class CronJobsStore {
do {
if let status = try? await GatewayConnection.shared.cronStatus() {
self.schedulerEnabled = status.enabled
self.schedulerStorePath = status.sqlitePath ?? status.storePath
self.schedulerStorePath = status.storePath
self.schedulerNextWakeAtMs = status.nextWakeAtMs
}
self.jobs = try await GatewayConnection.shared.cronList(includeDisabled: true)

View File

@@ -1,5 +1,4 @@
import CryptoKit
import Darwin
import Foundation
import OSLog
import Security
@@ -230,12 +229,6 @@ enum ExecApprovalsStore {
private static let secureStateDirPermissions = 0o700
private static let fileLock = NSRecursiveLock()
private enum LegacyMigrationResult {
case notNeeded
case migrated
case blocked
}
private static func withFileLock<T>(_ body: () throws -> T) rethrows -> T {
self.fileLock.lock()
defer { self.fileLock.unlock() }
@@ -250,195 +243,6 @@ enum ExecApprovalsStore {
OpenClawPaths.stateDirURL.appendingPathComponent("exec-approvals.sock").path
}
private static func legacyStateDirURLs() -> [URL] {
if let home = OpenClawEnv.path("OPENCLAW_HOME") {
var urls = [
URL(fileURLWithPath: home, isDirectory: true)
.appendingPathComponent(".openclaw", isDirectory: true),
]
let osHomeURL = FileManager().homeDirectoryForCurrentUser
.appendingPathComponent(".openclaw", isDirectory: true)
if !urls.contains(where: {
$0.standardizedFileURL.path == osHomeURL.standardizedFileURL.path
}) {
urls.append(osHomeURL)
}
return urls
}
return [
FileManager().homeDirectoryForCurrentUser
.appendingPathComponent(".openclaw", isDirectory: true),
]
}
private static func legacyFileURLIfPending() -> URL? {
guard OpenClawEnv.path("OPENCLAW_STATE_DIR") != nil else { return nil }
let targetURL = self.fileURL()
for stateDirURL in self.legacyStateDirURLs() {
let legacyURL = stateDirURL
.appendingPathComponent("exec-approvals.json", isDirectory: false)
guard legacyURL.standardizedFileURL.path != targetURL.standardizedFileURL.path else {
continue
}
guard FileManager().fileExists(atPath: legacyURL.path) else { continue }
guard !FileManager().fileExists(atPath: targetURL.path) else { return nil }
return legacyURL
}
return nil
}
private static func unmigratedLegacyFallbackFile() -> ExecApprovalsFile {
ExecApprovalsFile(
version: 1,
socket: nil,
defaults: ExecApprovalsDefaults(
security: .deny,
ask: .always,
askFallback: .deny,
autoAllowSkills: nil),
agents: [:])
}
private static func isLegacyDefaultSocketPath(_ raw: String, legacyFileURL: URL) -> Bool {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return true }
let expanded = self.expandPath(trimmed)
let legacySocket = legacyFileURL.deletingLastPathComponent()
.appendingPathComponent("exec-approvals.sock", isDirectory: false)
.path
return URL(fileURLWithPath: expanded).standardizedFileURL.path
== URL(fileURLWithPath: legacySocket).standardizedFileURL.path
}
private static func hasSymlinkParent(_ url: URL) -> Bool {
var cursor = url.deletingLastPathComponent()
let manager = FileManager()
while true {
var isDirectory = ObjCBool(false)
if manager.fileExists(atPath: cursor.path, isDirectory: &isDirectory) {
if (try? manager.destinationOfSymbolicLink(atPath: cursor.path)) != nil {
return true
}
}
let parent = cursor.deletingLastPathComponent()
if parent.path == cursor.path { return false }
cursor = parent
}
}
private static func archiveMigratedLegacyFile(_ legacyURL: URL) throws -> URL {
let manager = FileManager()
var archiveURL = URL(fileURLWithPath: "\(legacyURL.path).migrated")
if manager.fileExists(atPath: archiveURL.path) {
archiveURL = URL(fileURLWithPath: "\(archiveURL.path)-\(UUID().uuidString)")
}
try manager.moveItem(at: legacyURL, to: archiveURL)
return archiveURL
}
private static func writeMigratedFileExclusively(_ data: Data, to targetURL: URL) throws -> Bool {
let tempURL = targetURL.deletingLastPathComponent()
.appendingPathComponent(".exec-approvals.migration.\(UUID().uuidString)")
let fd = open(tempURL.path, O_WRONLY | O_CREAT | O_EXCL, S_IRUSR | S_IWUSR)
if fd == -1 {
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
}
var closed = false
defer {
if !closed { close(fd) }
}
do {
try data.withUnsafeBytes { rawBuffer in
guard let base = rawBuffer.baseAddress else { return }
var offset = 0
while offset < rawBuffer.count {
let written = Darwin.write(
fd,
base.advanced(by: offset),
rawBuffer.count - offset)
if written < 0 {
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
}
offset += written
}
}
close(fd)
closed = true
let copied = copyfile(
tempURL.path,
targetURL.path,
nil,
copyfile_flags_t(COPYFILE_EXCL))
if copied == -1 {
if errno == EEXIST {
try? FileManager().removeItem(at: tempURL)
return false
}
try? FileManager().removeItem(at: targetURL)
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
}
try? FileManager().removeItem(at: tempURL)
return true
} catch {
try? FileManager().removeItem(at: tempURL)
throw error
}
}
private static func migrateLegacyFileIfNeeded() -> LegacyMigrationResult {
guard let legacyURL = self.legacyFileURLIfPending() else { return .notNeeded }
let targetURL = self.fileURL()
do {
if self.hasSymlinkParent(targetURL) {
throw NSError(domain: "ExecApprovals", code: 10, userInfo: [
NSLocalizedDescriptionKey: "target path has a symlink parent",
])
}
let data = try Data(contentsOf: legacyURL)
var file = try JSONDecoder().decode(ExecApprovalsFile.self, from: data)
guard file.version == 1 else {
throw NSError(domain: "ExecApprovals", code: 11, userInfo: [
NSLocalizedDescriptionKey: "unsupported legacy approvals version",
])
}
file = self.normalizeIncoming(file)
let rawSocketPath = file.socket?.path?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if self.isLegacyDefaultSocketPath(rawSocketPath, legacyFileURL: legacyURL) {
if file.socket == nil {
file.socket = ExecApprovalsSocketConfig(path: nil, token: nil)
}
file.socket?.path = self.socketPath()
}
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let migrated = try encoder.encode(file)
self.ensureSecureStateDirectory()
try FileManager().createDirectory(
at: targetURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
if FileManager().fileExists(atPath: targetURL.path) { return .notNeeded }
let created = try self.writeMigratedFileExclusively(migrated, to: targetURL)
if !created { return .notNeeded }
try? FileManager().setAttributes(
[.posixPermissions: 0o600],
ofItemAtPath: targetURL.path)
do {
_ = try self.archiveMigratedLegacyFile(legacyURL)
} catch {
self.logger
.warning(
"exec approvals legacy archive failed: \(error.localizedDescription, privacy: .public)")
}
return .migrated
} catch {
self.logger
.error(
"exec approvals legacy migration failed: \(error.localizedDescription, privacy: .public)")
return .blocked
}
}
static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
@@ -474,14 +278,6 @@ enum ExecApprovalsStore {
static func readSnapshot() -> ExecApprovalsSnapshot {
self.withFileLock {
if self.legacyFileURLIfPending() != nil {
let file = self.unmigratedLegacyFallbackFile()
return ExecApprovalsSnapshot(
path: self.fileURL().path,
exists: false,
hash: self.hashRaw(nil),
file: file)
}
let url = self.fileURL()
guard FileManager().fileExists(atPath: url.path) else {
return ExecApprovalsSnapshot(
@@ -526,14 +322,6 @@ enum ExecApprovalsStore {
static func loadFile() -> ExecApprovalsFile {
self.withFileLock {
if self.legacyFileURLIfPending() != nil {
switch self.migrateLegacyFileIfNeeded() {
case .migrated, .notNeeded:
break
case .blocked:
return self.unmigratedLegacyFallbackFile()
}
}
let url = self.fileURL()
guard FileManager().fileExists(atPath: url.path) else {
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
@@ -573,14 +361,6 @@ enum ExecApprovalsStore {
static func ensureFile() -> ExecApprovalsFile {
self.withFileLock {
if self.legacyFileURLIfPending() != nil {
switch self.migrateLegacyFileIfNeeded() {
case .migrated, .notNeeded:
break
case .blocked:
return self.unmigratedLegacyFallbackFile()
}
}
self.ensureSecureStateDirectory()
let url = self.fileURL()
let existed = FileManager().fileExists(atPath: url.path)

View File

@@ -775,7 +775,6 @@ extension GatewayConnection {
struct CronSchedulerStatus: Decodable {
let enabled: Bool
let storePath: String
let sqlitePath: String?
let jobs: Int
let nextWakeAtMs: Int?
}

View File

@@ -92,13 +92,7 @@ extension VoiceWakeOverlayController {
let contentHeight = ceil(used.height + (textInset.height * 2))
let total = contentHeight + self.verticalPadding * 2
// Defer the overflow state mutation to break the SwiftUI onChange measuredHeight
// isOverflowing re-render onChange synchronous render loop (fixes #43480).
let overflowing = total > self.maxHeight
DispatchQueue.main.async { [weak self] in
guard let self, self.model.isOverflowing != overflowing else { return }
self.model.isOverflowing = overflowing
}
self.model.isOverflowing = total > self.maxHeight
return max(self.minHeight, min(total, self.maxHeight))
}

View File

@@ -4,85 +4,18 @@ import Testing
@Suite(.serialized)
struct ExecApprovalsStoreRefactorTests {
private var realTemporaryDirectory: URL {
let path = FileManager().temporaryDirectory.path
if path.hasPrefix("/var/") {
return URL(fileURLWithPath: "/private\(path)", isDirectory: true)
}
return FileManager().temporaryDirectory.resolvingSymlinksInPath()
}
private func withLockedEnv(
_ values: [String: String?],
_ body: () async throws -> Void) async throws
{
func restoreEnv(_ values: [String: String?]) {
for (key, value) in values {
if let value {
setenv(key, value, 1)
} else {
unsetenv(key)
}
}
}
await TestIsolationLock.shared.acquire()
var previousEnv: [String: String?] = [:]
for (key, value) in values {
previousEnv[key] = getenv(key).map { String(cString: $0) }
if let value {
setenv(key, value, 1)
} else {
unsetenv(key)
}
}
do {
try await body()
restoreEnv(previousEnv)
await TestIsolationLock.shared.release()
} catch {
restoreEnv(previousEnv)
await TestIsolationLock.shared.release()
throw error
}
}
private func withTempStateDir(
_ body: @escaping @Sendable (URL) async throws -> Void) async throws
{
let root = self.realTemporaryDirectory
let stateDir = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
let home = root.appendingPathComponent("home", isDirectory: true)
let stateDir = root.appendingPathComponent("state", isDirectory: true)
defer { try? FileManager().removeItem(at: root) }
try Self.seedCurrentApprovalsFile(in: stateDir)
defer { try? FileManager().removeItem(at: stateDir) }
try await self.withLockedEnv([
"OPENCLAW_HOME": home.path,
"OPENCLAW_STATE_DIR": stateDir.path,
]) {
try await TestIsolation.withEnvValues(["OPENCLAW_STATE_DIR": stateDir.path]) {
try await body(stateDir)
}
}
private func withTempHomeAndStateDir(
_ body: @escaping @Sendable (URL, URL) async throws -> Void) async throws
{
let root = self.realTemporaryDirectory
.appendingPathComponent("openclaw-home-state-\(UUID().uuidString)", isDirectory: true)
let home = root.appendingPathComponent("home", isDirectory: true)
let stateDir = root.appendingPathComponent("state", isDirectory: true)
defer { try? FileManager().removeItem(at: root) }
try await self.withLockedEnv([
"OPENCLAW_HOME": home.path,
"OPENCLAW_STATE_DIR": stateDir.path,
]) {
try await body(home, stateDir)
}
}
@Test
func `ensure file skips rewrite when unchanged`() async throws {
try await self.withTempStateDir { _ in
@@ -97,50 +30,6 @@ struct ExecApprovalsStoreRefactorTests {
}
}
@Test
func `ensure file migrates default approvals into custom state dir`() async throws {
try await self.withTempHomeAndStateDir { home, stateDir in
let legacyDir = home.appendingPathComponent(".openclaw", isDirectory: true)
try FileManager().createDirectory(
at: legacyDir,
withIntermediateDirectories: true)
let legacySocket = legacyDir.appendingPathComponent("exec-approvals.sock").path
let legacyFile = legacyDir.appendingPathComponent("exec-approvals.json")
let legacyJson = """
{
"version": 1,
"socket": {
"path": "\(legacySocket)",
"token": "legacy-token"
},
"defaults": {
"security": "deny",
"ask": "always"
},
"agents": {
"main": {
"allowlist": [{ "pattern": "git status" }]
}
}
}
"""
try Data(legacyJson.utf8).write(to: legacyFile)
let file = ExecApprovalsStore.ensureFile()
let targetURL = ExecApprovalsStore.fileURL()
#expect(targetURL.path == stateDir.appendingPathComponent("exec-approvals.json").path)
#expect(FileManager().fileExists(atPath: targetURL.path))
#expect(file.socket?.path == stateDir.appendingPathComponent("exec-approvals.sock").path)
#expect(file.socket?.token == "legacy-token")
#expect(file.defaults?.security == .deny)
#expect(file.defaults?.ask == .always)
#expect(file.agents?["main"]?.allowlist?.map(\.pattern) == ["git status"])
#expect(!FileManager().fileExists(atPath: legacyFile.path))
#expect(FileManager().fileExists(atPath: "\(legacyFile.path).migrated"))
}
}
@Test
func `update allowlist accepts basename pattern`() async throws {
try await self.withTempStateDir { _ in
@@ -197,19 +86,4 @@ struct ExecApprovalsStoreRefactorTests {
}
return identifier
}
private static func seedCurrentApprovalsFile(in stateDir: URL) throws {
try FileManager().createDirectory(at: stateDir, withIntermediateDirectories: true)
let file = ExecApprovalsFile(
version: 1,
socket: ExecApprovalsSocketConfig(
path: stateDir.appendingPathComponent("exec-approvals.sock").path,
token: "test-token"),
defaults: nil,
agents: [:])
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
try encoder.encode(file)
.write(to: stateDir.appendingPathComponent("exec-approvals.json"))
}
}

View File

@@ -46,17 +46,6 @@ public enum NodePresenceAliveReason: String, Codable, Sendable {
case connect = "connect"
}
public enum SessionFileKind: String, Codable, Sendable {
case modified = "modified"
case read = "read"
}
public enum SessionFileRelevance: String, Codable, Sendable {
case modified = "modified"
case read = "read"
case mixed = "mixed"
}
public struct ConnectParams: Codable, Sendable {
public let minprotocol: Int
public let maxprotocol: Int
@@ -1767,7 +1756,6 @@ public struct SessionsResolveParams: Codable, Sendable {
public let spawnedby: String?
public let includeglobal: Bool?
public let includeunknown: Bool?
public let allowmissing: Bool?
public init(
key: String?,
@@ -1776,8 +1764,7 @@ public struct SessionsResolveParams: Codable, Sendable {
agentid: String? = nil,
spawnedby: String?,
includeglobal: Bool?,
includeunknown: Bool?,
allowmissing: Bool? = nil)
includeunknown: Bool?)
{
self.key = key
self.sessionid = sessionid
@@ -1786,7 +1773,6 @@ public struct SessionsResolveParams: Codable, Sendable {
self.spawnedby = spawnedby
self.includeglobal = includeglobal
self.includeunknown = includeunknown
self.allowmissing = allowmissing
}
private enum CodingKeys: String, CodingKey {
@@ -1797,7 +1783,6 @@ public struct SessionsResolveParams: Codable, Sendable {
case spawnedby = "spawnedBy"
case includeglobal = "includeGlobal"
case includeunknown = "includeUnknown"
case allowmissing = "allowMissing"
}
}
@@ -2089,204 +2074,6 @@ public struct SessionsCompactionRestoreResult: Codable, Sendable {
}
}
public struct SessionFileBrowserEntry: Codable, Sendable {
public let path: String
public let name: String
public let kind: AnyCodable
public let sessionkind: SessionFileRelevance?
public let size: Int?
public let updatedatms: Int?
public init(
path: String,
name: String,
kind: AnyCodable,
sessionkind: SessionFileRelevance?,
size: Int?,
updatedatms: Int?)
{
self.path = path
self.name = name
self.kind = kind
self.sessionkind = sessionkind
self.size = size
self.updatedatms = updatedatms
}
private enum CodingKeys: String, CodingKey {
case path
case name
case kind
case sessionkind = "sessionKind"
case size
case updatedatms = "updatedAtMs"
}
}
public struct SessionFileBrowserResult: Codable, Sendable {
public let path: String
public let parentpath: String?
public let search: String?
public let entries: [SessionFileBrowserEntry]
public let truncated: Bool?
public init(
path: String,
parentpath: String?,
search: String?,
entries: [SessionFileBrowserEntry],
truncated: Bool?)
{
self.path = path
self.parentpath = parentpath
self.search = search
self.entries = entries
self.truncated = truncated
}
private enum CodingKeys: String, CodingKey {
case path
case parentpath = "parentPath"
case search
case entries
case truncated
}
}
public struct SessionFileEntry: Codable, Sendable {
public let path: String
public let name: String
public let kind: SessionFileKind
public let missing: Bool
public let size: Int?
public let updatedatms: Int?
public let content: String?
public init(
path: String,
name: String,
kind: SessionFileKind,
missing: Bool,
size: Int?,
updatedatms: Int?,
content: String?)
{
self.path = path
self.name = name
self.kind = kind
self.missing = missing
self.size = size
self.updatedatms = updatedatms
self.content = content
}
private enum CodingKeys: String, CodingKey {
case path
case name
case kind
case missing
case size
case updatedatms = "updatedAtMs"
case content
}
}
public struct SessionsFilesListParams: Codable, Sendable {
public let sessionkey: String
public let agentid: String?
public let path: String?
public let search: String?
public init(
sessionkey: String,
agentid: String? = nil,
path: String?,
search: String?)
{
self.sessionkey = sessionkey
self.agentid = agentid
self.path = path
self.search = search
}
private enum CodingKeys: String, CodingKey {
case sessionkey = "sessionKey"
case agentid = "agentId"
case path
case search
}
}
public struct SessionsFilesListResult: Codable, Sendable {
public let sessionkey: String
public let root: String?
public let files: [SessionFileEntry]
public let browser: SessionFileBrowserResult?
public init(
sessionkey: String,
root: String?,
files: [SessionFileEntry],
browser: SessionFileBrowserResult?)
{
self.sessionkey = sessionkey
self.root = root
self.files = files
self.browser = browser
}
private enum CodingKeys: String, CodingKey {
case sessionkey = "sessionKey"
case root
case files
case browser
}
}
public struct SessionsFilesGetParams: Codable, Sendable {
public let sessionkey: String
public let path: String
public let agentid: String?
public init(
sessionkey: String,
path: String,
agentid: String? = nil)
{
self.sessionkey = sessionkey
self.path = path
self.agentid = agentid
}
private enum CodingKeys: String, CodingKey {
case sessionkey = "sessionKey"
case path
case agentid = "agentId"
}
}
public struct SessionsFilesGetResult: Codable, Sendable {
public let sessionkey: String
public let root: String?
public let file: SessionFileEntry
public init(
sessionkey: String,
root: String?,
file: SessionFileEntry)
{
self.sessionkey = sessionkey
self.root = root
self.file = file
}
private enum CodingKeys: String, CodingKey {
case sessionkey = "sessionKey"
case root
case file
}
}
public struct SessionsCreateParams: Codable, Sendable {
public let key: String?
public let agentid: String?

View File

@@ -1,4 +1,4 @@
0485ba902d2afd89d2c41cde7180d0cec2900b2db6804b9f97d42b7d85cd3af5 config-baseline.json
72bb80be618406f3337eaa2560d2559a35e49bd29576de8dd4a3aec1a6a94d92 config-baseline.core.json
1218f5555541b61bd5ddcac6441f15061b44789e2471d4ffecbe3059777c55c1 config-baseline.channel.json
a14ac4261e98403d1a7e047070e6f151938444e27382b860315bd0c74fda4861 config-baseline.plugin.json
37b56008790612b8293930b6a29d74490e98daa90f954fca9d133fcc28645c4c config-baseline.json
75b64c2ea081369ba4306493313a8a4cd48b784145f92fed995e6b77a5df350d config-baseline.core.json
17d64c9799dfa239a49493413f1100bdd9237e9b67aaeae331a4604dbc227023 config-baseline.channel.json
f9d1f50bfa8403891e76cd99dc1357cdece4a71e8ae18a39b190c2a14e6f97b0 config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
303312830e2d7275bfe5abcdbdb3b47fd8648067a7b51ca043503a78bb18d275 plugin-sdk-api-baseline.json
71e94e1de9f1b03aa44da55ec63d16146ab279740c44854d5998bc0f04d6ae0d plugin-sdk-api-baseline.jsonl
8a2769df428906990ee0d1bf8b0423f2a099b053c64c816d092ff84d61e11633 plugin-sdk-api-baseline.json
28b798973f3fb2a5b33ccbb6e3c1ac0453fa234a3a1c6cdc27935c27639bd104 plugin-sdk-api-baseline.jsonl

View File

@@ -157,9 +157,6 @@ If stdout is non-empty, that text is the delivered result. If stdout is empty an
<ParamField path="--model" type="string">
Model override; uses the selected allowed model for the job.
</ParamField>
<ParamField path="--clear-model" type="boolean">
On `cron edit`, removes the per-job model override so the job follows normal cron model-selection precedence (a stored cron-session override if set, otherwise the agent/default model). Cannot be combined with `--model`.
</ParamField>
<ParamField path="--thinking" type="string">
Thinking level override.
</ParamField>
@@ -474,7 +471,6 @@ Model override note:
- If the model is allowed, that exact provider/model reaches the isolated agent run.
- If it is not allowed or cannot be resolved, cron fails the run with an explicit validation error.
- API `cron.update` payload patches can set `model: null` to clear a stored job model override.
- `openclaw cron edit <job-id> --clear-model` clears that override from the CLI (same effect as the `model: null` patch) and cannot be combined with `--model`.
- Configured fallback chains still apply because cron `--model` is a job primary, not a session `/model` override.
- Payload `fallbacks` replaces configured fallbacks for that job; `fallbacks: []` disables fallback and makes the run strict.
- A plain `--model` with no explicit or configured fallback list does not fall through to the agent primary as a silent extra retry target.

View File

@@ -311,9 +311,7 @@ $OPENCLAW_STATE_DIR/tasks/runs.sqlite
The registry loads into memory at gateway start and syncs writes to SQLite for durability across restarts.
The Gateway keeps the SQLite write-ahead log bounded by using SQLite's default
autocheckpoint threshold plus periodic `PASSIVE` checkpoints. Shutdown and
explicit maintenance checkpoints still use `TRUNCATE` so normal closes can
reclaim WAL space without making the background sweeper wait on active readers.
autocheckpoint threshold plus periodic and shutdown `TRUNCATE` checkpoints.
### Automatic maintenance
@@ -360,7 +358,7 @@ A sweeper runs every **60 seconds** and handles four things:
</Accordion>
<Accordion title="Tasks and sessions">
A task may reference a `childSessionKey` (where work runs) and a `requesterSessionKey` (who started it). Its `agentId` identifies the agent executing the work, while the requester and owner fields preserve launch and control context. Sessions are conversation context; tasks are activity tracking on top of that.
A task may reference a `childSessionKey` (where work runs) and a `requesterSessionKey` (who started it). Sessions are conversation context; tasks are activity tracking on top of that.
</Accordion>
<Accordion title="Tasks and agent runs">
A task's `runId` links to the agent run doing the work. Agent lifecycle events (start, end, error) automatically update the task status - you do not need to manage the lifecycle manually.

View File

@@ -161,20 +161,17 @@ Control how agents process messages:
<Step title="Incoming message arrives">
A WhatsApp group or DM message arrives.
</Step>
<Step title="Route and admission">
OpenClaw applies channel allowlists, group activation rules, and configured ACP binding ownership.
</Step>
<Step title="Broadcast check">
If no configured ACP binding owns the route, OpenClaw checks whether the peer ID is in `broadcast`.
System checks if peer ID is in `broadcast`.
</Step>
<Step title="If broadcast applies">
<Step title="If in broadcast list">
- All listed agents process the message.
- Each agent has its own session key and isolated context.
- Agents process in parallel (default) or sequentially.
</Step>
<Step title="If broadcast does not apply">
OpenClaw dispatches the ordinary route or the configured ACP session route selected during routing.
<Step title="If not in broadcast list">
Normal routing applies (first matching binding).
</Step>
</Steps>
@@ -325,7 +322,7 @@ Broadcast groups work alongside existing routing:
- `GROUP_B`: agent1 AND agent2 respond (broadcast).
<Note>
**Precedence:** `broadcast` takes priority over ordinary route bindings. Configured ACP bindings (`bindings[].type="acp"`) are exclusive: when one matches, OpenClaw dispatches to the configured ACP session instead of fan-out broadcast.
**Precedence:** `broadcast` takes priority over `bindings`.
</Note>
## Troubleshooting
@@ -346,9 +343,9 @@ Broadcast groups work alongside existing routing:
</Accordion>
<Accordion title="Only one agent responding">
**Cause:** Peer ID might be in ordinary route bindings but not `broadcast`, or it might match an exclusive configured ACP binding.
**Cause:** Peer ID might be in `bindings` but not `broadcast`.
**Fix:** Add ordinary route-bound peers to broadcast config, or remove/change the configured ACP binding if fan-out broadcast is desired.
**Fix:** Add to broadcast config or remove from bindings.
</Accordion>
<Accordion title="Performance issues">

View File

@@ -59,14 +59,6 @@ export CLICKCLACK_BOT_TOKEN="ccb_..."
openclaw gateway
```
If `plugins.allow` is a non-empty restrictive list, explicitly selecting
ClickClack in channel setup or running `openclaw plugins enable clickclack`
appends `clickclack` to that list. Onboarding installation uses the same
explicit-selection behavior. These paths do not override `plugins.deny` or a
global `plugins.enabled: false` setting. Direct `openclaw plugins install
clickclack` follows the normal plugin-install policy and also records ClickClack
in an existing allowlist.
## Multiple bots
Each account opens its own ClickClack realtime connection and uses its own bot token.

View File

@@ -416,9 +416,7 @@ Enable `dynamicAgentCreation` to automatically create **isolated agent instances
This is essential for public bots where you want each user to have their own private AI assistant experience.
<Note>
Dynamic bindings include the normalized Feishu `accountId`, so default and named accounts route each sender to the correct dynamic agent.
If a named account created an unscoped dynamic agent on an older release, that legacy agent still counts toward `maxAgents`. Confirm that it is not used by the default account before removing it, or temporarily increase `maxAgents`; OpenClaw cannot safely infer which account owns ambiguous legacy state.
**Account limitation**: `dynamicAgentCreation` currently works with the **default Feishu account only**. Named/multi-account setups are not yet fully supported — dynamic bindings are created without `accountId`, so messages to named accounts may still route to `agent:main`. Track progress in [Issue #42837](https://github.com/openclaw/openclaw/issues/42837).
</Note>
### Quick setup
@@ -449,7 +447,7 @@ If a named account created an unscoped dynamic agent on an older release, that l
When a new user sends their first DM:
1. The channel generates a unique `agentId`: `feishu-{user_open_id}` for the default account, or a bounded account-prefixed identity digest for a named account
1. The channel generates a unique `agentId` = `feishu-{user_open_id}`
2. Creates a new workspace at `workspaceTemplate` path
3. Registers the agent and creates a binding for this user
4. The workspace helper ensures bootstrap files (`AGENTS.md`, `SOUL.md`, `USER.md`, etc.) on first access
@@ -466,23 +464,22 @@ When a new user sends their first DM:
Template variables:
- `{agentId}` - the generated agent ID (e.g., `feishu-ou_xxxxxx` or `feishu-support-<identity_digest>`)
- `{agentId}` - the generated agent ID (e.g., `feishu-ou_xxxxxx`)
- `{userId}` - the sender's Feishu open_id (e.g., `ou_xxxxxx`)
### Session scope
`session.dmScope` controls how direct messages are mapped to agent sessions. This is a **global setting** that affects all channels.
| Value | Behavior | Best for |
| ---------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------ |
| `"main"` | Each user's DM maps to their agent's main session | Single-user bots where you want `USER.md` / `SOUL.md` to auto-load |
| `"per-channel-peer"` | Each (channel + user) combination gets a separate session | Public multi-user bots needing stronger isolation |
| `"per-account-channel-peer"` | Each (account + channel + user) combination gets a separate session | Multi-account bots needing account-level session isolation |
| Value | Behavior | Best for |
| -------------------- | --------------------------------------------------------- | ------------------------------------------------------------------ |
| `"main"` | Each user's DM maps to their agent's main session | Single-user bots where you want `USER.md` / `SOUL.md` to auto-load |
| `"per-channel-peer"` | Each (channel + user) combination gets a separate session | Public multi-user bots needing stronger isolation |
**Tradeoff**: Using `"main"` enables automatic bootstrap file loading (`USER.md`, `SOUL.md`, `MEMORY.md`), but means all DMs across all channels share the same session key pattern. For public multi-user bots where isolation matters more than bootstrap auto-loading, consider `"per-channel-peer"` and manage bootstrap files manually.
<Note>
Use `"per-account-channel-peer"` when named Feishu accounts should keep separate sessions for the same sender. Dynamic bindings preserve the account scope.
`"per-account-channel-peer"` is not recommended with `dynamicAgentCreation` because dynamic bindings are created without `accountId`. Use it only with manual bindings.
</Note>
```json5

View File

@@ -586,7 +586,7 @@ Group inbound payloads set:
- `WasMentioned` (mention gating result)
- Telegram forum topics also include `MessageThreadId` and `IsForum`.
The agent system prompt includes a group intro on the first turn of a new group session. It reminds the model to respond like a human, minimize empty lines and follow normal chat spacing, and avoid typing literal `\n` sequences. Non-Telegram groups also discourage Markdown tables; Telegram rich-text guidance comes from the Telegram channel prompt. Channel-sourced group names and participant labels are rendered as fenced untrusted metadata, not inline system instructions.
The agent system prompt includes a group intro on the first turn of a new group session. It reminds the model to respond like a human, avoid Markdown tables, minimize empty lines and follow normal chat spacing, and avoid typing literal `\n` sequences. Channel-sourced group names and participant labels are rendered as fenced untrusted metadata, not inline system instructions.
## iMessage specifics

View File

@@ -311,6 +311,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
- direct chats: preview message + `editMessageText`
- groups/topics: preview message + `editMessageText`
- direct-chat tool progress: optional native `sendMessageDraft` status preview when enabled and supported
Requirement:
@@ -319,10 +320,29 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
- `streaming.preview.toolProgress` controls whether tool/progress updates reuse the same edited preview message (default: `true` when preview streaming is active)
- `streaming.preview.commandText` controls command/exec detail inside those tool-progress lines: `raw` (default, preserves released behavior) or `status` (tool label only)
- `streaming.progress.commentary` (default: `false`) opts into assistant commentary/preamble text in the temporary progress draft
- legacy `channels.telegram.streamMode`, boolean `streaming` values, and retired native draft preview keys are detected; run `openclaw doctor --fix` to migrate them to current streaming config
- legacy `channels.telegram.streamMode` and boolean `streaming` values are detected; run `openclaw doctor --fix` to migrate them to `channels.telegram.streaming.mode`
Tool-progress preview updates are the short status lines shown while tools run, for example command execution, file reads, planning updates, patch summaries, or Codex preamble/commentary text in Codex app-server mode. Telegram keeps these enabled by default to match released OpenClaw behavior from `v2026.4.22` and later.
Direct chats can use native Telegram drafts for these tool-progress lines without persisting tool chatter into chat history. Native drafts stop before answer text starts; final answers stay on the normal persistent delivery path. This lane is off by default and should be gated to trusted DM IDs first:
```json
{
"channels": {
"telegram": {
"streaming": {
"mode": "partial",
"preview": {
"toolProgress": true,
"nativeToolProgress": true,
"nativeToolProgressAllowFrom": ["123456789"]
}
}
}
}
}
```
To keep the edited preview for answer text but hide tool-progress lines, set:
```json
@@ -400,16 +420,14 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
</Accordion>
<Accordion title="Rich message formatting">
Outbound text uses Telegram rich messages.
<Accordion title="Formatting and HTML fallback">
Outbound text uses Telegram `parse_mode: "HTML"`.
- Markdown text is rendered through OpenClaw's Markdown IR and sent as Telegram rich HTML.
- Explicit rich HTML payloads preserve supported Bot API 10.1 tags such as headings, tables, details, rich media, and formulas.
- Media captions still use Telegram HTML captions because rich messages do not replace captions.
- Markdown-ish text is rendered to Telegram-safe HTML.
- Supported Telegram HTML tags are preserved; unsupported HTML is escaped.
- If Telegram rejects parsed HTML, OpenClaw retries as plain text.
This keeps model text away from Telegram Rich Markdown sigils, so currency like `$400-600K` is not parsed as math. Long rich text is split automatically across Telegram's rich text and rich block limits. Tables over Telegram's column limit are sent as code blocks.
Link previews are enabled by default. `channels.telegram.linkPreview: false` skips automatic entity detection for rich text.
Link previews are enabled by default and can be disabled with `channels.telegram.linkPreview: false`.
</Accordion>

View File

@@ -164,7 +164,7 @@ handoff path over manual terminal capture.
- Gateway owns the WhatsApp socket and reconnect loop.
- The reconnect watchdog uses WhatsApp Web transport activity, not only inbound app-message volume, so a quiet linked-device session is not restarted solely because nobody has sent a message recently. A longer application-silence cap still forces a reconnect if transport frames keep arriving but no application messages are handled for the watchdog window; after a transient reconnect for a recently active session, that application-silence check uses the normal message timeout for the first recovery window.
- Baileys socket timings are explicit under `web.whatsapp.*`: `keepAliveIntervalMs` controls WhatsApp Web application pings, `connectTimeoutMs` controls the opening handshake timeout, and `defaultQueryTimeoutMs` controls Baileys query waits plus OpenClaw's local outbound send/presence operation bound.
- Baileys socket timings are explicit under `web.whatsapp.*`: `keepAliveIntervalMs` controls WhatsApp Web application pings, `connectTimeoutMs` controls the opening handshake timeout, and `defaultQueryTimeoutMs` controls Baileys query timeouts.
- Outbound sends require an active WhatsApp listener for the target account.
- Group sends attach native mention metadata for `@+<digits>` and `@<digits>` tokens in text and media captions when the token matches current WhatsApp participant metadata, including LID-backed groups.
- Status and broadcast chats are ignored (`@status`, `@broadcast`).
@@ -319,40 +319,6 @@ content and identifiers.
</Tab>
</Tabs>
## Configured ACP bindings
WhatsApp supports persistent ACP bindings with top-level `bindings[]` entries:
```json5
{
bindings: [
{
type: "acp",
agentId: "codex",
match: {
channel: "whatsapp",
accountId: "work",
peer: { kind: "direct", id: "+15555550123" },
},
},
{
type: "acp",
agentId: "codex",
match: {
channel: "whatsapp",
accountId: "work",
peer: { kind: "group", id: "120363424282127706@g.us" },
},
},
],
}
```
- Direct chats match E.164 numbers such as `+15555550123`.
- Groups match WhatsApp group JIDs such as `120363424282127706@g.us`.
- Group allowlists, sender policy, and mention or activation gating run before OpenClaw ensures the configured ACP session exists.
- A matched configured ACP binding owns the route. WhatsApp broadcast groups do not fan out that turn to ordinary WhatsApp sessions.
## Personal-number and self-chat behavior
When the linked self number is also present in `allowFrom`, WhatsApp self-chat safeguards activate:

View File

@@ -183,7 +183,7 @@ The workflow installs OCM from a pinned release and Kova from `openclaw/Kova` at
- `mock-deep-profile`: CPU/heap/trace profiling for startup, gateway, and agent-turn hotspots.
- `live-openai-candidate`: a real OpenAI `openai/gpt-5.5` agent turn, skipped when `OPENAI_API_KEY` is unavailable.
The mock-provider lane also runs OpenClaw-native source probes after the Kova pass: gateway boot timing and memory across default, hook, and 50-plugin startup cases; bundled plugin import RSS, repeated mock-OpenAI `channel-chat-baseline` hello loops, CLI startup commands against the booted gateway, and the SQLite state smoke performance probe. When the previous published mock-provider source report is available for the tested ref, the source summary compares current RSS and heap values against that baseline and marks large RSS increases as `watch`. The source probe Markdown summary lives at `source/index.md` in the report bundle, with raw JSON beside it.
The mock-provider lane also runs OpenClaw-native source probes after the Kova pass: gateway boot timing and memory across default, hook, and 50-plugin startup cases; bundled plugin import RSS, repeated mock-OpenAI `channel-chat-baseline` hello loops, and CLI startup commands against the booted gateway. When the previous published mock-provider source report is available for the tested ref, the source summary compares current RSS and heap values against that baseline and marks large RSS increases as `watch`. The source probe Markdown summary lives at `source/index.md` in the report bundle, with raw JSON beside it.
Every lane uploads GitHub artifacts. When `CLAWGRIT_REPORTS_TOKEN` is configured, the workflow also commits `report.json`, `report.md`, bundles, `index.md`, and source-probe artifacts into `openclaw/clawgrit-reports` under `openclaw-performance/<tested-ref>/<run-id>-<attempt>/<lane>/`. The current tested-ref pointer is written as `openclaw-performance/<tested-ref>/latest-<lane>.json`.
@@ -200,19 +200,13 @@ from `release/YYYY.M.PATCH` or `main` after the release tag exists and after the
OpenClaw npm preflight has succeeded. It verifies `pnpm plugins:sync:check`,
dispatches `Plugin NPM Release` for all publishable plugin packages, dispatches
`Plugin ClawHub Release` for the same release SHA, and only then dispatches
`OpenClaw NPM Release` with the saved `preflight_run_id`. Stable publish also
requires an exact `windows_node_tag`; the workflow verifies the Windows source
release and compares its x64/ARM64 installers with the candidate-approved
`windows_node_installer_digests` input before any publish child, then promotes
and verifies those same pinned installer digests plus the exact companion asset
and checksum contract before publishing the GitHub release draft.
`OpenClaw NPM Release` with the saved `preflight_run_id`.
```bash
gh workflow run openclaw-release-publish.yml \
--ref release/YYYY.M.PATCH \
-f tag=vYYYY.M.PATCH-beta.N \
-f preflight_run_id=<successful-openclaw-npm-preflight-run-id> \
-f full_release_validation_run_id=<successful-full-release-validation-run-id> \
-f npm_dist_tag=beta
```

View File

@@ -27,7 +27,7 @@ Use it when you want to:
- inspect the local requested policy, host approvals file, and effective merge
- apply a local preset such as YOLO or deny-all
- synchronize local `tools.exec.*` and the local host approvals file
- synchronize local `tools.exec.*` and local `~/.openclaw/exec-approvals.json`
Examples:
@@ -183,9 +183,7 @@ Targeting notes:
- `--node` uses the same resolver as `openclaw nodes` (id, name, ip, or id prefix).
- `--agent` defaults to `"*"`, which applies to all agents.
- The node host must advertise `system.execApprovals.get/set` (macOS app or headless node host).
- Approvals files are stored per host in the OpenClaw state dir
(`$OPENCLAW_STATE_DIR/exec-approvals.json`, or
`~/.openclaw/exec-approvals.json` when the variable is unset).
- Approvals files are stored per host at `~/.openclaw/exec-approvals.json`.
## Related

View File

@@ -174,22 +174,7 @@ Notes:
or `--element`.
- `existing-session` / `user` profiles support page screenshots and `--ref`
screenshots from snapshot output, but not CSS `--element` screenshots.
- `--labels` overlays current snapshot refs on the screenshot. On
Playwright-backed profiles, it works with `--full-page` (full-page label
overlay), `--ref` (element-clip label overlay by ARIA ref), and `--element`
(element-clip label overlay by CSS selector); in element-clip modes, labels
are projected relative to the element. The response also includes an
`annotations` array with each ref's bounding box. Each item has `ref`,
`number`, `role`, optional `name`, and `box: {x, y, width, height}`;
coordinates are in the captured image's space (viewport / fullpage /
element-relative). The field is omitted when empty.
`existing-session` profiles render a chrome-mcp overlay on page screenshots
but do not use the Playwright projection helper and do not include
`annotations`; CSS `--element` screenshots are unsupported there. Without
Playwright or chrome-mcp, labeled screenshots are not available. Prior
releases ignored `--full-page`, `--ref`, and `--element` on labeled
Playwright screenshots and always returned a viewport capture; labeled
screenshots now honor those scopes.
- `--labels` overlays current snapshot refs on the screenshot.
- `snapshot --urls` appends discovered link destinations to AI snapshots so
agents can choose direct navigation targets instead of guessing from link
text alone.

View File

@@ -168,7 +168,7 @@ Use `--due` when you want the manual command to run only if the job is currently
## Models
`cron add|edit --model <ref>` selects an allowed model for the job. `cron edit <job-id> --clear-model` removes the per-job model override so the job follows normal cron model-selection precedence (a stored cron-session override if present, otherwise the agent/default model); it cannot be combined with `--model`.
`cron add|edit --model <ref>` selects an allowed model for the job.
<Warning>
If the model is not allowed or cannot be resolved, cron fails the run with an explicit validation error instead of falling back to the job's agent or default model selection.

View File

@@ -162,8 +162,7 @@ The node host stores its node id, token, display name, and gateway connection in
`system.run` is gated by local exec approvals:
- `$OPENCLAW_STATE_DIR/exec-approvals.json`, or
`~/.openclaw/exec-approvals.json` when the variable is unset
- `~/.openclaw/exec-approvals.json`
- [Exec approvals](/tools/exec-approvals)
- `openclaw approvals --node <id|name|ip>` (edit from the Gateway)

View File

@@ -182,10 +182,7 @@ Interactive onboarding behavior with reference mode:
### Non-interactive Z.AI endpoint choices
<Note>
`--auth-choice zai-api-key` auto-detects the best Z.AI endpoint and model for
your key. Coding Plan endpoints prefer `zai/glm-5.2`; general API endpoints use
`zai/glm-5.1`. To force a Coding Plan endpoint, pick `zai-coding-global` or
`zai-coding-cn`.
`--auth-choice zai-api-key` auto-detects the best Z.AI endpoint for your key (prefers the general API with `zai/glm-5.1`). If you specifically want the GLM Coding Plan endpoints, pick `zai-coding-global` or `zai-coding-cn`.
</Note>
```bash

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