Compare commits

..

1 Commits

Author SHA1 Message Date
Sarah Fortune
074f699c54 fix(onboard): skip config-handling prompt when no key settings detected 2026-05-12 17:16:28 -07:00
5176 changed files with 54110 additions and 330733 deletions

View File

@@ -1,145 +0,0 @@
---
name: autoreview
description: "Autoreview closeout: local dirty changes, PR branch vs main, parallel tests."
---
# Autoreview
Run Codex's built-in code review as a closeout check. This is code review (`codex review`), not Guardian `auto_review` approval routing.
Codex native review mode performs best and is recommended. Non-Codex reviewers are fallback/second-opinion paths that receive a generated diff prompt, not the full Codex review-mode runtime.
Use when:
- user asks for Codex review / autoreview / second-model review
- after non-trivial code edits, before final/commit/ship
- reviewing a local branch or PR branch after fixes
## Contract
- Treat review output as advisory. Never blindly apply it.
- Verify every finding by reading the real code path and adjacent files.
- Read dependency docs/source/types when the finding depends on external behavior.
- Reject unrealistic edge cases, speculative risks, broad rewrites, and fixes that over-complicate the codebase.
- Prefer small fixes at the right ownership boundary; no refactor unless it clearly improves the bug class.
- Keep going until the selected review path returns no accepted/actionable findings.
- If a review-triggered fix changes code, rerun focused tests and rerun the review helper.
- Default to Codex review. If Codex is unavailable or exits with an error, the helper falls back to the first configured CLI from `claude -p`, `pi -p`, `opencode run`, `droid exec`, or `copilot`. Prefer Codex for final closeout because it uses native review mode; non-Codex reviewers use a Codex-inspired generated diff prompt. The helper runs nested Codex review in yolo/full-access mode by default; use `--no-yolo` only when intentionally testing sandbox behavior.
- Stop as soon as the review command/helper exits 0 with no accepted/actionable findings. Do not run an extra direct `codex review` just to get a nicer "clean" line, a second opinion, or clearer closeout wording.
- Treat the helper's successful exit plus absence of actionable findings as the clean review result, even if the underlying Codex CLI output is terse.
- If rejecting a finding as intentional/not worth fixing, add a brief inline code comment only when it explains a real invariant or ownership decision that future reviewers should know.
- Do not push just to review. Push only when the user requested push/ship/PR update.
- For OpenClaw maintainers, keep autoreview validation Crabbox/Testbox-aware when maintainer validation mode is enabled (`OPENCLAW_TESTBOX=1` or `AUTOREVIEW_OPENCLAW_MAINTAINER_VALIDATION=1`). A review pass may inspect files and run cheap non-Node probes, but it must not start local `pnpm`, Vitest, `tsgo`, `npm test`, or `node scripts/run-vitest.mjs` from a Codex/worktree review unless the operator explicitly requested local proof. For runtime proof, use existing evidence or route through Crabbox/Testbox and report the id. Do not apply this rule to ordinary contributors who do not have maintainer Testbox access.
## Pick Target
Dirty local work:
```bash
codex review --uncommitted
```
Use this only when the patch is actually unstaged/staged/untracked in the
current checkout. For committed, pushed, or PR work, point Codex at the commit
or branch diff instead; do not force `--mode local` / `--uncommitted` just
because the helper docs mention dirty work first. A clean `--uncommitted` review
only proves there is no local patch.
Branch/PR work:
```bash
git fetch origin
codex review --base origin/main
```
Do not pass any prompt with `--base`. Some Codex CLI versions reject both inline
and stdin prompt forms, including the helper's `codex review --base <ref> -`,
with `--base <BRANCH> cannot be used with [PROMPT]`. If the helper hits this
error, run plain `codex review --base <ref>` and report that the helper prompt
injection was skipped.
If an open PR exists, use its actual base:
```bash
base=$(gh pr view --json baseRefName --jq .baseRefName)
codex review --base "origin/$base"
```
Committed single change:
```bash
codex review --commit HEAD
```
or with the helper:
```bash
.agents/skills/autoreview/scripts/autoreview --mode commit --commit HEAD
```
Use commit review for already-landed or already-pushed work on `main`. Reviewing
clean `main` against `origin/main` is usually an empty diff after push. For a
small stack, review each commit explicitly or review the branch before merging
with `--base`.
## Parallel Closeout
Format first if formatting can change line locations. Then it is OK to run tests and review in parallel:
```bash
.agents/skills/autoreview/scripts/autoreview --parallel-tests "<focused test command>"
```
Tradeoff: tests may force code changes that stale the review. If tests or review lead to code edits, rerun the affected tests and rerun review until no accepted/actionable findings remain. Once that rerun exits cleanly, stop; do not spend another long review cycle on redundant confirmation.
## Context Efficiency
Codex review is usually noisy. Default to a subagent filter when subagents are available. Ask it to run the review and return only:
- actionable findings it accepts
- findings it rejects, with one-line reason
- exact files/tests to rerun
Run inline only for tiny changes or when subagents are unavailable.
## Helper
Bundled helper:
```bash
.agents/skills/autoreview/scripts/autoreview --help
```
The helper:
- chooses dirty `--uncommitted` first
- otherwise uses current PR base if `gh pr view` works
- otherwise uses `origin/main` for non-main branches
- auto-runs `PNPM_CONFIG_PM_ON_FAIL=ignore PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=false PNPM_CONFIG_OFFLINE=true pnpm run check` in parallel when a repo has `package.json`, `pnpm-lock.yaml`, `node_modules`, and a `check` script; disable with `AUTOREVIEW_AUTO_TESTS=0`
- use `--mode commit --commit <ref>` for already-committed work, especially clean `main` after landing
- should be left in `--mode auto` or forced to `--mode branch` for PR/branch work; do not force `--mode local` after committing
- supports `--reviewer codex|claude|pi|opencode|droid|copilot|auto`; `auto` means Codex first
- supports `--fallback-reviewer auto|claude|pi|opencode|droid|copilot|none`; default is configured CLI fallback
- falls back only when Codex is unavailable or exits nonzero, not when Codex reports findings
- writes only to stdout unless `--output` or `AUTOREVIEW_OUTPUT` is set
- supports `--dry-run`, `--parallel-tests`, and commit refs
- runs nested review with `--dangerously-bypass-approvals-and-sandbox --sandbox danger-full-access` by default
- injects maintainer-only OpenClaw validation policy into native Codex review when `OPENCLAW_TESTBOX=1` or `AUTOREVIEW_OPENCLAW_MAINTAINER_VALIDATION=1`, so local memory-heavy Node/Vitest checks are avoided in favor of Crabbox/Testbox proof
- branch mode may fail on Codex CLI versions that reject `--base` plus the helper's stdin prompt; on that exact parser error, rerun plain `codex review --base <ref>` instead of falling back to a non-Codex reviewer
- keeps accepting `--full-access`; use `--no-yolo` or `AUTOREVIEW_YOLO=0` to opt out
- still accepts legacy `CODEX_REVIEW_*` env vars when the matching `AUTOREVIEW_*` var is unset
- prints `autoreview clean: no accepted/actionable findings reported` when the selected review command exits 0
## Final Report
Include:
- review command used
- tests/proof run
- findings accepted/rejected, briefly why
- the clean review result from the final helper/review run, or why a remaining finding was consciously rejected
Do not run another Codex review solely to improve the final report wording. If the final helper run exited 0 and produced no accepted/actionable findings, report that exact run as clean.
## PR / CI Closeout
- Prefer direct run/job APIs after CI starts: `gh run view <run-id> --json jobs`; use PR rollup only for final mergeability.
- After rebase, compare `origin/main..HEAD`; drop CI-fix commits already upstream before pushing.
- For prompt snapshot CI failures, prove/generate with Linux Node 24 before rerunning the failed job.
- Update PR body once near the final head unless proof labels are missing or stale enough to block CI.

View File

@@ -1,707 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: autoreview [options]
Options:
--mode auto|local|branch|commit
Target selection. Default: auto.
--base REF Base ref for branch review. Default: PR base or origin/main.
--commit REF Commit ref for commit review. Default: HEAD.
--reviewer codex|claude|pi|opencode|droid|copilot|auto
Review engine. Default: Codex with configured fallback on error.
--fallback-reviewer auto|claude|pi|opencode|droid|copilot|none
Fallback when Codex is unavailable or exits nonzero. Default: auto.
--codex-bin PATH Codex binary. Default: codex.
--claude-bin PATH Claude binary. Default: claude.
--pi-bin PATH Pi binary. Default: pi.
--opencode-bin PATH OpenCode binary. Default: opencode.
--droid-bin PATH Droid binary. Default: droid.
--copilot-bin PATH GitHub Copilot binary. Default: copilot.
--full-access Keep yolo/full-access mode enabled. Default.
--no-yolo Run nested Codex review with normal sandbox/approval prompts.
--output FILE Also save output to file.
--parallel-tests CMD Run review and test command concurrently.
Default: PNPM_CONFIG_PM_ON_FAIL=ignore PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=false PNPM_CONFIG_OFFLINE=true pnpm run check when available.
--dry-run Print selected commands, do not run.
-h, --help Show help.
Environment:
OPENCLAW_TESTBOX=1 or AUTOREVIEW_OPENCLAW_MAINTAINER_VALIDATION=1
Enable maintainer-only OpenClaw Crabbox/Testbox validation policy.
Modes:
local codex review --uncommitted
branch codex review --base <base>
commit codex review --commit <commit>
auto dirty tree -> local, else PR/current branch -> branch
EOF
}
mode=auto
base_ref=
commit_ref=HEAD
reviewer=${AUTOREVIEW_REVIEWER:-${CODEX_REVIEW_REVIEWER:-auto}}
fallback_reviewer=${AUTOREVIEW_FALLBACK_REVIEWER:-${CODEX_REVIEW_FALLBACK_REVIEWER:-auto}}
codex_bin=${CODEX_BIN:-codex}
claude_bin=${CLAUDE_BIN:-claude}
pi_bin=${PI_BIN:-pi}
opencode_bin=${OPENCODE_BIN:-opencode}
droid_bin=${DROID_BIN:-droid}
copilot_bin=${COPILOT_BIN:-copilot}
codex_args=()
yolo=${AUTOREVIEW_YOLO:-${CODEX_REVIEW_YOLO:-1}}
output=${AUTOREVIEW_OUTPUT:-${CODEX_REVIEW_OUTPUT:-}}
parallel_tests=
parallel_tests_auto=false
dry_run=false
codex_review_prompt=
codex_review_stdin_prompt=false
codex_review_prompt_file=false
openclaw_maintainer_validation=${AUTOREVIEW_OPENCLAW_MAINTAINER_VALIDATION:-${OPENCLAW_TESTBOX:-0}}
while [[ $# -gt 0 ]]; do
case "$1" in
--mode)
mode=${2:-}
shift 2
;;
--base)
base_ref=${2:-}
shift 2
;;
--commit)
commit_ref=${2:-}
shift 2
;;
--reviewer)
reviewer=${2:-}
shift 2
;;
--fallback-reviewer)
fallback_reviewer=${2:-}
shift 2
;;
--codex-bin)
codex_bin=${2:-}
shift 2
;;
--claude-bin)
claude_bin=${2:-}
shift 2
;;
--pi-bin)
pi_bin=${2:-}
shift 2
;;
--opencode-bin)
opencode_bin=${2:-}
shift 2
;;
--droid-bin)
droid_bin=${2:-}
shift 2
;;
--copilot-bin)
copilot_bin=${2:-}
shift 2
;;
--full-access)
yolo=1
shift
;;
--no-yolo)
yolo=0
shift
;;
--output)
output=${2:-}
shift 2
;;
--parallel-tests)
parallel_tests=${2:-}
shift 2
;;
--dry-run)
dry_run=true
shift
;;
-h|--help)
usage
exit 0
;;
*)
usage >&2
exit 2
;;
esac
done
case "$yolo" in
0|false|False|FALSE|no|No|NO|off|Off|OFF) ;;
*) codex_args+=(--dangerously-bypass-approvals-and-sandbox --sandbox danger-full-access) ;;
esac
case "$mode" in
auto|local|branch|commit) ;;
*)
echo "invalid --mode: $mode" >&2
exit 2
;;
esac
case "$reviewer" in
auto|codex|claude|pi|opencode|droid|copilot) ;;
*)
echo "invalid --reviewer: $reviewer" >&2
exit 2
;;
esac
case "$fallback_reviewer" in
auto|claude|pi|opencode|droid|copilot|none) ;;
*)
echo "invalid --fallback-reviewer: $fallback_reviewer" >&2
exit 2
;;
esac
repo_root=$(git rev-parse --show-toplevel)
printf -v quoted_repo_root '%q' "$repo_root"
has_package_check_script() {
command -v node >/dev/null 2>&1 || return 1
node -e 'const { readFileSync } = require("node:fs"); const p = JSON.parse(readFileSync(process.argv[1], "utf8")); process.exit(p.scripts?.check ? 0 : 1)' \
"$repo_root/package.json" \
>/dev/null 2>&1
}
auto_tests_disabled() {
case "${AUTOREVIEW_AUTO_TESTS:-${CODEX_REVIEW_AUTO_TESTS:-1}}" in
0|false|False|FALSE|no|No|NO|off|Off|OFF) return 0 ;;
*) return 1 ;;
esac
}
current_branch=$(git branch --show-current 2>/dev/null || true)
dirty=false
if [[ -n "$(git status --porcelain)" ]]; then
dirty=true
fi
pr_url=
if [[ -z "$base_ref" && "$mode" != local ]] && command -v gh >/dev/null 2>&1; then
if pr_lines=$(gh pr view --json baseRefName,url --jq '[.baseRefName, .url] | @tsv' 2>/dev/null); then
base_name=${pr_lines%%$'\t'*}
pr_url=${pr_lines#*$'\t'}
if [[ -n "$base_name" ]]; then
base_ref="origin/$base_name"
fi
fi
fi
if [[ -z "$base_ref" ]]; then
base_ref=origin/main
fi
review_kind=
if [[ "$mode" == local || ( "$mode" == auto && "$dirty" == true ) ]]; then
review_kind=local
elif [[ "$mode" == commit ]]; then
review_kind=commit
elif [[ "$mode" == branch || ( "$mode" == auto && -n "$current_branch" && "$current_branch" != "main" ) ]]; then
review_kind=branch
else
echo "no review target: clean main checkout and no forced mode" >&2
exit 1
fi
if [[ "$review_kind" == local ]]; then
review_cmd=("$codex_bin" "${codex_args[@]}" review --uncommitted)
elif [[ "$review_kind" == commit ]]; then
review_cmd=("$codex_bin" "${codex_args[@]}" review --commit "$commit_ref")
else
review_cmd=("$codex_bin" "${codex_args[@]}" review --base "$base_ref")
fi
repo_url=$(git -C "$repo_root" config --get remote.origin.url 2>/dev/null || true)
case "$openclaw_maintainer_validation" in
1|true|True|TRUE|yes|Yes|YES|on|On|ON) openclaw_maintainer_validation=1 ;;
*) openclaw_maintainer_validation=0 ;;
esac
if [[ -z "$parallel_tests" && "$openclaw_maintainer_validation" != 1 ]] &&
! auto_tests_disabled; then
if [[ -f "$repo_root/package.json" && -f "$repo_root/pnpm-lock.yaml" && -d "$repo_root/node_modules" ]] &&
command -v pnpm >/dev/null 2>&1 &&
has_package_check_script; then
parallel_tests="cd $quoted_repo_root && PNPM_CONFIG_PM_ON_FAIL=ignore PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=false PNPM_CONFIG_OFFLINE=true pnpm run check"
parallel_tests_auto=true
fi
fi
if [[ "$repo_url" == *"openclaw/openclaw"* && "$openclaw_maintainer_validation" == 1 ]]; then
codex_review_prompt=$(cat <<'EOF'
OpenClaw maintainer autoreview validation policy:
- Review the diff by reading code, tests, and dependency contracts.
- Do not run local memory-heavy Node validation from review mode. This includes local pnpm checks/tests, Vitest, tsgo, npm test, and node scripts/run-vitest.mjs.
- If runtime proof is needed, use existing proof or route validation through Crabbox / Blacksmith Testbox and report the exact provider and id.
- If remote validation is not necessary for the finding, state the targeted proof that should be run instead of starting local tests.
EOF
)
if [[ "$review_kind" == local ]]; then
review_cmd+=(-)
codex_review_stdin_prompt=true
else
review_cmd=("$codex_bin" "${codex_args[@]}" review -)
codex_review_prompt_file=true
fi
fi
printf 'autoreview target: %s\n' "$review_kind"
printf 'branch: %s\n' "${current_branch:-detached}"
if [[ -n "$pr_url" ]]; then
printf 'pr: %s\n' "$pr_url"
fi
if [[ "$reviewer" == auto ]]; then
printf 'reviewer: codex\n'
else
printf 'reviewer: %s\n' "$reviewer"
fi
case "$reviewer" in
codex|auto) ;;
*)
printf 'note: Codex native review mode is the recommended and best-supported review path; %s uses a generated diff prompt.\n' "$reviewer"
;;
esac
if [[ "$reviewer" == auto || "$reviewer" == codex ]]; then
printf 'review:'
printf ' %q' "${review_cmd[@]}"
printf '\n'
if [[ "$codex_review_stdin_prompt" == true || "$codex_review_prompt_file" == true ]]; then
printf 'review policy: OpenClaw maintainer Crabbox/Testbox-aware validation prompt injected\n'
fi
else
printf 'review: %s prompt review\n' "$reviewer"
fi
if [[ -n "$parallel_tests" ]]; then
printf 'tests: %s' "$parallel_tests"
if [[ "$parallel_tests_auto" == true ]]; then
printf ' (auto)'
fi
printf '\n'
fi
if [[ "$review_kind" == branch ]]; then
printf 'fetch: git fetch origin --quiet\n'
fi
if [[ -n "$output" ]]; then
printf 'output: %s\n' "$output"
fi
if [[ "$dry_run" == true ]]; then
exit 0
fi
if [[ "$review_kind" == branch ]]; then
git fetch origin --quiet || {
echo "warning: git fetch origin failed; reviewing with existing refs" >&2
}
fi
review_output=$output
review_output_is_temp=false
if [[ -z "$review_output" ]]; then
review_output=$(mktemp)
review_output_is_temp=true
fi
mkdir -p "$(dirname "$review_output")"
: > "$review_output"
cleanup() {
if [[ "${review_output_is_temp:-false}" == true && -n "${review_output:-}" ]]; then
rm -f "$review_output"
fi
if [[ -n "${prompt_file:-}" ]]; then
rm -f "$prompt_file"
fi
}
trap cleanup EXIT
run_review() {
local status=0
mkdir -p "$(dirname "$review_output")"
if [[ "$codex_review_prompt_file" == true ]]; then
build_prompt_file || return
"${review_cmd[@]}" < "$prompt_file" 2>&1 | tee "$review_output"
status=${PIPESTATUS[0]}
rm -f "$prompt_file"
prompt_file=
return "$status"
elif [[ "$codex_review_stdin_prompt" == true ]]; then
printf '%s\n' "$codex_review_prompt" | "${review_cmd[@]}" 2>&1 | tee "$review_output"
else
"${review_cmd[@]}" 2>&1 | tee "$review_output"
fi
}
diff_for_review() {
case "$review_kind" in
local)
git -C "$repo_root" diff --stat
git -C "$repo_root" diff --cached --stat
git -C "$repo_root" diff --find-renames
git -C "$repo_root" diff --cached --find-renames
while IFS= read -r untracked_file; do
[[ -n "$untracked_file" ]] || continue
git -C "$repo_root" diff --no-index -- /dev/null "$untracked_file" || true
done < <(git -C "$repo_root" ls-files --others --exclude-standard)
;;
commit)
git -C "$repo_root" show --find-renames --stat --format=fuller "$commit_ref"
git -C "$repo_root" show --find-renames --format=medium "$commit_ref"
;;
branch)
git -C "$repo_root" diff --find-renames --stat "$base_ref"...HEAD
git -C "$repo_root" diff --find-renames "$base_ref"...HEAD
;;
esac
}
build_prompt_file() {
prompt_file=$(mktemp)
{
cat <<EOF
You are performing a closeout code review for the current repository.
Review target: $review_kind
Branch: ${current_branch:-detached}
Base: ${base_ref:-}
Commit: ${commit_ref:-}
Rules:
- Review the proposed code change as a closeout reviewer.
- Focus on the diff below. If your CLI exposes read-only repository tools, inspect surrounding code and tests to verify findings; never modify files.
- Do not modify files.
${codex_review_prompt}
- Report only discrete, actionable issues introduced by this change.
- Prioritize correctness, regressions, security, data loss, performance cliffs, and missing tests that would catch a real bug.
- Do not report pre-existing issues, speculative risks, broad rewrites, style nits, changelog gaps, or findings that depend on unstated assumptions.
- Identify the concrete scenario where the issue appears, and keep the line reference as small as possible.
- A finding should overlap changed code or clearly cite changed code as the cause.
- For each accepted/actionable finding, use exactly this format:
[P<0-3>] Short title
File: path:line
Why: one sentence
Fix: one sentence
- If no accepted/actionable findings, output exactly:
autoreview clean: no accepted/actionable findings reported
Diff:
EOF
diff_for_review
} > "$prompt_file" || return
}
reviewer_output_has_clean_marker() {
local path=$1
grep -Eq '^[^[:alnum:]]*autoreview clean: no accepted/actionable findings reported[[:space:]]*$' "$path"
}
run_prompt_reviewer() {
local selected=$1
local copilot_prompt=
local prompt_bytes=0
local reviewer_output
local status=0
if ! build_prompt_file; then
rm -f "${prompt_file:-}"
prompt_file=
return 1
fi
reviewer_output=$(mktemp)
mkdir -p "$(dirname "$review_output")"
case "$selected" in
claude)
if ! command -v "$claude_bin" >/dev/null 2>&1; then
echo "fallback reviewer unavailable: $claude_bin" >&2
status=127
elif printf 'fallback: claude -p\n' | tee -a "$review_output"; then
"$claude_bin" --tools "" --no-session-persistence -p < "$prompt_file" 2>&1 | tee -a "$review_output" "$reviewer_output"
status=$?
else
status=$?
fi
;;
pi)
if ! command -v "$pi_bin" >/dev/null 2>&1; then
echo "fallback reviewer unavailable: $pi_bin" >&2
status=127
elif printf 'fallback: pi -p\n' | tee -a "$review_output"; then
"$pi_bin" --no-tools --no-session -p < "$prompt_file" 2>&1 | tee -a "$review_output" "$reviewer_output"
status=$?
else
status=$?
fi
;;
opencode)
if ! command -v "$opencode_bin" >/dev/null 2>&1; then
echo "fallback reviewer unavailable: $opencode_bin" >&2
status=127
elif printf 'fallback: opencode run\n' | tee -a "$review_output"; then
"$opencode_bin" run --pure --dir "$repo_root" \
"Review the attached prompt file. Do not modify files." \
--file "$prompt_file" 2>&1 | tee -a "$review_output" "$reviewer_output"
status=$?
else
status=$?
fi
;;
droid)
if ! command -v "$droid_bin" >/dev/null 2>&1; then
echo "fallback reviewer unavailable: $droid_bin" >&2
status=127
elif printf 'fallback: droid exec\n' | tee -a "$review_output"; then
"$droid_bin" exec --cwd "$repo_root" -f "$prompt_file" 2>&1 | tee -a "$review_output" "$reviewer_output"
status=$?
else
status=$?
fi
;;
copilot)
if ! command -v "$copilot_bin" >/dev/null 2>&1; then
echo "fallback reviewer unavailable: $copilot_bin" >&2
status=127
elif printf 'fallback: copilot\n' | tee -a "$review_output"; then
prompt_bytes=$(wc -c < "$prompt_file" | tr -d '[:space:]')
if (( prompt_bytes > 120000 )); then
echo "copilot reviewer unavailable: generated prompt is too large for copilot -p; use codex, droid, or another file/stdin-capable reviewer" \
2>&1 | tee -a "$review_output" "$reviewer_output"
status=1
else
copilot_prompt=$(< "$prompt_file")
"$copilot_bin" -C "$repo_root" --available-tools=none --stream off --output-format text --silent \
-p "$copilot_prompt" \
2>&1 | tee -a "$review_output" "$reviewer_output"
status=$?
fi
else
status=$?
fi
;;
*)
echo "unsupported prompt reviewer: $selected" >&2
status=2
;;
esac
if [[ "$status" == 0 ]]; then
if grep -Eq '\[P[0-3]\]' "$reviewer_output"; then
status=1
elif ! grep -q '[^[:space:]]' "$reviewer_output"; then
status=1
elif ! reviewer_output_has_clean_marker "$reviewer_output"; then
status=1
fi
fi
rm -f "$reviewer_output"
rm -f "$prompt_file"
prompt_file=
return "$status"
}
run_selected_review() {
local selected=$1
case "$selected" in
codex)
if ! command -v "$codex_bin" >/dev/null 2>&1; then
echo "codex reviewer unavailable: $codex_bin" >&2
return 127
fi
run_review
;;
claude|pi|opencode|droid|copilot)
run_prompt_reviewer "$selected"
;;
*)
echo "unsupported reviewer: $selected" >&2
return 2
;;
esac
}
fallback_reviewer_is_available() {
local selected=$1
case "$selected" in
claude) command -v "$claude_bin" >/dev/null 2>&1 ;;
pi) command -v "$pi_bin" >/dev/null 2>&1 ;;
opencode) command -v "$opencode_bin" >/dev/null 2>&1 ;;
droid) command -v "$droid_bin" >/dev/null 2>&1 ;;
copilot) command -v "$copilot_bin" >/dev/null 2>&1 ;;
*) return 1 ;;
esac
}
run_auto_fallback_review() {
local selected
if [[ "$fallback_reviewer" != auto ]]; then
run_selected_review "$fallback_reviewer"
return $?
fi
for selected in claude pi opencode droid copilot; do
if fallback_reviewer_is_available "$selected"; then
run_selected_review "$selected"
return $?
fi
done
echo "fallback reviewer unavailable: no configured fallback CLI found" >&2
return 127
}
run_auto_review() {
run_selected_review codex
local status=$?
if [[ "$status" == 0 ]]; then
return 0
fi
if (( status > 128 && status < 192 )); then
return "$status"
fi
if review_output_has_findings; then
return "$status"
fi
if [[ "$fallback_reviewer" == none ]]; then
return "$status"
fi
if [[ "$fallback_reviewer" == auto ]]; then
printf 'autoreview warning: codex exited %s; trying configured fallback reviewers\n' "$status" >&2
else
printf 'autoreview warning: codex exited %s; falling back to %s\n' "$status" "$fallback_reviewer" >&2
fi
run_auto_fallback_review
}
elapsed_since() {
local started_at=$1
local finished_at
finished_at=$(date +%s)
printf '%s\n' "$((finished_at - started_at))"
}
format_elapsed() {
local seconds=$1
if (( seconds < 60 )); then
printf '%ss\n' "$seconds"
else
printf '%sm%ss\n' "$((seconds / 60))" "$((seconds % 60))"
fi
}
review_output_empty() {
[[ ! -s "$review_output" ]] || ! grep -q '[^[:space:]]' "$review_output"
}
review_findings_text() {
if grep -Fxq 'codex' "$review_output"; then
awk '
$0 == "codex" {
capture = 1
output = $0 ORS
next
}
capture {
output = output $0 ORS
}
END {
printf "%s", output
}
' "$review_output"
return
fi
cat "$review_output"
}
review_output_has_findings() {
review_findings_text | grep -Eq '\[P[0-3]\]'
}
report_clean_review_or_fail() {
local elapsed_text
elapsed_text=$(format_elapsed "${review_elapsed_seconds:-0}")
if review_output_has_findings; then
printf 'autoreview complete after %s\n' "$elapsed_text"
printf 'autoreview findings: accepted/actionable findings reported\n'
return 1
fi
if review_output_empty; then
printf 'autoreview complete after %s; no output\n' "$elapsed_text"
return 1
fi
printf 'autoreview complete after %s\n' "$elapsed_text"
printf 'autoreview clean: no accepted/actionable findings reported\n'
}
if [[ -z "$parallel_tests" ]]; then
review_started_at=$(date +%s)
set +e
if [[ "$reviewer" == auto ]]; then
run_auto_review
else
run_selected_review "$reviewer"
fi
review_status=$?
review_elapsed_seconds=$(elapsed_since "$review_started_at")
set -e
if [[ "$review_status" == 0 ]]; then
report_clean_review_or_fail
exit $?
fi
exit "$review_status"
fi
review_status_file=$(mktemp)
review_elapsed_file=$(mktemp)
tests_status_file=$(mktemp)
(
set +e
review_started_at=$(date +%s)
if [[ "$reviewer" == auto ]]; then
run_auto_review
else
run_selected_review "$reviewer"
fi
status=$?
elapsed=$(elapsed_since "$review_started_at")
printf '%s\n' "$status" > "$review_status_file"
printf '%s\n' "$elapsed" > "$review_elapsed_file"
) &
review_pid=$!
(
set +e
bash -lc "$parallel_tests"
status=$?
printf '%s\n' "$status" > "$tests_status_file"
) &
tests_pid=$!
wait "$review_pid" || true
wait "$tests_pid" || true
review_status=$(cat "$review_status_file")
review_elapsed_seconds=$(cat "$review_elapsed_file")
tests_status=$(cat "$tests_status_file")
rm -f "$review_status_file" "$review_elapsed_file" "$tests_status_file"
printf 'autoreview exit: %s\n' "$review_status"
printf 'tests exit: %s\n' "$tests_status"
if [[ "$review_status" != 0 || "$tests_status" != 0 ]]; then
exit 1
fi
report_clean_review_or_fail

View File

@@ -1,159 +0,0 @@
---
name: clawdtributor
description: "Use for OpenClaw clawtributors PR/issue triage: Discrawl discovery, live-open rechecks, deep review, topic grouping, and compact @handle/LOC/type/blast/verification summaries."
---
# Clawdtributor
Use for the `#clawtributors` queue: Discord-discovered OpenClaw PRs/issues that need live GitHub status plus maintainer-quality review.
## Compose with other skills
- `$discrawl`: local Discord archive sync/search.
- `$openclaw-pr-maintainer`: live GitHub PR/issue review, duplicate search, close/land rules.
- `$gitcrawl`: related issue/PR and current-main/stale-proof search.
- `$openclaw-testing` / `$crabbox`: proof choice when a candidate needs real validation.
## Archive flow
Local archive first; verify freshness for current questions.
```bash
discrawl status --json
discrawl sync
```
Resolve channel if needed:
```bash
sqlite3 "$HOME/.discrawl/discrawl.db" \
"select id,name from channels where name like '%clawtributor%' order by name;"
```
Current known channel id from prior work: `1458141495701012561`. Re-resolve if it stops matching.
Extract recent refs:
```bash
sqlite3 "$HOME/.discrawl/discrawl.db" "
select m.created_at, coalesce(nullif(mm.username,''), m.author_id), m.content
from messages m
left join members mm on mm.guild_id=m.guild_id and mm.user_id=m.author_id
where m.channel_id='1458141495701012561'
and m.created_at >= '<ISO cutoff>'
order by m.created_at desc;" |
perl -nE 'while(m{github\.com/openclaw/openclaw/(pull|issues)/(\d+)}g){say "$1\t$2\t$_"}'
```
Map a PR/issue back to the Discord handle:
```bash
sqlite3 -separator $'\t' "$HOME/.discrawl/discrawl.db" "
select m.created_at,
coalesce(nullif(mm.username,''), nullif(mm.global_name,''), m.author_id)
from messages m
left join members mm on mm.guild_id=m.guild_id and mm.user_id=m.author_id
where m.channel_id='1458141495701012561'
and m.content like '%github.com/openclaw/openclaw/<pull-or-issues>/<number>%'
order by m.created_at desc
limit 1;"
```
Show only `@handle` in the final list. Do not write the word Discord unless the user asks for source details.
## Live GitHub recheck
Always recheck live state before listing, closing, or saying "open".
```bash
GITHUB_TOKEN= GITHUB_TOKEN_NODIFF= GH_TOKEN= \
gh api repos/openclaw/openclaw/pulls/<number> \
--jq '. | {number,title,state,merged,mergeable,draft,author:.user.login,url:.html_url,updatedAt:.updated_at,additions,deletions,changedFiles:.changed_files}'
```
For issues:
```bash
GITHUB_TOKEN= GITHUB_TOKEN_NODIFF= GH_TOKEN= \
gh api repos/openclaw/openclaw/issues/<number> \
--jq '. | {number,title,state,author:.user.login,url:.html_url,updatedAt:.updated_at,pull_request}'
```
If `gh` says bad credentials, clear env vars with empty assignments as above. Use `--jq '. | {...}'` for object projections.
## Review depth
For each open item, inspect enough to classify risk:
- PR body, linked issue, comments, files, additions/deletions, checks.
- Current `origin/main` code path and adjacent tests.
- Related threads with `gitcrawl neighbors/search`.
- Whether main already fixed it, the PR is obsolete, or the idea is invalid.
- Blast radius: touched runtime surfaces, config/schema, plugin/core boundary, user-visible behavior, release/package surface.
- Verification: say if local unit/docs proof is enough, live/provider proof is needed, or it is not directly verifiable.
Do not close from title alone. If closing as done on main or nonsensical, prove it against current main and comment first when mutation is requested. Bulk close/reopen above 5 requires explicit scope.
## Candidate selection
When asked for `5 new`, exclude refs already surfaced in the session and refill from the archive until there are 5 live-open candidates. If fewer than 5 remain open, list all open ones and say how many short.
When asked to `update`, `refresh`, `recheck`, `check again`, or similar, return an updated live-open candidate list. Do not fill the main list with items that merely merged/closed since the last pass; put those numbers in a short bottom line.
Prefer:
- Fresh, open, external contributor work.
- Small, high-confidence bugfixes.
- Clear repro, tests, or obvious code-path proof.
Demote:
- Broad product/features without owner decision.
- Large rewrites with unclear contract.
- PRs already in progress, merged, closed, duplicate, or fixed on main.
## Topic grouping
Group only when useful or requested:
- Agents/tooling
- Providers/auth/models
- Channels/messaging
- UI/web
- Gateway/protocol/runtime
- Config/memory/cache
- Docker/install/release
- Docs/tests/chore
- Closed/obsolete
Infer topic from labels, touched files, title/body, and actual code path.
## Output format
No Markdown tables. Compact bullets. Use color/risk markers:
- 🟢 low/narrow
- 🟡 medium or needs targeted proof
- 🔴 broad/high runtime risk
- 🟣 security/policy/owner-boundary slow review
- ✅ merged
- ⚪ closed unmerged
Required line shape:
```markdown
- **PR #81244** `@whatsskill.` `+118/-1` `bug` 🟢 verifiable: yes. This prevents chat action buttons from overlapping short assistant replies. Blast: web chat rendering, low.
- **Issue #81245** `@alice` `LOC n/a` `bug` 🟡 verifiable: partial. This reports duplicate Telegram replies when reconnecting after gateway restart. Blast: Telegram channel runtime, medium.
```
Rules:
- Bold the `PR #n` or `Issue #n` marker.
- Use `@handle`, not author bio text.
- PR LOC is `+additions/-deletions`; issue LOC is `LOC n/a`.
- Type: `bug`, `feature`, `perf`, `security`, `docs`, `test`, `chore`, or `refactor`.
- Write a full sentence for what it does.
- Always include blast radius in one phrase.
- Always include `verifiable: yes|partial|no` plus the shortest proof hint when helpful.
- If status is not open, still show it only when the user asked for all surfaced refs; use ✅ or ⚪ and state merged/closed.
- For refresh-style asks, bottom line: `Merged/closed since last pass: #81016 merged, #81026 closed.` Omit if none.

View File

@@ -1,32 +1,17 @@
---
name: crabbox
description: Use the Crabbox wrapper for OpenClaw remote validation across Linux, macOS, Windows, and WSL2, including delegated Blacksmith Testbox proof. Report the actual provider and id.
description: Use Crabbox for OpenClaw remote Linux validation. Default to Blacksmith Testbox; includes direct Blacksmith and owned AWS/Hetzner fallback notes when Crabbox fails.
---
# Crabbox
Use the Crabbox wrapper when OpenClaw needs remote Linux proof for broad tests,
CI-parity checks, secrets, hosted services, Docker/E2E/package lanes, warmed
reusable boxes, sync timing, logs/results, cache inspection, or lease cleanup.
Use Crabbox when OpenClaw needs remote Linux proof for broad tests, CI-parity
checks, secrets, hosted services, Docker/E2E/package lanes, warmed reusable
boxes, sync timing, logs/results, cache inspection, or lease cleanup.
Crabbox is the transport/orchestration surface. The actual backend can be:
- brokered AWS Crabbox: direct provider, `provider=aws`, lease ids like
`cbx_...`, `syncDelegated=false`
- Blacksmith Testbox through Crabbox: delegated provider,
`provider=blacksmith-testbox`, ids like `tbx_...`, `syncDelegated=true`
For OpenClaw maintainer broad `pnpm` gates, Blacksmith Testbox through the
Crabbox wrapper is acceptable and often preferred when the standing Testbox
rules apply. Do not describe those runs as "AWS Crabbox"; report them as
Testbox-through-Crabbox with the `tbx_...` id and Actions run.
Use the repo `.crabbox.yaml` brokered AWS path when the task specifically needs
direct AWS Crabbox behavior, persistent direct-provider leases, `--fresh-pr`,
`--full-resync`, environment forwarding, capture/download support, or provider
comparison. Use `--provider blacksmith-testbox` when the task needs OpenClaw
maintainer Testbox proof, prepared CI environment, broad/heavy pnpm gates, or
the user asks for Testbox/Blacksmith.
Default backend: `blacksmith-testbox`. The separate `blacksmith-testbox` skill
has been removed; this skill owns both the normal Crabbox path and the direct
Blacksmith fallback playbook.
## First Checks
@@ -43,25 +28,16 @@ pnpm crabbox:run -- --help | sed -n '1,120p'
- OpenClaw scripts prefer `../crabbox/bin/crabbox` when present. The user PATH
shim can be stale.
- Check `.crabbox.yaml` for direct-provider defaults. Omitting `--provider`
means brokered AWS today.
- 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.
- 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
concluding no box exists.
- If a warm direct-provider lease smells stale, retry with `--full-resync`
(alias `--fresh-sync`) before replacing the lease. This resets the remote
workdir, skips the fingerprint fast path, reseeds Git when possible, and
uploads the checkout from scratch.
- For live/provider bugs, use the configured secret workflow before downgrading
to mocks. Copy only the exact needed key into the remote process environment
for that one command. Do not print it, do not sync it as a repo file, and do
not leave it in remote shell history or logs. If no secret-safe injection path
is available, say true live provider auth is blocked instead of silently using
a fake key.
- Check `.crabbox.yaml` for repo defaults, but override provider explicitly.
Even if config still says AWS, maintainer validation should normally pass
`--provider blacksmith-testbox`.
- For live/provider bugs, check keys on the local Mac before downgrading to
mocks: source local `~/.profile` and test only presence/length. If Crabbox
does not already have the key, copy only the exact needed key into the remote
process environment for that one command. Do not print it, do not sync it as a
repo file, and do not leave it in remote shell history or logs. If no
secret-safe injection path is available, say true live provider auth is
blocked instead of silently using a fake key.
- Prefer local targeted tests for tight edit loops. Broad gates belong remote.
- Do not treat inherited shell env as operator intent. In particular,
`OPENCLAW_LOCAL_CHECK_MODE=throttled` from the local shell is not permission
@@ -75,24 +51,7 @@ pnpm crabbox:run -- --help | sed -n '1,120p'
## macOS And Windows Targets
Use these only when the task needs an existing non-Linux host. OpenClaw broad
Linux validation uses the repo Crabbox config unless a provider is explicitly
requested.
When the user explicitly asks for brokered macOS runners, use Crabbox AWS
macOS only after confirming the deployed coordinator supports EC2 Mac host
lifecycle/image routes and the operator has AWS EC2 Mac Dedicated Host quota
and IAM. Prefer `CRABBOX_HOST_ID` for a known Crabbox-managed Dedicated Host,
or run the no-spend preflight first:
```sh
crabbox admin hosts quota --provider aws --target macos --region eu-west-1 --type mac2.metal --json
crabbox admin hosts allocate --provider aws --target macos --region eu-west-1 --type mac2.metal --dry-run --json
CRABBOX_MACOS_TYPES=all scripts/macos-host-region-preflight.sh
```
Do not silently substitute AWS macOS for normal OpenClaw Linux proof. Report
paid-host blockers as quota, IAM, coordinator deployment, or host availability
instead of falling back to local macOS.
validation still defaults to `blacksmith-testbox`.
Crabbox supports static SSH targets:
@@ -105,23 +64,27 @@ Crabbox supports static SSH targets:
- `target=macos` and `target=windows --windows-mode wsl2` use the POSIX SSH,
bash, Git, rsync, and tar contract.
- Native Windows uses OpenSSH, PowerShell, Git, and tar; sync is manifest tar
archive transfer into `static.workRoot`. Direct native Windows runs support
`--script*`, `--env-from-profile`, `--preflight`, and PowerShell `--shell`.
archive transfer into `static.workRoot`.
- `crabbox actions hydrate/register` are Linux-only today; use plain
`crabbox run` loops for static macOS and Windows hosts.
- Live proof needs a reachable, operator-managed SSH host. Without one, verify
with `../crabbox/bin/crabbox run --help`, config/flag tests, and the Crabbox
Go test suite.
## Direct Brokered AWS Backend
## Default Blacksmith Backend
Use this when the task needs direct AWS Crabbox semantics rather than the
prepared Blacksmith Testbox CI environment.
Use this for `pnpm check`, `pnpm check:changed`, `pnpm test`,
`pnpm test:changed`, Docker/E2E/live/package gates, or anything likely to fan
out across many Vitest projects.
Changed gate:
```sh
pnpm crabbox:run -- \
pnpm crabbox:run -- --provider blacksmith-testbox \
--blacksmith-org openclaw \
--blacksmith-workflow .github/workflows/ci-check-testbox.yml \
--blacksmith-job check \
--blacksmith-ref main \
--idle-timeout 90m \
--ttl 240m \
--timing-json \
@@ -132,7 +95,11 @@ pnpm crabbox:run -- \
Full suite:
```sh
pnpm crabbox:run -- \
pnpm crabbox:run -- --provider blacksmith-testbox \
--blacksmith-org openclaw \
--blacksmith-workflow .github/workflows/ci-check-testbox.yml \
--blacksmith-job check \
--blacksmith-ref main \
--idle-timeout 90m \
--ttl 240m \
--timing-json \
@@ -143,7 +110,11 @@ pnpm crabbox:run -- \
Focused rerun:
```sh
pnpm crabbox:run -- \
pnpm crabbox:run -- --provider blacksmith-testbox \
--blacksmith-org openclaw \
--blacksmith-workflow .github/workflows/ci-check-testbox.yml \
--blacksmith-job check \
--blacksmith-ref main \
--idle-timeout 90m \
--ttl 240m \
--timing-json \
@@ -153,53 +124,19 @@ pnpm crabbox:run -- \
Read the JSON summary. Useful fields:
- `provider`: `aws`
- `leaseId`: `cbx_...`
- `syncDelegated`: `false`
- `provider`: should be `blacksmith-testbox`
- `leaseId`: `tbx_...`
- `syncDelegated`: should be `true`
- `commandPhases`: populated when the command prints `CRABBOX_PHASE:<name>`
- `commandMs` / `totalMs`
- `exitCode`
Crabbox should stop one-shot AWS leases automatically after the run. Verify
cleanup when a run fails, is interrupted, or the command output is unclear:
Crabbox should stop one-shot Blacksmith Testboxes automatically after the run.
Verify cleanup when a run fails, is interrupted, or the command output is
unclear:
```sh
../crabbox/bin/crabbox list --provider aws
```
## Blacksmith Testbox Through Crabbox
Use this for OpenClaw maintainer broad/heavy `pnpm` gates when the prepared CI
environment is the right proof surface:
```sh
node scripts/crabbox-wrapper.mjs run \
--provider blacksmith-testbox \
--blacksmith-org openclaw \
--blacksmith-workflow .github/workflows/ci-check-testbox.yml \
--blacksmith-job check \
--blacksmith-ref main \
--idle-timeout 90m \
--ttl 240m \
--timing-json \
-- \
CI=1 NODE_OPTIONS=--max-old-space-size=4096 OPENCLAW_TEST_PROJECTS_PARALLEL=6 OPENCLAW_VITEST_MAX_WORKERS=1 OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS=900000 OPENCLAW_TESTBOX=1 OPENCLAW_TESTBOX_REMOTE_RUN=1 pnpm check:changed
```
Read the JSON summary and the Testbox line. Useful fields:
- `provider`: `blacksmith-testbox`
- `leaseId`: `tbx_...`
- `syncDelegated`: `true`
- `syncPhases`: delegated/skipped because Blacksmith owns checkout/sync
- Actions run URL/id from the Testbox output
- `exitCode`
`blacksmith testbox list` may hide hydrating or ready boxes. Use:
```sh
blacksmith testbox list --all
blacksmith testbox status <tbx_id>
blacksmith testbox list
```
## Observability Flags
@@ -207,16 +144,8 @@ blacksmith testbox status <tbx_id>
Use these on debugging runs before inventing ad hoc logging:
- `--preflight`: prints run context, workspace mode, SSH target, remote user/cwd,
and target-specific tool probes. Defaults cover `git`, `tar`, `node`, `npm`,
`corepack`, `pnpm`, `yarn`, `bun`, `docker`, plus POSIX
`sudo`/`apt`/`bubblewrap` and native Windows
`powershell`/`execution_policy`/`longpaths`/`temp`/`pwsh`. Add
`--preflight-tools node,bun,docker`, `CRABBOX_PREFLIGHT_TOOLS`, or repo
`run.preflightTools` to replace the list. `default` expands built-ins; `none`
prints only the workspace summary. Preflight is diagnostic only; install
toolchains through Actions hydration, images, devcontainer/Nix/mise/asdf, or
the run script. On `blacksmith-testbox`, this prints a delegated-unsupported
note because the workflow owns setup.
sudo/apt, Node, pnpm, Docker, and bubblewrap. On `blacksmith-testbox`, this
prints a delegated-unsupported note because the workflow owns setup.
- `CRABBOX_ENV_ALLOW=NAME,...`: forwards only listed local env vars for direct
providers and prints `set len=N secret=true` style summaries. On
`blacksmith-testbox`, env forwarding is unsupported; put secrets in the
@@ -225,36 +154,21 @@ Use these on debugging runs before inventing ad hoc logging:
`export NAME=value` / `NAME=value` lines from a local profile without
executing it, then forwards only allowlisted names. `--allow-env` is
repeatable and comma-separated. Profile values override ambient allowlisted
env values for that run. Direct POSIX, WSL2, and native Windows runs are
supported; delegated providers are not. Crabbox probes the uploaded profile
remotely and prints redacted presence/length metadata before the command.
- `--env-helper <name>`: with `--env-from-profile` on POSIX SSH targets,
persists `.crabbox/env/<name>` and `.crabbox/env/<name>.env` so follow-up
commands on the same lease can run through `./.crabbox/env/<name> <command>`.
Use only on leases you control; the profile stays until cleanup, lease reset,
or `--full-resync`.
env values for that run.
- `--script <file>` / `--script-stdin`: upload a local script into
`.crabbox/scripts/` and execute it on the remote box. Shebang scripts execute
directly on POSIX; scripts without a shebang run through `bash`. Native
Windows uploads run through Windows PowerShell, and Crabbox appends `.ps1`
when needed. Arguments after `--` become script args.
directly; scripts without a shebang run through `bash`. Arguments after `--`
become script args.
- `--fresh-pr owner/repo#123|URL|number`: skip dirty local sync and create a
fresh remote checkout of the GitHub PR. Bare numbers use the current repo's
GitHub origin. Add `--apply-local-patch` only when the current local
`git diff --binary HEAD` should be applied on top of that PR checkout.
- `--full-resync` / `--fresh-sync`: reset a stale direct-provider workdir
before syncing. Use after sync fingerprints look wrong, SSH times out before
sync, or rsync watchdog output suggests it. It is redundant with
`--fresh-pr`, incompatible with `--no-sync`, and unsupported by delegated
providers.
- `--capture-stdout <path>` / `--capture-stderr <path>`: write remote streams to
local files and keep binary/noisy output out of retained logs. Parent
directories must already exist. These are direct-provider only.
- `--capture-on-fail`: on non-zero direct-provider exits, downloads
`.crabbox/captures/*.tar.gz` with `test-results`, `playwright-report`,
`coverage`, JUnit XML, and nearby logs. Treat as secret-bearing until reviewed.
- `--keep-on-failure`: leave a failed one-shot lease alive for live debugging
until idle/TTL expiry. Useful on direct providers and delegated one-shots.
- `--timing-json`: final machine-readable timing. Add
`echo CRABBOX_PHASE:install`, `CRABBOX_PHASE:test`, etc. in long shell
commands; direct providers and Blacksmith Testbox both report them as
@@ -266,6 +180,7 @@ Live-provider debug template for direct AWS/Hetzner leases:
mkdir -p .crabbox/logs
pnpm crabbox:run -- --provider aws \
--preflight \
--env-from-profile ~/.profile \
--allow-env OPENAI_API_KEY,OPENAI_BASE_URL \
--timing-json \
--capture-stdout .crabbox/logs/live-provider.stdout.log \
@@ -276,10 +191,9 @@ pnpm crabbox:run -- --provider aws \
```
Do not pass `--capture-*`, `--download`, `--checksum`, `--force-sync-large`, or
`--sync-only` to delegated providers. Also do not pass `--script*`,
`--fresh-pr`, `--full-resync`, or `--env-helper` there. Crabbox rejects these
because the provider owns sync or command transport. `--keep-on-failure` is OK
for delegated one-shots when you need to inspect a failed lease.
`--sync-only` to delegated providers. Also do not pass `--script*` or
`--fresh-pr` there. Crabbox rejects these because the provider owns sync or
command transport.
## Efficient Bug E2E Verification
@@ -287,20 +201,13 @@ Use the smallest Crabbox lane that proves the reported user path, not just the
touched code. Aim for one after-fix E2E proof before commenting, closing, or
opening a PR for a user-visible bug.
When the user says "test in Crabbox", do not simply copy tests to the remote
box and run them there. Crabbox is for remote real-scenario proof: copy or
install OpenClaw as the user would, run the same setup/update/CLI/Gateway/API
call that failed, and capture behavior from that entrypoint. For regressions or
bug reports, prove the broken state first when feasible, then run the same
scenario after the fix.
Pick the lane by symptom:
- Docker/setup/install bug: build a package tarball and run the matching
`scripts/e2e/*-docker.sh` or package script. This proves npm packaging,
install paths, runtime deps, config writes, and container behavior.
- Provider/model/auth bug: prefer true live E2E. Use the configured secret
workflow, then inject the single needed key into Crabbox if needed. Scrub
- Provider/model/auth bug: prefer true live E2E. First source local Mac
`~/.profile`, then inject the single needed key into Crabbox if needed. Scrub
unrelated provider env vars in the child command so interactive defaults do
not drift to another provider. If only a dummy key is used, label the proof
narrowly, e.g. "UI/install path only; live provider auth not exercised."
@@ -315,9 +222,8 @@ Pick the lane by symptom:
Efficient flow:
1. Reproduce or prove the pre-fix symptom from the real user-facing entrypoint
when feasible. If the issue cannot be reproduced, capture the exact command
and observed behavior instead.
1. Reproduce or prove the pre-fix symptom when feasible. If the issue cannot be
reproduced, capture the exact command and observed behavior instead.
2. Patch locally and run narrow local tests for edit speed.
3. Run one Crabbox E2E command that starts from the user-facing entrypoint:
package install, Docker setup, onboarding, channel add, gateway start, or
@@ -335,8 +241,6 @@ Keep it efficient:
- Use `--fresh-pr <pr>` when validating an upstream PR in isolation from the
local dirty tree. Add `--apply-local-patch` only when testing a local fixup on
top of that PR.
- Use `--full-resync` before replacing a warmed direct-provider lease when the
remote workdir or sync fingerprint appears stale.
- Use one-shot Crabbox for a single proof; use a reusable Testbox only when
several commands must share built images, installed packages, or live state.
- Prefer `OPENCLAW_CURRENT_PACKAGE_TGZ` with Docker/package lanes when testing a
@@ -398,13 +302,13 @@ Interactive CLI/onboarding:
## Reuse And Keepalive
For most Crabbox calls, one-shot is enough. Use reuse only when you need
multiple manual commands on the same hydrated box.
For most Blacksmith-backed Crabbox calls, one-shot is enough. Use reuse only
when you need multiple manual commands on the same hydrated box.
If Crabbox returns a reusable id or you intentionally keep a lease:
```sh
pnpm crabbox:run -- --id <cbx_id-or-slug> --no-sync --timing-json --shell -- "pnpm test <path>"
pnpm crabbox:run -- --provider blacksmith-testbox --id <tbx_id> --no-sync --timing-json --shell -- "pnpm test <path>"
```
Stop boxes you created before handoff:
@@ -425,70 +329,35 @@ Common desktop flow:
```sh
../crabbox/bin/crabbox warmup --provider hetzner --desktop --browser --class standard --idle-timeout 60m --ttl 240m
../crabbox/bin/crabbox desktop launch --provider hetzner --id <cbx_id-or-slug> --browser --url https://example.com --webvnc --open --take-control
../crabbox/bin/crabbox desktop launch --provider hetzner --id <cbx_id-or-slug> --browser --url https://example.com --webvnc --open
```
Useful WebVNC commands:
```sh
../crabbox/bin/crabbox webvnc --provider hetzner --id <cbx_id-or-slug> --open --take-control
../crabbox/bin/crabbox webvnc daemon start --provider hetzner --id <cbx_id-or-slug> --open --take-control
../crabbox/bin/crabbox webvnc daemon status --provider hetzner --id <cbx_id-or-slug>
../crabbox/bin/crabbox webvnc daemon stop --provider hetzner --id <cbx_id-or-slug>
../crabbox/bin/crabbox webvnc status --provider hetzner --id <cbx_id-or-slug>
../crabbox/bin/crabbox webvnc reset --provider hetzner --id <cbx_id-or-slug> --open --take-control
../crabbox/bin/crabbox desktop doctor --provider hetzner --id <cbx_id-or-slug>
../crabbox/bin/crabbox desktop click --provider hetzner --id <cbx_id-or-slug> --x 640 --y 420
../crabbox/bin/crabbox desktop paste --provider hetzner --id <cbx_id-or-slug> --text "user@example.com"
../crabbox/bin/crabbox desktop key --provider hetzner --id <cbx_id-or-slug> ctrl+l
../crabbox/bin/crabbox artifacts collect --id <cbx_id-or-slug> --all --output artifacts/<slug>
../crabbox/bin/crabbox artifacts publish --dir artifacts/<slug> --pr <number>
../crabbox/bin/crabbox webvnc --provider hetzner --id <cbx_id-or-slug> --open
../crabbox/bin/crabbox webvnc --provider hetzner --id <cbx_id-or-slug> --daemon --open
../crabbox/bin/crabbox webvnc --provider hetzner --id <cbx_id-or-slug> --status
../crabbox/bin/crabbox webvnc --provider hetzner --id <cbx_id-or-slug> --stop
../crabbox/bin/crabbox screenshot --provider hetzner --id <cbx_id-or-slug> --output desktop.png
```
`desktop launch --webvnc --open` is usually the nicest one-shot: it starts the
browser/app inside the visible session, bridges the lease into the authenticated
WebVNC portal, and opens the portal. Keep browsers windowed for human QA; use
`--fullscreen` only for capture/video workflows.
For human handoff, include `--take-control` so the opened portal viewer gets
keyboard/mouse control automatically instead of landing as an observer.
Human handoff preflight:
- Do not assume a visible desktop or launched browser means the repo CLI/app is
installed, built, or on the interactive terminal's `PATH`.
- Before handing WebVNC to a human tester, prove the expected command from the
same kept lease and from a neutral directory such as `~`.
- If the handoff needs repo-local code, sync/build/link it explicitly on that
lease. Source-tree CLIs often need build output before a symlink works.
- Prefer a real `command -v <expected-command> && <expected-command> --version`
check over a repo-root-only `pnpm ...` command.
Generic handoff repair pattern:
```sh
../crabbox/bin/crabbox run --id <cbx_id-or-slug> --full-resync --shell -- \
"set -euo pipefail
pnpm install --frozen-lockfile
pnpm build
sudo ln -sf \"\$PWD/<cli-entry>\" /usr/local/bin/<expected-command>
cd ~
command -v <expected-command>
<expected-command> --version"
```
## If Crabbox Fails
Keep the fallback narrow. First decide whether the failure is Crabbox itself,
the brokered AWS lease, Blacksmith/Testbox, repo hydration, sync, or the test
command.
Blacksmith/Testbox, repo hydration, sync, or the test command.
Fast checks:
```sh
command -v crabbox
../crabbox/bin/crabbox --version
pnpm crabbox:run -- --help | sed -n '1,140p'
../crabbox/bin/crabbox doctor
crabbox run --provider blacksmith-testbox --help | sed -n '1,140p'
command -v blacksmith
blacksmith --version
blacksmith testbox list
@@ -498,36 +367,34 @@ Common Crabbox-only failures:
- Provider missing or old CLI: use `../crabbox/bin/crabbox` from the sibling
repo, or update/install Crabbox before retrying.
- Bad local config: inspect `.crabbox.yaml`, `crabbox config show`, and
`crabbox whoami`; normal OpenClaw proof should use brokered AWS without
asking for cloud keys.
- Slug/claim confusion: use the raw `cbx_...` / `tbx_...` id, or run one-shot
without `--id`.
- Bad local config: pass `--provider blacksmith-testbox` plus explicit
`--blacksmith-*` flags instead of relying on `.crabbox.yaml`.
- Slug/claim confusion: use the raw `tbx_...` id, or run one-shot without
`--id`.
- Sync/timing bug: add `--debug --timing-json`; capture the final JSON and the
printed Actions URL. Large sync warnings now include top source directories
by file count and a hint to update `.crabboxignore` / `sync.exclude`; inspect
those before reaching for `--force-sync-large`. Quiet rsync watchdogs and SSH
timeouts now print `next_action=` hints; follow them, usually `--full-resync`
first and a fresh lease second.
- Cleanup uncertainty: run `crabbox list --provider aws`; for explicit
Blacksmith runs, use `blacksmith testbox list` and stop only boxes you
those before reaching for `--force-sync-large`.
- Cleanup uncertainty: run `blacksmith testbox list` and stop only boxes you
created.
- Testbox queued/capacity pressure: do not retry Blacksmith repeatedly. Rerun
once without `--provider` so `.crabbox.yaml` routes to brokered AWS, or report
the Blacksmith blocker if Testbox itself is the requested proof.
- Testbox queued/capacity pressure: do not convert a broad changed gate or full
suite into local `OPENCLAW_LOCAL_CHECK_MODE=throttled pnpm ...`. Leave the
remote lane queued, switch to a narrower targeted local check, or stop and
report the capacity blocker.
If brokered AWS cannot dispatch, sync, attach, or stop, retry once with
`--debug` and `--timing-json`:
If Crabbox cannot dispatch, sync, attach, or stop but Blacksmith itself works,
first try the same command through the repo wrapper with `--debug` and
`--timing-json`:
```sh
pnpm crabbox:run -- --debug --timing-json -- \
pnpm crabbox:run -- --provider blacksmith-testbox --debug --timing-json -- \
CI=1 NODE_OPTIONS=--max-old-space-size=4096 OPENCLAW_TEST_PROJECTS_PARALLEL=6 OPENCLAW_VITEST_MAX_WORKERS=1 OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS=900000 pnpm test:changed
```
Full suite:
```sh
pnpm crabbox:run -- --debug --timing-json -- \
pnpm crabbox:run -- --provider blacksmith-testbox --debug --timing-json -- \
CI=1 NODE_OPTIONS=--max-old-space-size=4096 OPENCLAW_TEST_PROJECTS_PARALLEL=6 OPENCLAW_VITEST_MAX_WORKERS=1 OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS=900000 pnpm test
```
@@ -546,10 +413,9 @@ Raw Blacksmith footguns:
- Treat `blacksmith testbox list` as cleanup diagnostics, not a shared reusable
queue.
Use Blacksmith only when the task is specifically about Testbox, brokered AWS
is unavailable, or an explicit comparison is needed. If Blacksmith is down or
quota-limited, do not keep probing it; stay on brokered AWS and note the
delegated-provider outage.
Escalate to owned AWS/Hetzner only when Blacksmith is down, quota-limited,
missing the needed environment, or owned capacity is the explicit goal. Use the
Owned Cloud Fallback section below.
## Blacksmith Backend Notes
@@ -585,14 +451,13 @@ Important Blacksmith footguns:
blacksmith auth login --non-interactive --organization openclaw
```
## Brokered AWS
## Owned Cloud Fallback
Use AWS for normal OpenClaw remote proof. The repo `.crabbox.yaml` already
selects brokered AWS, so omit `--provider` unless you are testing a different
provider deliberately.
Use AWS/Hetzner only when Blacksmith is down, quota-limited, missing the needed
environment, or owned capacity is explicitly the goal.
```sh
pnpm crabbox:warmup -- --class beast --market on-demand --idle-timeout 90m
pnpm crabbox:warmup -- --provider aws --class beast --market on-demand --idle-timeout 90m
pnpm crabbox:hydrate -- --id <cbx_id-or-slug>
pnpm crabbox:run -- --id <cbx_id-or-slug> --timing-json --shell -- "env NODE_OPTIONS=--max-old-space-size=4096 OPENCLAW_TEST_PROJECTS_PARALLEL=6 OPENCLAW_VITEST_MAX_WORKERS=1 OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS=900000 pnpm test:changed"
pnpm crabbox:stop -- <cbx_id-or-slug>
@@ -616,8 +481,8 @@ crabbox whoami
- If broker auth is missing, run `crabbox login --url https://crabbox.openclaw.ai --provider aws`.
- If the CLI asks for `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, or AWS
profile setup during normal OpenClaw validation, assume the agent selected
the wrong path. Use brokered `crabbox login` or an existing brokered lease
before asking the user for cloud credentials.
the wrong path. Use brokered `crabbox login`, `--provider blacksmith-testbox`,
or an existing brokered lease before asking the user for cloud credentials.
- Ask for AWS keys only for explicit direct-provider/account administration,
not for normal brokered OpenClaw proof.
- Trusted automation may still use
@@ -630,7 +495,8 @@ macOS config lives at:
```
It should include `broker.url`, `broker.token`, and usually `provider: aws`
for OpenClaw lanes. Let that config drive normal validation.
for owned-cloud lanes. Do not let that config override the OpenClaw default
when Blacksmith proof is requested; pass `--provider blacksmith-testbox`.
### Interactive Desktop / WebVNC
@@ -650,10 +516,7 @@ crabbox run --id <lease> --shell -- 'DISPLAY=:99 xdotool search --onlyvisible --
crabbox status --id <id-or-slug> --wait
crabbox inspect --id <id-or-slug> --json
crabbox sync-plan
crabbox history --limit 20
crabbox history --lease <id-or-slug>
crabbox attach <run_id>
crabbox events <run_id> --json
crabbox logs <run_id>
crabbox results <run_id>
crabbox cache stats --id <id-or-slug>
@@ -668,15 +531,14 @@ Use `--market spot|on-demand` only on AWS warmup/one-shot runs.
## Failure Triage
- Crabbox cannot find provider: verify `../crabbox/bin/crabbox --help` lists
the provider selected by `.crabbox.yaml`; update Crabbox before falling back.
`blacksmith-testbox`; update Crabbox before falling back.
- Hydration stuck or failed: open the printed GitHub Actions run URL and inspect
the hydration step.
- Sync failed: rerun with `--debug`; check changed-file count and whether the
checkout is dirty.
- Command failed: rerun only the failing shard/file first. Do not rerun a full
suite until the focused failure is understood.
- Cleanup uncertain: `crabbox list --provider aws`; for explicit Blacksmith
runs, use `blacksmith testbox list` and stop owned `tbx_...` leases you
- Cleanup uncertain: `blacksmith testbox list`; stop owned `tbx_...` leases you
created.
- Crabbox broken but Blacksmith works: use the direct Blacksmith fallback above,
then file/fix the Crabbox issue.

View File

@@ -1,44 +0,0 @@
---
name: discrawl
description: "Discord archive: search, sync freshness, DMs, channel slices, SQL counts, and Discrawl repo work."
metadata:
openclaw:
homepage: https://github.com/openclaw/discrawl
requires:
bins:
- discrawl
install:
- kind: go
module: github.com/openclaw/discrawl/cmd/discrawl@latest
bins:
- discrawl
---
# Discrawl
Use local Discord archive data before live Discord APIs. Check freshness for recent/current questions:
```bash
discrawl status --json
discrawl doctor
```
Refresh only when stale or asked:
```bash
discrawl sync --source wiretap
discrawl sync
```
Query with bounded slices:
```bash
DISCRAWL_NO_AUTO_UPDATE=1 discrawl search --limit 20 "query"
discrawl messages --channel '#maintainers' --days 7 --all
discrawl dms --last 20
DISCRAWL_NO_AUTO_UPDATE=1 discrawl --json sql "select count(*) from messages;"
```
Report absolute date spans, channel/DM names, counts, and known gaps. Use read-only SQL for exact counts/rankings. Never use `--unsafe --confirm` unless the user explicitly requests a reviewed DB mutation.
Boundaries: bot sync needs configured Discord bot credentials. Wiretap reads local Discord Desktop artifacts only; do not extract user tokens, call Discord as the user, or write to Discord storage. Git-share snapshots must not include secrets or `@me` DM rows.

View File

@@ -1,4 +0,0 @@
interface:
display_name: "Discrawl"
short_description: "Search local Discord archives and freshness"
default_prompt: "Use $discrawl to search local Discord archives, check freshness, inspect DMs or channel slices, and report exact date spans and source gaps."

View File

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

View File

@@ -1,44 +0,0 @@
---
name: graincrawl
description: "Granola archive: search, sync freshness, notes, transcripts, panels, SQL counts, and Graincrawl repo work."
metadata:
openclaw:
homepage: https://github.com/openclaw/graincrawl
requires:
bins:
- graincrawl
install:
- kind: go
module: github.com/vincentkoc/graincrawl/cmd/graincrawl@latest
bins:
- graincrawl
---
# Graincrawl
Use local Granola archive data first. Check freshness for recent/current questions:
```bash
graincrawl doctor --json
graincrawl status --json
```
Refresh only when stale or asked:
```bash
graincrawl sync --source private-api
graincrawl sync --source desktop-cache
```
Query with bounded reads:
```bash
graincrawl search "query"
graincrawl notes --json
graincrawl note get <id>
graincrawl transcripts get <id>
graincrawl panels get <id>
graincrawl --json sql "select count(*) as notes from notes;"
```
Report absolute date spans, note titles, source gaps, and transcript/panel availability. Use read-only SQL for exact counts/rankings. Before encrypted source debugging, run explicit unlock/secrets checks; do not surprise-prompt Keychain.

View File

@@ -1,4 +0,0 @@
interface:
display_name: "Graincrawl"
short_description: "Search local Granola notes and transcripts"
default_prompt: "Use $graincrawl to search local Granola notes, transcripts, and panels, check freshness, and report exact date spans and source gaps."

View File

@@ -1,42 +0,0 @@
---
name: notcrawl
description: "Notion archive: search, sync freshness, pages/databases, Markdown exports, SQL counts, and Notcrawl repo work."
metadata:
openclaw:
homepage: https://github.com/openclaw/notcrawl
requires:
bins:
- notcrawl
install:
- kind: go
module: github.com/vincentkoc/notcrawl/cmd/notcrawl@latest
bins:
- notcrawl
---
# Notcrawl
Use local Notion archive data before browsing or live Notion API calls. Check freshness for recent/current questions:
```bash
notcrawl doctor
notcrawl status --json
```
Refresh only when stale or asked:
```bash
notcrawl sync --source desktop
notcrawl sync --source api
```
Query with bounded reads:
```bash
notcrawl search "query"
notcrawl databases
notcrawl report
notcrawl sql "select count(*) from pages;"
```
Report workspace/teamspace, page/database titles, absolute date spans, counts, and known gaps. Use read-only SQL only; never mutate the archive. API mode requires `NOTION_TOKEN`; do not assume token availability.

View File

@@ -1,4 +0,0 @@
interface:
display_name: "Notcrawl"
short_description: "Search local Notion archives and freshness"
default_prompt: "Use $notcrawl to search local Notion pages and databases, check freshness, inspect exports, and report exact date spans and source gaps."

View File

@@ -73,9 +73,8 @@ openclaw logs --follow
tool execution.
- **Worker/dist:** run `pnpm build` when touching workers, dynamic imports,
package exports, lazy runtime boundaries, or published paths.
- **Live keys:** use the configured secret workflow for missing provider keys
before saying live proof is blocked. Env checks are presence-only; never print
secrets.
- **Live keys:** check local `~/.profile` for key presence/length before saying
live proof is blocked. Never print secrets.
## Code Pointers

View File

@@ -1,64 +0,0 @@
---
name: openclaw-docker-e2e-authoring
description: "Author OpenClaw Docker E2E and live provider Docker lanes."
---
# OpenClaw Docker E2E Authoring
Use this when adding or changing Docker E2E lanes, release-path Docker tests,
or live-provider Docker proof.
## Lane Choice
- Deterministic Docker: fake the dependency/server and assert the exact runtime
contract crossing the boundary.
- Live Docker: use real provider credentials/model only when user-visible
behavior needs the real service.
- Prefer both when they prove different risks: deterministic for byte/payload
routing, live for actual provider behavior.
## Authoring Rules
- Test-only helpers live in `test/helpers` or `scripts/e2e/lib/<lane>/`, not
`src/**`, unless production imports them.
- Package-installed app runs from `/app`; mount only explicit harness/helper
paths read-only.
- Fake servers should log boundary requests as JSONL and clients should assert
the real dependency payload, not just process success.
- Add the package script and `scripts/lib/docker-e2e-scenarios.mjs` lane in the
same change.
- If a lane installs a plugin from npm, default the spec via env so published
and local override paths are both testable.
## Media And Vision
- Expected answer must exist only in pixels or provider output being tested.
- Use neutral filenames, neutral prompts, and no metadata leaks.
- Random bitmap/OCR tokens reuse the repo OCR-safe alphabet `24567ACEF` unless
the test owns a stronger glyph set.
- Make the expected answer unique per run when proving real image
understanding.
## `chat.send` E2E
- Require `chat.send` to return `status: "started"` and a string `runId`.
- Wait for completion with `agent.wait`.
- Assert final user-visible text via `chat.history` when event ordering is not
the behavior under test.
- Keep originating channel/account metadata only when the bug path needs queued
inbound/channel context.
## Verification
Run the smallest proof that covers the touched lane:
```bash
pnpm exec oxfmt --write <changed files>
node --check <new .mjs files>
bash -n <new .sh files>
node scripts/run-vitest.mjs test/scripts/docker-e2e-plan.test.ts
OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:<lane>
```
For real-provider lanes, run the matching live Docker script after deterministic
Docker is green. Finish with `$autoreview` before commit/PR.

View File

@@ -42,20 +42,16 @@ Choose the page type before writing:
Use this default topic page structure:
1. Title: name the major entity or surface.
2. Opening overview: start with a few unheaded sentences that explain what it
is, what it owns, and what it does not own. Do not add a `## Overview`
heading unless the page is itself an overview index.
2. Overview: explain what it is, what it owns, and what it does not own.
3. Requirements: include only when setup needs specific accounts, versions,
permissions, plugins, operating systems, or credentials.
4. Quickstart: show the recommended setup path and smallest reliable verification.
5. Configuration: show the minimum configuration needed to use the surface,
common variants users must choose between, and where each option is set:
CLI, config file, environment variable, plugin manifest, dashboard, or API.
6. Major subtopics: organize the entity's major concepts, workflows, and
decisions by reader intent. Put each major subtopic under its own heading;
do not wrap them in a generic `## Subtopics` section.
7. Troubleshooting: diagnose common observable failures under an explicit
`## Troubleshooting` heading.
6. Subtopics: organize the entity's major concepts, workflows, and decisions by
reader intent.
7. Troubleshooting: diagnose common observable failures.
8. Related: link to guides, references, commands, concepts, and adjacent topics.
Topic pages may be longer than quickstarts, but they should not become exhaustive

View File

@@ -1,95 +0,0 @@
---
name: openclaw-mac-release
description: "Run or recover OpenClaw macOS release signing, notarization, appcast, and asset promotion."
---
# OpenClaw Mac Release
Use with `$openclaw-release-maintainer`, `$openclaw-release-ci`, and `$one-password` when stable macOS assets, private mac preflight, notarization, appcast promotion, or mac release recovery is involved.
## Credentials
- Canonical ASC item: vault `Molty`, title `API Key - App Store Connect - Personal - Release`.
- Fields: `private_key_p8`, `key_id`, `issuer_id`.
- Current known good key id: `AKVLXW849T`.
- Legacy mirror: vault `Private`, title `API Key - App Store Connect - Personal`; keep it synced for older refs.
- Stale/revoked key symptom: `xcrun notarytool submit` fails with `HTTP status code: 401. Unauthenticated`.
- Validate candidate ASC credentials with `xcrun notarytool history` before setting GitHub secrets.
## 1Password
- Use `$one-password`: all `op` work inside one persistent tmux session, no secret output.
- Prefer `OP_SERVICE_ACCOUNT_TOKEN` from `~/.profile` for Molty reads.
- Do not assume `MOLTY_OP_SERVICE_ACCOUNT_TOKEN` is alive; it has previously pointed at a deleted service account.
- If a service token fails, run status-only checks: token present/length and `op whoami`; never print token values.
- If desktop app auth is needed but Touch ID is unavailable, set `OP_BIOMETRIC_UNLOCK_ENABLED=false` for the manual `op account add --signin` path.
## GitHub Secrets
Target private repo environment: `openclaw/releases-private`, env `mac-release`.
Set only after local notary auth validation:
- `APP_STORE_CONNECT_API_KEY_P8`
- `APP_STORE_CONNECT_KEY_ID`
- `APP_STORE_CONNECT_ISSUER_ID`
Do not update these from mixed sources. All three ASC fields must come from the same 1Password item.
## Workflow Shape
- Public release branch may carry mac-only packaging fixes after the stable tag/npm are already live.
- Use `source_ref=release/YYYY.M.D` for private mac preflight/validation when building that branch variation.
- Keep `tag=vYYYY.M.D` pointing at the original stable release commit.
- Real mac publish must reuse:
- a successful private mac preflight run for the same tag/source SHA
- a successful private mac validation run for the same tag/source SHA
- If preflight source SHA differs from tag SHA, validation must also use the same `source_ref`; promotion rejects mismatched proof.
## Notarization
- OpenClaw uses `scripts/notarize-mac-artifact.sh`.
- `xcrun notarytool submit` should use `--no-s3-acceleration`; accelerated upload can surface misleading 401s even when `notarytool history` succeeds.
- If signing succeeds but notarization fails immediately with 401, check ASC key freshness first.
- If notarization stays in progress for several minutes after key-file write, that is normal Apple wait time; do not edit blindly.
## Dispatch
Private preflight:
```bash
gh workflow run openclaw-macos-publish.yml --repo openclaw/releases-private --ref main \
-f tag=vYYYY.M.D \
-f source_ref=release/YYYY.M.D \
-f preflight_only=true \
-f smoke_test_only=false \
-f allow_late_calver_recovery=false \
-f public_release_branch=release/YYYY.M.D
```
Private validation for a branch-variation preflight:
```bash
gh workflow run openclaw-macos-validate.yml --repo openclaw/releases-private --ref main \
-f tag=vYYYY.M.D \
-f source_ref=release/YYYY.M.D
```
Real publish:
```bash
gh workflow run openclaw-macos-publish.yml --repo openclaw/releases-private --ref main \
-f tag=vYYYY.M.D \
-f preflight_only=false \
-f smoke_test_only=false \
-f preflight_run_id=<successful-preflight-run> \
-f validate_run_id=<successful-validation-run> \
-f allow_late_calver_recovery=false \
-f public_release_branch=release/YYYY.M.D
```
## Verify
- `gh release view vYYYY.M.D --repo openclaw/openclaw` shows zip, dmg, dSYM zip, not draft, not prerelease.
- Public `main` `appcast.xml` points at `OpenClaw-YYYY.M.D.zip`.
- Appcast entry has `sparkle:version`, `sparkle:shortVersionString`, length, and `sparkle:edSignature`.

View File

@@ -56,7 +56,7 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
- For unpublished targets, pack the candidate on the host, serve the `.tgz` over the harness HTTP server, and point the guest updater at that served package. Prefer `openclaw update --tag http://<host-ip>:<port>/openclaw-<version>.tgz --yes --json`; when channel persistence also matters, pass `--channel <stable|beta>` and set `OPENCLAW_UPDATE_PACKAGE_SPEC` to the same served URL in the guest update environment. The command under test must still be `openclaw update`, not direct npm.
- For unpublished local-fix validation, remember the old baseline updater code still controls the first hop. A fix that lives only in the new updater code cannot change that already-running old process; the served candidate must either keep package/plugin metadata compatible with the baseline host or the baseline itself must include the updater fix.
- For beta/stable verification, resolve the tag immediately before the run (`npm view openclaw@beta version dist.tarball` or `npm view openclaw@latest ...`). Tags can move while a long VM matrix is already running; restart the matrix when the intended prerelease appears after an earlier registry 404/tag-lag check.
- Use the configured secret workflow to inject only the provider keys needed by OpenAI/Anthropic lanes. Do not print secrets or env dumps; pass provider secrets through the guest exec environment.
- Source Peter's profile in the host shell (`set -a; source "$HOME/.profile"; set +a`) before OpenAI/Anthropic lanes. Do not print profile contents or env dumps; pass provider secrets through the guest exec environment.
- Same-guest update verification should set the default model explicitly to `openai/gpt-5.4` before the agent turn and use a fresh explicit `--session-id` so old session model state does not leak into the check.
- The aggregate npm-update wrapper must resolve the Linux VM with the same Ubuntu fallback policy as `parallels-linux-smoke.sh` before both fresh and update lanes. Treat any Ubuntu guest with major version `>= 24` as acceptable when the exact default VM is missing, preferring the closest version match. On Peter's current host today, missing `Ubuntu 24.04.3 ARM64` should fall back to `Ubuntu 25.10`.
- On macOS same-guest update checks, restart the gateway after the npm upgrade before `gateway status` / `agent`; launchd can otherwise report a loaded service while the old process has exited and the fresh process is not RPC-ready yet.

View File

@@ -24,36 +24,6 @@ gitcrawl search openclaw/openclaw --query "<scope or title keywords>" --mode hyb
gitcrawl cluster-detail openclaw/openclaw --id <cluster-id> --member-limit 20 --body-chars 280 --json
```
## Claim specific review targets
When a maintainer asks Codex to review, triage, fix, or land a specific OpenClaw issue/PR, check assignment before deep work.
- Identify the requesting maintainer's GitHub login. In this environment, default Peter to `steipete`; if another maintainer is clearly the requester, use that maintainer's bare login.
- Read current assignees with live `gh issue view` / `gh pr view`; `gitcrawl` is not enough for assignment state.
- If unassigned, assign the requester before deep review. This is allowed for specific requested targets; do not auto-assign broad discovery candidates or shortlists.
- If assigned to someone else, say so clearly before analysis and include assignment age:
- fresh: assigned within 6h; treat as actively owned unless user explicitly asks to continue or reassign
- stale: assigned 6h+ ago; treat as ownership hint, not a hard block; continue only with that caveat
- If assigned to requester plus others, mention co-assignees and continue.
- If assignment event time is unavailable, say `assigned, time unknown`; treat as assigned, not stale.
- Never remove or replace assignees unless explicitly asked.
Assignment time proof:
```bash
gh api "repos/openclaw/openclaw/issues/<number>/timeline" --paginate \
-H "Accept: application/vnd.github+json" \
--jq '[.[] | select(.event=="assigned") | {assignee:.assignee.login, assigner:.assigner.login, actor:.actor.login, created_at}]'
```
Use the newest `assigned` event for each current assignee. Issue timeline events expose `created_at`; GitHub GraphQL `AssignedEvent.createdAt` is also valid when REST pagination is awkward.
Claim command for issues or PRs:
```bash
gh api -X POST "repos/openclaw/openclaw/issues/<number>/assignees" -f 'assignees[]=<login>' >/dev/null
```
## Surface opener identity
- For every reviewed, triaged, closed, or landed issue/PR, show the opener's human name when available, GitHub login, and account age.
@@ -168,9 +138,7 @@ Output only qualifying candidates, with: ref, surface, proof, cause, fix sketch,
- Start every PR review with 1-3 plain sentences explaining what the change does and why it matters. Put this before `Findings`.
- Then list findings first. If none, say `No blocking findings` or `No findings`.
- Always answer: bug/behavior being fixed, PR/issue URL and affected surface, provenance for regressions when traceable, and best-fix verdict.
- For bug/regression fixes, include a compact `Provenance:` line after cause/root-cause when a bounded history pass can identify it. Use `git log -S/-G`, `git blame`, linked PRs/issues, and tests; separate author, committer/merger, and current PR author when they differ.
- Phrase provenance as `introduced by`, `made visible by`, or `carried forward by`, with confidence (`clear`, `likely`, `unknown`). If unclear, say what evidence is missing instead of guessing. For features, docs, and refactors, use `Provenance: N/A` or omit it when no broken behavior is being fixed.
- Always answer: bug/behavior being fixed, PR/issue URL and affected surface, and best-fix verdict.
- Keep summaries compact, but include enough proof that the verdict is auditable without rereading the PR.
## Read beyond the diff
@@ -192,9 +160,8 @@ Output only qualifying candidates, with: ref, surface, proof, cause, fix sketch,
- Before landing, require:
1. symptom evidence such as a repro, logs, or a failing test
2. a verified root cause in code with file/line
3. provenance for regressions when traceable by bounded git/PR history
4. a fix that touches the implicated code path
5. a regression test when feasible, or explicit manual verification plus a reason no test was added
3. a fix that touches the implicated code path
4. a regression test when feasible, or explicit manual verification plus a reason no test was added
- If the claim is unsubstantiated or likely wrong, request evidence or changes instead of merging.
- If the linked issue appears outdated or incorrect, correct triage first. Do not merge a speculative fix.
- If Crabbox/E2E proof is blocked, say exactly why and use the closest available
@@ -247,7 +214,6 @@ gh search issues --repo openclaw/openclaw --match title,body --limit 50 \
not correctness findings.
- If bot review conversations exist on your PR, address them and resolve them yourself once fixed.
- Leave a review conversation unresolved only when reviewer or maintainer judgment is still needed.
- Before landing any PR with non-trivial code changes, run `$autoreview` until no accepted/actionable findings remain, unless equivalent manual review already covered it, the change is trivial/docs-only, or the user opts out.
- When landing or merging any PR, follow the global `/landpr` process.
- Use `scripts/committer "<msg>" <file...>` for scoped commits instead of manual `git add` and `git commit`.
- Keep commit messages concise and action-oriented.

View File

@@ -227,9 +227,7 @@ pnpm openclaw qa manual \
- Treat the concrete Codex model name as user/config input; do not hardcode it in source, docs examples, or scenarios.
- Live QA preserves `CODEX_HOME` so Codex CLI auth/config works while keeping `HOME` and `OPENCLAW_HOME` sandboxed.
- Mock QA should scrub `CODEX_HOME`.
- If Codex returns fallback/auth text every turn, first check `CODEX_HOME`,
relevant secret-backed auth, and gateway child logs before changing
scenario assertions.
- If Codex returns fallback/auth text every turn, first check `CODEX_HOME`, `~/.profile`, and gateway child logs before changing scenario assertions.
- For model comparison, include `codex-cli/<codex-model>` as another candidate in `qa character-eval`; the report should label it as an opaque model name.
## Repo facts

View File

@@ -1,90 +0,0 @@
---
name: openclaw-release-ci
description: "Run, watch, debug, and summarize OpenClaw full release CI, release checks, live provider gates, install/update proofs, and release-secret preflights."
---
# OpenClaw Release CI
Use this with `$openclaw-release-maintainer` and `$openclaw-testing` when a release candidate needs full validation, install/update proof, live provider checks, or CI recovery.
## Guardrails
- No version bump, tag, npm publish, GitHub release, or release promotion without explicit operator approval.
- Validate provider secrets before dispatching expensive full release matrices.
- Do not set GitHub secrets from unvalidated 1Password candidates. If a candidate returns 401/403, leave the existing secret alone and report the exact missing provider.
- Use `$one-password` for secret reads/writes: one persistent tmux session, targeted items only, no secret output.
- Watch one parent run plus compact child summaries. Avoid broad `gh run view` polling loops; REST quota is easy to burn.
- Fetch logs only for failed or currently-blocking jobs. If quota is low, stop polling and wait for reset.
- Treat live-provider flakes separately from code failures: prove key validity, provider HTTP status, retry evidence, and exact failing lane before editing code.
## Preflight
Before full release validation:
```bash
node .agents/skills/openclaw-release-ci/scripts/verify-provider-secrets.mjs --required openai,anthropic,fireworks
gh api rate_limit --jq '.resources.core'
git status --short --branch
git rev-parse HEAD
```
If env lacks keys, use `$one-password` to inject or set them, then rerun the script. The script prints only provider status and HTTP class, never tokens.
## Dispatch
Prefer the trusted workflow on `main`, target the exact release SHA:
```bash
gh workflow run full-release-validation.yml \
--repo openclaw/openclaw \
--ref main \
-f ref=<release-sha> \
-f provider=openai \
-f mode=both \
-f release_profile=full \
-f rerun_group=all
```
Use `release_profile=stable` unless the operator explicitly asks for the broad advisory provider/media matrix. Use narrow `rerun_group` after focused fixes.
## Watch
Use the summary helper instead of repeated raw polling:
```bash
node .agents/skills/openclaw-release-ci/scripts/release-ci-summary.mjs <full-release-run-id>
```
Then watch only when useful:
```bash
gh run watch <full-release-run-id> --repo openclaw/openclaw --exit-status
```
Stop watchers before ending the turn or switching strategy.
## Failure Triage
1. Confirm parent SHA and child run IDs.
2. List failed jobs only:
```bash
gh run view <child-run-id> --repo openclaw/openclaw --json jobs \
--jq '.jobs[] | select(.conclusion=="failure" or .conclusion=="timed_out" or .conclusion=="cancelled") | [.databaseId,.name,.conclusion,.url] | @tsv'
```
3. Fetch one failed job log. If rate-limited, note reset time and avoid more REST calls.
4. For secret-looking failures, validate the provider endpoint from the same secret source before editing code.
5. For live-cache failures, inspect whether it is missing/invalid key, empty text, provider refusal, timeout, or baseline miss. Do not weaken release gates without clear provider evidence.
6. Fix narrowly, run local/changed proof, commit, push, rerun the smallest matching group.
## Evidence
Record:
- release SHA
- full parent run URL
- child run IDs and conclusions: CI, Release Checks, Plugin Prerelease, NPM Telegram
- targeted local proof commands
- provider-secret preflight result
- known gaps or unrelated failures
For lessons and recovery patterns, read `references/release-ci-notes.md`.

View File

@@ -1,4 +0,0 @@
interface:
display_name: "OpenClaw Release CI"
short_description: "Verify and debug OpenClaw release validation runs"
default_prompt: "Use $openclaw-release-ci to preflight provider secrets, watch full release validation, summarize child runs, and triage only failing release lanes."

View File

@@ -1,41 +0,0 @@
# Release CI Notes
## What Went Wrong
- Full validation was started before all provider keys were proven valid.
- GitHub secret presence was confused with key validity.
- Repeated `gh run view` and log fetches exhausted REST quota.
- Parent run state was less useful than child run evidence.
- Live-cache failures needed structured classification: invalid key, empty provider output, timeout, or real cache regression.
- Background watchers accumulated and made interruption recovery harder.
## Better Defaults
- Run provider-secret preflight first. Require real `/models` or equivalent endpoint checks for release-blocking providers.
- Keep one watcher open. Use child summaries every few minutes, not every few seconds.
- Fetch failed-job logs only after a job reaches a terminal failing state.
- Prefer narrow `rerun_group` recovery after a focused fix.
- Leave bad secrets unset. A 401 candidate from 1Password should not overwrite GitHub.
- Make the final release evidence note durable: parent URL, child run URLs, SHA, command proof, and gaps.
## Secret Handling Pattern
- Use `$one-password`; never run broad env dumps.
- Search exact item titles or known ids.
- Validate candidates without printing values.
- Set GitHub secrets only after endpoint validation succeeds.
- After setting, verify metadata with `gh secret list`, not value output.
## Live Cache Pattern
- Empty text with token usage is a provider/output issue until proven otherwise.
- Retry lane-level mismatches once with a fresh session id.
- Keep cache baselines strict, but log enough structured usage to distinguish cache miss from response mismatch.
- If a provider key validates locally but fails in Actions, inspect whether the workflow reads the expected secret name.
## Quota-Safe GitHub Pattern
- Check `gh api rate_limit --jq '.resources.core'` before log-heavy work.
- Use one child-run listing call, then inspect failed jobs only.
- If remaining quota is low, pause until reset; do not keep polling.
- Prefer GraphQL only for metadata when REST is exhausted; logs still need REST.

View File

@@ -1,79 +0,0 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import process from "node:process";
const runId = process.argv[2];
const repo = process.env.OPENCLAW_RELEASE_REPO || "openclaw/openclaw";
if (!runId) {
console.error("usage: release-ci-summary.mjs <full-release-run-id>");
process.exit(2);
}
function gh(args) {
return execFileSync("gh", args, {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
}
function jsonGh(args) {
return JSON.parse(gh(args));
}
function rate() {
try {
return jsonGh(["api", "rate_limit"]).resources.core;
} catch {
return undefined;
}
}
const core = rate();
if (core) {
const reset = new Date(core.reset * 1000).toISOString();
console.log(`rate: remaining=${core.remaining}/${core.limit} reset=${reset}`);
if (core.remaining < 20) {
console.error("rate too low for CI summary; wait for reset before polling");
process.exit(3);
}
}
const parent = jsonGh([
"run",
"view",
runId,
"--repo",
repo,
"--json",
"status,conclusion,createdAt,headSha,url,jobs",
]);
console.log(`parent: ${runId} ${parent.status}/${parent.conclusion || "none"}`);
console.log(`sha: ${parent.headSha}`);
console.log(`url: ${parent.url}`);
for (const job of parent.jobs ?? []) {
const marker = job.conclusion || job.status;
console.log(`parent-job: ${marker} ${job.name}`);
}
const since = parent.createdAt;
const runList = gh([
"api",
`repos/${repo}/actions/runs?per_page=100`,
"--jq",
`.workflow_runs[] | select(.created_at >= "${since}") | select(.name=="CI" or .name=="OpenClaw Release Checks" or .name=="Plugin Prerelease" or .name=="NPM Telegram Beta E2E" or .name=="Full Release Validation") | [.id,.name,.status,.conclusion,.head_sha,.html_url] | @tsv`,
]).trim();
if (!runList) {
console.log("children: none found yet");
process.exit(0);
}
console.log("children:");
for (const line of runList.split("\n")) {
const [id, name, status, conclusion, sha, url] = line.split("\t");
console.log(`child: ${id} ${name} ${status}/${conclusion || "none"} sha=${sha}`);
console.log(`child-url: ${url}`);
}

View File

@@ -1,113 +0,0 @@
#!/usr/bin/env node
import process from "node:process";
const args = new Map();
for (let index = 2; index < process.argv.length; index += 1) {
const arg = process.argv[index];
if (!arg.startsWith("--")) continue;
const [key, inlineValue] = arg.slice(2).split("=", 2);
const value = inlineValue ?? process.argv[index + 1];
if (inlineValue === undefined) index += 1;
args.set(key, value);
}
const requiredInput = String(args.get("required") ?? "openai,anthropic").trim();
const required = new Set(
(requiredInput.toLowerCase() === "none" ? "" : requiredInput)
.split(",")
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean),
);
const timeoutMs = Number(args.get("timeout-ms") ?? 10_000);
function envFirst(names) {
for (const name of names) {
const value = process.env[name]?.trim();
if (value) return { name, value };
}
return undefined;
}
async function checkProvider(id, config) {
const secret = envFirst(config.env);
if (!secret) {
return { id, ok: false, status: "missing", env: config.env.join("|") };
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const headers = config.headers(secret.value);
const response = await fetch(config.url, {
headers,
signal: controller.signal,
});
return {
id,
ok: response.ok,
status: response.ok ? "ok" : `http_${response.status}`,
env: secret.name,
};
} catch (error) {
return {
id,
ok: false,
status: error?.name === "AbortError" ? "timeout" : "error",
env: secret.name,
};
} finally {
clearTimeout(timer);
}
}
const providers = {
openai: {
env: ["OPENAI_API_KEY"],
url: "https://api.openai.com/v1/models",
headers: (token) => ({ authorization: `Bearer ${token}` }),
},
anthropic: {
env: ["ANTHROPIC_API_KEY", "ANTHROPIC_API_TOKEN"],
url: "https://api.anthropic.com/v1/models",
headers: (token) => ({
"anthropic-version": "2023-06-01",
"x-api-key": token,
}),
},
fireworks: {
env: ["FIREWORKS_API_KEY"],
url: "https://api.fireworks.ai/inference/v1/models",
headers: (token) => ({ authorization: `Bearer ${token}` }),
},
openrouter: {
env: ["OPENROUTER_API_KEY"],
url: "https://openrouter.ai/api/v1/models",
headers: (token) => ({ authorization: `Bearer ${token}` }),
},
};
const unknown = [...required].filter((id) => !providers[id]);
if (unknown.length > 0) {
console.error(`unknown providers: ${unknown.join(",")}`);
process.exit(2);
}
const results = [];
for (const id of Object.keys(providers)) {
if (required.has(id) || envFirst(providers[id].env)) {
results.push(await checkProvider(id, providers[id]));
}
}
let failed = false;
for (const result of results) {
const requiredLabel = required.has(result.id) ? "required" : "optional";
console.log(`${result.id}: ${result.status} env=${result.env} ${requiredLabel}`);
if (required.has(result.id) && !result.ok) failed = true;
}
if (failed) {
console.error("release provider secret preflight failed");
process.exit(1);
}

View File

@@ -65,8 +65,8 @@ Use this skill for release and publish-time workflow. Keep ordinary development
stable base version section, for example `v2026.4.20-beta.1` uses
`## 2026.4.20` release notes.
- When any beta or stable release is live, make a best-effort Discord
announcement using the configured secret workflow; do not block or roll back
the release if the announcement fails.
announcement using Peter's bot token from `.profile`; do not block or roll
back the release if the announcement fails.
- When asked to announce on X, use `~/Projects/bird/bird` and follow the
release tweet style below.
@@ -288,11 +288,13 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
## Check all relevant release builds
- Always validate the OpenClaw npm release path before creating the tag.
- Use the configured secret workflow before live release validation so OpenAI
and Anthropic credentials are available without printing secrets.
- Source Peter's profile before live release validation so OpenAI and Anthropic
credentials are available without printing secrets:
`set -a; source "$HOME/.profile"; set +a`.
- Parallels validation and any local live model QA for this train must use both
`OPENAI_API_KEY` and `ANTHROPIC_API_KEY`. If either cannot be injected, stop
before starting those local long lanes and report the missing key.
`OPENAI_API_KEY` and `ANTHROPIC_API_KEY`. If either is missing after sourcing
`.profile`, stop before starting those local long lanes and report the
missing key.
- Live credentialed channel QA is the GitHub Actions workflow
`QA-Lab - All Lanes` (`.github/workflows/qa-live-telegram-convex.yml`), not a
local substitute. Dispatch it from Actions against the release tag and wait
@@ -590,7 +592,8 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
If a pre-npm lane fails before any tag/package leaves the machine, fix and
rerun the same intended beta attempt. Repeat up to the operator's
authorized beta-attempt limit, normally 4.
24. Announce the beta/stable release on Discord best-effort using the configured secret workflow.
24. Announce the beta/stable release on Discord best-effort using Peter's bot
token from `.profile`.
25. If the operator requested beta only, stop after beta verification and the
announcement.
26. If the stable release was published to `beta`, use the light stable

View File

@@ -34,10 +34,10 @@ Supports single or multiple alerts. For multiple alerts, process in ascending or
For each alert:
1. **Identify**`fetch-alert` + `fetch-content` to get metadata and body
2. **Decide** — Agent reads the body file, identifies whether plaintext secrets remain, and produces a redacted version only when needed
3. **Redact**`redact-body-if-needed` for issue/PR body; skip for comments (delete directly)
2. **Decide** — Agent reads the body file, identifies all secrets, produces redacted version
3. **Redact**`redact-body` for issue/PR body; skip for comments (delete directly)
4. **Purge**`delete-comment` + `recreate-comment` for comments; cannot purge body history
5. **Notify**`notify` posts the right template per location type, unless the current issue/PR body is already redacted
5. **Notify**`notify` posts the right template per location type
6. **Resolve**`resolve` closes the alert
7. **Summary**`summary` prints formatted results
@@ -81,20 +81,11 @@ The `fetch-content` output includes:
The agent reads the body file from `fetch-content` output and:
1. Identifies ALL secrets in the content (there may be more than the alert flagged)
2. Determines whether any plaintext credential remains in the current body
3. Replaces each remaining secret with `[REDACTED <secret_type>]`**no partial values, no prefix/suffix**
4. Saves the redacted content to a new temp file
2. Replaces each secret with `[REDACTED <secret_type>]`**no partial values, no prefix/suffix**
3. Saves the redacted content to a new temp file
This is the only step that requires semantic understanding. Everything else is mechanical.
For `issue_body` and `pull_request_body`: if the current body has already been redacted by the author and no plaintext credential remains, **do not post a public notification comment**. Resolve the alert with a maintainer-only resolution comment such as:
```bash
node secret-scanning.mjs resolve <ALERT_NUMBER> revoked "Current issue/PR body is already redacted; no public notification posted."
```
This avoids creating a fresh public pointer to historical sensitive content.
## Step 3: Redact
### For comments (issue_comment / PR comments)
@@ -104,11 +95,9 @@ This avoids creating a fresh public pointer to historical sensitive content.
### For issue_body / pull_request_body
```bash
node secret-scanning.mjs redact-body-if-needed <issue|pr> <NUMBER> <current-body-file> <redacted-body-file> <result-file>
node secret-scanning.mjs redact-body <issue|pr> <NUMBER> <redacted-body-file>
```
Use the `body_file` from `fetch-content` as `<current-body-file>`. The command writes `notify_required` to `<result-file>` and only PATCHes the body when the redacted file differs from the current body.
## Step 4: Purge Edit History
### Comments — Delete and Recreate
@@ -145,12 +134,10 @@ The recreated comment should follow this format:
<redacted original content>
```
### issue_body / pull_request_body — Cannot Purge Edit History
### issue_body / pull_request_body — Cannot Purge
Editing creates an edit history revision with the pre-edit plaintext. This cannot be cleared via API.
Do not advise authors publicly to delete/recreate issues or close/reopen PRs. That can draw attention to historical content. Keep purge guidance maintainer-only.
**Output to maintainer terminal only (never in public comments):**
```
@@ -168,13 +155,12 @@ Cannot clean. Notify author to delete branch or force-push (for unmerged PRs).
## Step 5: Notify
```bash
node secret-scanning.mjs notify <TARGET> <AUTHOR> <LOCATION_TYPE> <SECRET_TYPES> [REPLY_TO_NODE_ID|BODY_REDACTION_RESULT_FILE]
node secret-scanning.mjs notify <TARGET> <AUTHOR> <LOCATION_TYPE> <SECRET_TYPES> [REPLY_TO_NODE_ID]
```
- For non-discussion types, `<TARGET>` is the issue/PR number.
- For `discussion_comment`, `<TARGET>` is the `discussion_node_id` returned by `fetch-content`.
- For reply-style `discussion_comment` locations, pass the optional `reply_to_node_id` from `fetch-content` so the notification stays in the same thread.
- For `issue_body` and `pull_request_body`, pass the `<result-file>` from `redact-body-if-needed`. The script skips notification when `notify_required` is `false` and refuses body notifications without this file.
Secret types are comma-separated: `"Discord Bot Token,Feishu App Secret"`
@@ -184,8 +170,6 @@ The script picks the right template:
- **body types**: "your issue/PR description … redacted in place"
- **commit**: "code you committed"
For `issue_body` and `pull_request_body`, only notify when the current body still contained plaintext and maintainers redacted it. If the user already redacted the current body, skip this step and resolve silently.
## Step 6: Resolve
```bash
@@ -194,7 +178,7 @@ node secret-scanning.mjs resolve <ALERT_NUMBER>
node secret-scanning.mjs resolve <ALERT_NUMBER> revoked "Custom comment"
```
Resolution is `revoked` by default. As maintainers we cannot control whether users rotate — our responsibility is to remove current plaintext exposure and notify only when public notification is useful. The `revoked` means "this secret should be considered leaked", not "I confirmed it was revoked".
Resolution is `revoked` by default. As maintainers we cannot control whether users rotate — our responsibility is to redact + notify. The `revoked` means "this secret should be considered leaked", not "I confirmed it was revoked".
## Step 7: Summary

View File

@@ -7,7 +7,6 @@ import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
const REPO = "openclaw/openclaw";
const REPO_URL = `https://github.com/${REPO}`;
@@ -51,34 +50,6 @@ function ghGraphQL(query, options = {}) {
return gh(["api", "graphql", "-f", `query=${query}`], options);
}
function isBodyLocationType(locationType) {
return locationType === "issue_body" || locationType === "pull_request_body";
}
export function decideBodyRedaction(currentBody, redactedBody) {
const bodyChanged = String(currentBody) !== String(redactedBody);
return {
body_changed: bodyChanged,
notify_required: bodyChanged,
};
}
export function loadBodyRedactionResult(locationType, resultFile) {
if (!isBodyLocationType(locationType)) {
return { notify_required: true };
}
if (!resultFile) {
fail("Body notifications require a redaction result file from redact-body-if-needed");
}
if (!fs.existsSync(resultFile)) fail(`File not found: ${resultFile}`);
const result = JSON.parse(fs.readFileSync(resultFile, "utf8"));
if (typeof result.notify_required !== "boolean") {
fail(`Invalid redaction result file: missing boolean notify_required in ${resultFile}`);
}
return result;
}
function failOnGraphQLFailure(result, message) {
if (result?.gh_failed) {
const details = (
@@ -499,43 +470,6 @@ function cmdRedactBody(kind, number, bodyFile) {
console.log(JSON.stringify({ ok: true, kind, number: Number(number) }));
}
/**
* redact-body-if-needed <issue|pr> <number> <current-body-file> <redacted-body-file> <result-file>
* PATCH only when the agent-produced redacted body differs from the current body.
*/
function cmdRedactBodyIfNeeded(kind, number, currentBodyFile, redactedBodyFile, resultFile) {
if (!kind || !number || !currentBodyFile || !redactedBodyFile || !resultFile) {
fail(
"Usage: redact-body-if-needed <issue|pr> <number> <current-body-file> <redacted-body-file> <result-file>",
);
}
if (!fs.existsSync(currentBodyFile)) fail(`File not found: ${currentBodyFile}`);
if (!fs.existsSync(redactedBodyFile)) fail(`File not found: ${redactedBodyFile}`);
const currentBody = fs.readFileSync(currentBodyFile, "utf8");
const redactedBody = fs.readFileSync(redactedBodyFile, "utf8");
const decision = decideBodyRedaction(currentBody, redactedBody);
const result = {
ok: true,
kind,
number: Number(number),
...decision,
};
if (decision.body_changed) {
const endpoint =
kind === "pr" ? `repos/${REPO}/pulls/${number}` : `repos/${REPO}/issues/${number}`;
gh(["api", endpoint, "-X", "PATCH", "-F", `body=@${redactedBodyFile}`]);
result.redacted = true;
} else {
result.redacted = false;
result.reason = "current_body_already_redacted";
}
fs.writeFileSync(resultFile, `${JSON.stringify(result, null, 2)}\n`, { mode: 0o600 });
console.log(JSON.stringify(result));
}
/**
* delete-comment <comment-id>
* Delete a comment (and all its edit history).
@@ -621,17 +555,6 @@ function cmdNotify(target, author, locationType, secretTypes, replyToNodeId) {
const types = secretTypes.split(",").map((s) => s.trim());
const typeList = types.map((t, i) => `${i + 1}. **${t}**`).join("\n");
const redactionResult = loadBodyRedactionResult(locationType, replyToNodeId);
if (isBodyLocationType(locationType) && !redactionResult.notify_required) {
console.log(
JSON.stringify({
ok: true,
skipped: true,
reason: "current_body_already_redacted",
}),
);
return;
}
let locationDesc;
let actionDesc;
@@ -658,8 +581,6 @@ function cmdNotify(target, author, locationType, secretTypes, replyToNodeId) {
}
const body = [
`> **Note:** This is an automated message sent by the OpenClaw maintainer team. **NO_REPLY.**`,
"",
`@${author} :warning: **Security Notice: Secret Leakage Detected**`,
"",
`GitHub Secret Scanning detected the following exposed secret types in ${locationDesc}:`,
@@ -835,13 +756,12 @@ function cmdSummary(jsonFile) {
// ─── Dispatch ───────────────────────────────────────────────────────────────
const args = [];
const [command, ...args] = process.argv.slice(2);
export const commands = {
const commands = {
"fetch-alert": () => cmdFetchAlert(args[0]),
"fetch-content": () => cmdFetchContent(args[0]),
"redact-body": () => cmdRedactBody(args[0], args[1], args[2]),
"redact-body-if-needed": () => cmdRedactBodyIfNeeded(args[0], args[1], args[2], args[3], args[4]),
"delete-comment": () => cmdDeleteComment(args[0]),
"delete-discussion-comment": () => cmdDeleteDiscussionComment(args[0]),
"recreate-comment": () => cmdRecreateComment(args[0], args[1]),
@@ -852,37 +772,26 @@ export const commands = {
summary: () => cmdSummary(args[0]),
};
function main(argv = process.argv.slice(2)) {
const [command, ...commandArgs] = argv;
args.length = 0;
args.push(...commandArgs);
if (!command || !commands[command]) {
console.error(
[
"Usage: node secret-scanning.mjs <command> [args]",
"",
"Commands:",
" fetch-alert <number> Fetch alert metadata + locations",
" fetch-content '<location-json>' Fetch content for a location",
" redact-body <issue|pr> <n> <file> PATCH body with redacted file",
" redact-body-if-needed <issue|pr> <n> <current-file> <redacted-file> <result-file> PATCH body only if redaction changed it",
" delete-comment <comment-id> Delete a comment",
" delete-discussion-comment <node-id> Delete a discussion comment (GraphQL)",
" recreate-comment <issue-n> <file> Create replacement comment",
" recreate-discussion-comment <disc-node-id> <file> [reply-to-node-id] Create discussion comment (GraphQL)",
" notify <target> <author> <type> <types> [reply-to-node-id|body-result-file] Post notification",
" resolve <n> [resolution] [comment] Close alert",
" list-open List open alerts",
" summary <json-file> Print formatted summary",
].join("\n"),
);
process.exit(1);
}
commands[command]();
if (!command || !commands[command]) {
console.error(
[
"Usage: node secret-scanning.mjs <command> [args]",
"",
"Commands:",
" fetch-alert <number> Fetch alert metadata + locations",
" fetch-content '<location-json>' Fetch content for a location",
" redact-body <issue|pr> <n> <file> PATCH body with redacted file",
" delete-comment <comment-id> Delete a comment",
" delete-discussion-comment <node-id> Delete a discussion comment (GraphQL)",
" recreate-comment <issue-n> <file> Create replacement comment",
" recreate-discussion-comment <disc-node-id> <file> [reply-to-node-id] Create discussion comment (GraphQL)",
" notify <target> <author> <type> <types> [reply-to-node-id] Post notification",
" resolve <n> [resolution] [comment] Close alert",
" list-open List open alerts",
" summary <json-file> Print formatted summary",
].join("\n"),
);
process.exit(1);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main();
}
commands[command]();

View File

@@ -19,16 +19,9 @@ or validating a change without wasting hours.
Prove the touched surface first. Do not reflexively run the whole suite.
1. Inspect the diff and classify the touched surface:
- normal source checkout, source change: `pnpm changed:lanes --json`, then `pnpm check:changed`
- normal source checkout, tests only: `pnpm test:changed`
- normal source checkout, one failing file: `pnpm test <path-or-filter> -- --reporter=verbose`
- Codex worktree or linked/sparse checkout, one/few explicit files: `node scripts/run-vitest.mjs <path-or-filter>`
- Codex worktree or linked/sparse checkout, changed gates or anything broad:
use the Crabbox wrapper with the provider that matches the proof surface.
For maintainer heavy `pnpm` gates, that is usually delegated Blacksmith
Testbox through Crabbox, e.g. `node scripts/crabbox-wrapper.mjs run
--provider blacksmith-testbox ... -- pnpm check:changed`. For direct AWS
Crabbox proof, omit `--provider` and let `.crabbox.yaml` choose AWS.
- source: `pnpm changed:lanes --json`, then `pnpm check:changed`
- tests only: `pnpm test:changed`
- one failing file: `pnpm test <path-or-filter> -- --reporter=verbose`
- workflow-only: `git diff --check`, workflow syntax/lint (`actionlint` when available)
- docs-only: `pnpm docs:list`, docs formatter/lint only if docs tooling changed or requested
2. Reproduce narrowly before fixing.
@@ -43,24 +36,11 @@ Prove the touched surface first. Do not reflexively run the whole suite.
- Prefer GitHub Actions for release/Docker proof when the workflow already has the prepared image and secrets.
- Use `scripts/committer "<msg>" <paths...>` when committing; stage only your files.
- If deps are missing, run `pnpm install`, retry once, then report the first actionable error.
- In a Codex worktree or linked/sparse checkout, do not run direct local
`pnpm test*`, `pnpm check*`, `pnpm crabbox:run`, or `scripts/committer` until
you have verified pnpm will not reconcile or reinstall dependencies. Use
`node scripts/run-vitest.mjs` for tiny local proof, `node
scripts/crabbox-wrapper.mjs` for Testbox, and `git commit --no-verify` only
after the relevant remote or node-wrapper proof is already clean.
- For remote proof, use the Crabbox wrapper first, but name the actual backend.
Direct AWS Crabbox uses `provider=aws` and `cbx_...` ids. Delegated
Blacksmith Testbox through Crabbox uses `provider=blacksmith-testbox`,
`syncDelegated=true`, and `tbx_...` ids. Both satisfy "remote proof" when the
requested proof surface allows either.
- Do not infer "no Testbox is running" from plain `blacksmith testbox list`.
Use `blacksmith testbox list --all` or `blacksmith testbox status <tbx_id>`
before reporting cloud state.
- Reuse only an id/slug created in this operator session unless explicitly
coordinating with another lane. If Testbox queues, fails capacity, or cannot
allocate, report the blocker or switch to direct AWS Crabbox only when that
still proves the requested surface.
- For Blacksmith Testbox proof, use Crabbox first. `pnpm crabbox:run -- --provider
blacksmith-testbox --timing-json -- <command...>` warms, claims, syncs, runs,
reports, and cleans up one-shot boxes. Reuse only an id/slug created in this
operator session; `blacksmith testbox list` is diagnostics only, not a shared
work queue.
## Local Test Shortcuts
@@ -75,14 +55,6 @@ OPENCLAW_VITEST_MAX_WORKERS=1 pnpm test <path-or-filter>
Use targeted file paths whenever possible. Avoid raw `vitest`; use the repo
`pnpm test` wrapper so project routing, workers, and setup stay correct.
When the checkout is a Codex worktree, prefer the direct node harness instead:
```bash
node scripts/run-vitest.mjs <path-or-filter>
```
That keeps the test scoped without giving pnpm a chance to run dependency
status checks or install reconciliation in a linked worktree.
## Command Semantics
@@ -131,8 +103,6 @@ gh run view <run-id> --job <job-id> --log
- Check exact SHA. Ignore newer unrelated `main` unless asked.
- For cancelled same-branch runs, confirm whether a newer run superseded it.
- Fetch full logs only for failed or relevant jobs.
- Prefer `gh run view <run-id> --json jobs` over PR rollup while debugging; rollup can be stale/noisy.
- For `prompt:snapshots:check` failures, treat Linux Node 24 as CI truth. If macOS passes but CI drifts, reproduce in a Linux Node 24 container or Testbox, commit that generated output, then rerun.
## GitHub Release Workflows

View File

@@ -1,41 +0,0 @@
---
name: slacrawl
description: "Slack archive: search, sync freshness, threads/DMs, SQL counts, and Slacrawl repo work."
metadata:
openclaw:
homepage: https://github.com/openclaw/slacrawl
requires:
bins:
- slacrawl
install:
- kind: go
module: github.com/vincentkoc/slacrawl/cmd/slacrawl@latest
bins:
- slacrawl
---
# Slacrawl
Use local Slack archive data first. Check freshness for recent/current questions:
```bash
slacrawl doctor
slacrawl status --json
```
Refresh only when stale or asked:
```bash
slacrawl sync --source desktop
slacrawl sync --source api --latest-only
```
Query with bounded slices:
```bash
slacrawl search --limit 20 "query"
slacrawl messages --since 7d --limit 50
slacrawl sql "select count(*) from messages;"
```
Report workspace/channel names, absolute date spans, counts, and token/source limits. Use read-only SQL for exact counts/rankings. API sync and full thread/DM hydration require Slack tokens; do not assume they exist.

View File

@@ -1,4 +0,0 @@
interface:
display_name: "Slacrawl"
short_description: "Search local Slack archives and freshness"
default_prompt: "Use $slacrawl to search local Slack archives, check freshness, inspect channel or DM slices, and report exact date spans and token/source limits."

View File

@@ -17,8 +17,7 @@ artifact bundle. The runner leases the shared burner account from Convex.
Run from the OpenClaw repo and branch under test:
```bash
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
"$proof_cmd" start \
pnpm qa:telegram-user:crabbox -- start \
--tdlib-url http://artifacts.openclaw.ai/tdlib-v1.8.0-linux-x64.tgz \
--output-dir .artifacts/qa-e2e/telegram-user-crabbox/pr-review
```
@@ -40,8 +39,7 @@ For deterministic visual repros, put the exact mock-model reply in a file and
pass it to `start`:
```bash
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
"$proof_cmd" start \
pnpm qa:telegram-user:crabbox -- start \
--tdlib-url http://artifacts.openclaw.ai/tdlib-v1.8.0-linux-x64.tgz \
--mock-response-file .artifacts/qa-e2e/telegram-user-crabbox/reply.txt \
--output-dir .artifacts/qa-e2e/telegram-user-crabbox/pr-review
@@ -57,16 +55,15 @@ For visual proof, first send or identify a bottom marker message, then open the
group/topic directly by message id:
```bash
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
"$proof_cmd" view \
pnpm qa:telegram-user:crabbox -- view \
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json \
--message-id <message-id>
```
This uses Telegram Desktop directly with `tg://privatepost`, not `xdg-open`.
It also resizes Telegram to `650x1000` at the tested desktop position so
the crop can isolate the chat pane even if Telegram keeps a split/sidebar
layout. Do not press Escape after this; Escape can close the selected chat.
Telegram switches to single-chat mode with no left chat list or right info
pane. Do not press Escape after this; Escape can close the selected chat.
Bottom behavior matters:
@@ -74,14 +71,13 @@ Bottom behavior matters:
later messages appear live in the recording
- deep-linking to an older message does not auto-scroll to new arrivals; link
again to the newest/final marker instead of clicking the down-arrow
- the cropped GIF intentionally uses the chat pane, not the whole desktop or
whole Telegram window
- `650px` is the largest tested clean width; `660px` switches Telegram back to
split/sidebar layout
Send as the real Telegram user:
```bash
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
"$proof_cmd" send \
pnpm qa:telegram-user:crabbox -- send \
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json \
--text /status
```
@@ -91,8 +87,7 @@ For slash commands, omit the bot username; the runner targets the SUT bot.
Run arbitrary commands on the Crabbox:
```bash
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
"$proof_cmd" run \
pnpm qa:telegram-user:crabbox -- run \
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json \
-- bash -lc 'source /tmp/openclaw-telegram-user-crabbox/env.sh && python3 /tmp/openclaw-telegram-user-crabbox/user-driver.py transcript --limit 20 --json'
```
@@ -111,16 +106,14 @@ python3 /tmp/openclaw-telegram-user-crabbox/user-driver.py probe --text '@{sut}
Capture the current desktop without ending the session:
```bash
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
"$proof_cmd" screenshot \
pnpm qa:telegram-user:crabbox -- screenshot \
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json
```
Check lease state and get the WebVNC command:
```bash
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
"$proof_cmd" status \
pnpm qa:telegram-user:crabbox -- status \
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json
```
@@ -129,8 +122,7 @@ proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-pr
Always finish or explicitly keep the box:
```bash
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
"$proof_cmd" finish \
pnpm qa:telegram-user:crabbox -- finish \
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json \
--preview-crop telegram-window
```
@@ -158,8 +150,7 @@ Attach only the useful visual artifact to the PR unless logs are needed. The
runner is GIF-only by default:
```bash
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
"$proof_cmd" publish \
pnpm qa:telegram-user:crabbox -- publish \
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json \
--pr <pr-number> \
--summary 'Telegram real-user Crabbox session motion GIF'
@@ -198,8 +189,7 @@ experiments unless those artifacts are explicitly needed.
For a fast one-shot check, use:
```bash
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
"$proof_cmd" --text /status
pnpm qa:telegram-user:crabbox -- --text /status
```
This is a start/send/finish shortcut. Prefer the held session for PR review,

2
.github/CODEOWNERS vendored
View File

@@ -11,8 +11,6 @@
/.github/workflows/codeql.yml @openclaw/openclaw-secops
/.github/workflows/codeql-android-critical-security.yml @openclaw/openclaw-secops
/.github/workflows/codeql-critical-quality.yml @openclaw/openclaw-secops
/.github/workflows/dependency-change-awareness.yml @openclaw/openclaw-secops
/test/scripts/dependency-change-awareness-workflow.test.ts @openclaw/openclaw-secops
/src/security/ @openclaw/openclaw-secops
/src/secrets/ @openclaw/openclaw-secops
/src/config/*secret*.ts @openclaw/openclaw-secops

View File

@@ -2,9 +2,10 @@
You are Mantis running native Telegram Desktop visual proof for an OpenClaw PR.
Goal: inspect the pull request, decide whether it has an honest
Telegram-visible before/after behavior, then either run native Telegram Desktop
proof or leave a no-visual-proof manifest for the workflow to publish.
Goal: inspect the pull request, decide the best Telegram-visible behavior to
prove, run before/after native Telegram Desktop sessions, iterate until the GIFs
are visually good, and leave a Mantis evidence manifest for the workflow to
publish.
Hard limits:
@@ -15,16 +16,6 @@ Hard limits:
- Do not use fixed `/status` proof unless it genuinely proves the PR.
- Do not finish with tiny, cropped-wrong, off-bottom, or sidebar-heavy GIFs.
- Do not invent a generic proof. The proof must match the PR behavior.
- Do not force GIFs for internal-only, workflow-only, test-only, docs-only, or
otherwise non-visual PRs. A no-visual-proof manifest is a successful workflow
outcome when GIFs would be misleading, but it is not proof that the PR passed.
- Do not skip Telegram-visible PRs just because the proof needs a specific
message, mock response, media attachment, command, button, reaction, stop
timing, approval prompt, or progress/final delivery sequence. First write a
concrete proof plan and try the standard harness path.
- Keep public-facing manifest summaries short and user-domain. Do not mention
harness internals, mock-provider limits, secret/trust boundaries, local paths,
transcript seeding, or workflow implementation details in the summary.
Inputs are provided as environment variables:
@@ -45,63 +36,10 @@ Required workflow:
1. Read `.agents/skills/telegram-crabbox-e2e-proof/SKILL.md`.
2. Inspect the PR with `gh pr view "$MANTIS_PR_NUMBER"` and
`gh pr diff "$MANTIS_PR_NUMBER"`.
3. Decide whether the PR has a visibly reproducible Telegram Desktop
before/after. Treat these as visible until proven otherwise: message text
formatting/content, progress drafts, native drafts, final delivery, media or
document delivery, inline buttons, approval prompts, stop/abort behavior,
reactions/status indicators, guest/inline responses, TTS/voice/audio
delivery, and routing changes whose result is visible in the chat. For those
PRs, define the exact Telegram stimulus and expected main/PR visual delta
before deciding to skip.
If the PR does not have a Telegram-visible before/after, write
`${MANTIS_OUTPUT_DIR}/mantis-evidence.json` with `comparison.pass: true`, no
artifacts, and a summary that starts with
`Mantis did not generate before/after GIFs because`. Include a short
public reason, such as `the PR changes internal session bookkeeping rather
than Telegram-visible behavior`. Use this manifest shape and do not create
worktrees or start Crabbox for this case:
```json
{
"schemaVersion": 1,
"id": "telegram-desktop-proof",
"title": "Mantis Telegram Desktop Proof",
"summary": "Mantis did not generate before/after GIFs because <reason>.",
"scenario": "telegram-desktop-proof",
"comparison": {
"baseline": {
"ref": "<BASELINE_REF>",
"sha": "<BASELINE_SHA>",
"expected": "no visible Telegram Desktop delta",
"status": "skipped"
},
"candidate": {
"ref": "<CANDIDATE_REF>",
"sha": "<CANDIDATE_SHA>",
"expected": "no visible Telegram Desktop delta",
"status": "skipped",
"fixed": true
},
"pass": true
},
"artifacts": []
}
```
If the PR appears visual but proof is blocked by Telegram Desktop session
state, authorization, credentials, Crabbox, missing Telegram client support,
unavailable media/provider setup, or another capture-infrastructure issue,
do not describe it as a no-visual PR. Write a manifest with
`comparison.pass: false`, skipped lanes, no artifacts, and a summary that
starts with `Mantis could not capture Telegram Desktop proof because`. The
publisher will keep that out of PR comments so the failure stays in the
workflow logs and artifacts.
4. Decide what Telegram message, mock model response, command, callback, button,
3. Decide what Telegram message, mock model response, command, callback, button,
media, or sequence best proves the PR. Use `MANTIS_INSTRUCTIONS` as extra
maintainer guidance, not as a replacement for reading the PR.
5. Create detached worktrees under
4. Create detached worktrees under
`.artifacts/qa-e2e/mantis/telegram-desktop-proof-worktrees/baseline` and
`.artifacts/qa-e2e/mantis/telegram-desktop-proof-worktrees/candidate`, then
install and build each worktree with the repo's normal `pnpm` commands.
@@ -111,7 +49,7 @@ than Telegram-visible behavior`. Use this manifest shape and do not create
runtime commands. The candidate SUT may receive only the proof runner's
short-lived Telegram bot token, generated local config/state paths, and mock
model key needed for this isolated proof.
6. In each worktree, run the real-user Telegram Crabbox proof flow from the
5. In each worktree, run the real-user Telegram Crabbox proof flow from the
skill with `$OPENCLAW_TELEGRAM_USER_PROOF_CMD`; do not run
`pnpm qa:telegram-user:crabbox` directly. The proof command comes from the
trusted workflow checkout while the current directory controls which
@@ -121,11 +59,11 @@ than Telegram-visible behavior`. Use this manifest shape and do not create
install, or patch replacement proof tooling during the run. Use the same
proof idea for baseline and candidate. You may iterate and rerun if the
visual result is not convincing.
7. Open Telegram Desktop directly to the newest relevant message with the
6. Open Telegram Desktop directly to the newest relevant message with the
runner `view` command before finishing each recording. Keep the chat scrolled
to the bottom so new proof messages appear in-frame.
8. Finish each session with `--preview-crop telegram-window`.
9. Build `${MANTIS_OUTPUT_DIR}/mantis-evidence.json` with:
7. Finish each session with `--preview-crop telegram-window`.
8. Build `${MANTIS_OUTPUT_DIR}/mantis-evidence.json` with:
```bash
node scripts/mantis/build-telegram-desktop-proof-evidence.mjs \
@@ -155,10 +93,6 @@ Visual acceptance:
Expected final state:
- `${MANTIS_OUTPUT_DIR}/mantis-evidence.json` exists.
- Visual proof manifests contain paired `motionPreview` artifacts labeled
`Main` and `This PR`.
- No-visual-proof manifests contain no artifacts and have `comparison.pass:
true`.
- Capture-infrastructure failure manifests contain no artifacts and have
`comparison.pass: false`.
- The manifest contains paired `motionPreview` artifacts labeled `Main` and
`This PR`.
- The worktree can be dirty only under `.artifacts/`.

6
.github/labeler.yml vendored
View File

@@ -101,9 +101,7 @@
- changed-files:
- any-glob-to-any-file:
- "extensions/qa-lab/**"
- "qa/scenarios/**"
- "docs/concepts/qa-e2e-automation.md"
- "docs/concepts/personal-agent-benchmark-pack.md"
- "docs/channels/qa-channel.md"
"channel: signal":
- changed-files:
@@ -246,10 +244,6 @@
- "docs/gateway/security.md"
- "security/**"
"extensions: admin-http-rpc":
- changed-files:
- any-glob-to-any-file:
- "extensions/admin-http-rpc/**"
"extensions: copilot-proxy":
- changed-files:
- any-glob-to-any-file:

View File

@@ -5,7 +5,7 @@ Describe the problem and fix in 25 bullets:
If this PR fixes a plugin beta-release blocker, title it `fix(<plugin-id>): beta blocker - <summary>` and link the matching `Beta blocker: <plugin-name> - <summary>` issue labeled `beta-blocker`. Contributors cannot label PRs, so the title is the PR-side signal for maintainers and automation.
- Problem:
- Solution:
- Why it matters:
- What changed:
- What did NOT change (scope boundary):
@@ -35,12 +35,6 @@ If this PR fixes a plugin beta-release blocker, title it `fix(<plugin-id>): beta
- Related #
- [ ] This PR fixes a bug or regression
## Motivation
Explain why this change should exist now. Link it to the user pain, failure mode, maintainer need, or product goal. If this is purely mechanical, write `N/A`.
-
## Real behavior proof (required for external PRs)
External contributors must show after-fix evidence from a real OpenClaw setup. Unit tests, mocks, lint, typechecks, snapshots, and CI are supplemental only. Screenshots are encouraged even for CLI, console, text, or log changes; terminal screenshots and copied live output count. Be mindful of private information like IP addresses, API keys, phone numbers, non-public endpoints, or other private details when providing evidence.

View File

@@ -124,6 +124,5 @@ jobs:
- name: Run Testbox
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
if: always()
continue-on-error: true
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

View File

@@ -20,8 +20,6 @@ on:
- "docs/**"
pull_request:
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
paths-ignore:
- "CHANGELOG.md"
permissions:
contents: read
@@ -40,7 +38,7 @@ jobs:
permissions:
contents: read
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
runs-on: ubuntu-24.04
timeout-minutes: 20
outputs:
checkout_revision: ${{ steps.checkout_ref.outputs.sha }}
@@ -60,11 +58,14 @@ jobs:
plugin_contracts_matrix: ${{ steps.manifest.outputs.plugin_contracts_matrix }}
channel_contracts_matrix: ${{ steps.manifest.outputs.channel_contracts_matrix }}
run_checks: ${{ steps.manifest.outputs.run_checks }}
checks_matrix: ${{ steps.manifest.outputs.checks_matrix }}
run_checks_node_core_nondist: ${{ steps.manifest.outputs.run_checks_node_core_nondist }}
checks_node_core_nondist_matrix: ${{ steps.manifest.outputs.checks_node_core_nondist_matrix }}
run_checks_node_core_dist: ${{ steps.manifest.outputs.run_checks_node_core_dist }}
checks_node_core_dist_matrix: ${{ steps.manifest.outputs.checks_node_core_dist_matrix }}
run_check: ${{ steps.manifest.outputs.run_check }}
run_check_additional: ${{ steps.manifest.outputs.run_check_additional }}
run_build_smoke: ${{ steps.manifest.outputs.run_build_smoke }}
run_check_docs: ${{ steps.manifest.outputs.run_check_docs }}
run_control_ui_i18n: ${{ steps.manifest.outputs.run_control_ui_i18n }}
run_checks_windows: ${{ steps.manifest.outputs.run_checks_windows }}
@@ -131,7 +132,6 @@ jobs:
OPENCLAW_CI_RUN_CONTROL_UI_I18N: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_control_ui_i18n || 'false' }}
OPENCLAW_CI_CHECKOUT_REVISION: ${{ steps.checkout_ref.outputs.sha }}
OPENCLAW_CI_REPOSITORY: ${{ github.repository }}
OPENCLAW_CI_EVENT_NAME: ${{ github.event_name }}
run: |
node --input-type=module <<'EOF'
import { appendFileSync } from "node:fs";
@@ -173,7 +173,6 @@ jobs:
const isCanonicalRepository = process.env.OPENCLAW_CI_REPOSITORY === "openclaw/openclaw";
const docsOnly = parseBoolean(process.env.OPENCLAW_CI_DOCS_ONLY);
const docsChanged = parseBoolean(process.env.OPENCLAW_CI_DOCS_CHANGED);
const eventName = process.env.OPENCLAW_CI_EVENT_NAME ?? "";
const runNode = parseBoolean(process.env.OPENCLAW_CI_RUN_NODE) && !docsOnly;
const runNodeFastOnly =
runNode && parseBoolean(process.env.OPENCLAW_CI_RUN_NODE_FAST_ONLY);
@@ -198,7 +197,7 @@ jobs:
const checksFastCoreTasks = [];
if (runNodeFull) {
checksFastCoreTasks.push(
{ check_name: "checks-fast-bundled-protocol", runtime: "node", task: "bundled-protocol" },
{ check_name: "checks-fast-bundled", runtime: "node", task: "bundled" },
);
} else {
if (runNodeFastCiRouting) {
@@ -247,12 +246,21 @@ jobs:
runNodeFull ? createChannelContractTestShards() : [],
),
run_checks: runNodeFull,
checks_matrix: createMatrix(
runNodeFull
? [
{ check_name: "checks-node-channels", runtime: "node", task: "channels" },
]
: [],
),
run_checks_node_core_nondist: nodeTestNonDistShards.length > 0,
checks_node_core_nondist_matrix: createMatrix(nodeTestNonDistShards),
run_checks_node_core_dist: nodeTestDistShards.length > 0,
checks_node_core_dist_matrix: createMatrix(nodeTestDistShards),
run_check: runNodeFull,
run_check_additional: runNodeFull,
run_check_docs: docsChanged && eventName !== "push",
run_build_smoke: runNodeFull,
run_check_docs: docsChanged,
run_control_ui_i18n: runControlUiI18n,
run_skills_python_job: runSkillsPython,
run_checks_windows: runWindows,
@@ -287,13 +295,13 @@ jobs:
}
EOF
# Run dependency-free security checks in parallel with scope detection so the
# Run the fast security/SCM checks in parallel with scope detection so the
# main Node jobs do not have to wait for Python/pre-commit setup.
security-fast:
security-scm-fast:
permissions:
contents: read
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
runs-on: ubuntu-24.04
timeout-minutes: 20
env:
PRE_COMMIT_HOME: .cache/pre-commit-security-fast
@@ -382,6 +390,22 @@ jobs:
printf 'Auditing workflow files:\n%s\n' "${workflow_files[@]}"
pre-commit run --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" zizmor --files "${workflow_files[@]}"
security-dependency-audit:
permissions:
contents: read
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ inputs.target_ref || github.sha }}
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
@@ -391,6 +415,35 @@ jobs:
- name: Audit production dependencies
run: node scripts/pre-commit/pnpm-audit-prod.mjs --audit-level=high
security-fast:
permissions: {}
needs: [security-scm-fast, security-dependency-audit]
if: ${{ !cancelled() && always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify fast security jobs
env:
DEPENDENCY_AUDIT_RESULT: ${{ needs.security-dependency-audit.result }}
SCM_RESULT: ${{ needs.security-scm-fast.result }}
run: |
set -euo pipefail
failed=0
for result in \
"security-scm-fast=${SCM_RESULT}" \
"security-dependency-audit=${DEPENDENCY_AUDIT_RESULT}"
do
job="${result%%=*}"
status="${result#*=}"
if [ "$status" != "success" ]; then
echo "::error::${job} ended with ${status}"
failed=1
fi
done
exit "$failed"
# Build dist once for Node-relevant changes and share it with downstream jobs.
# Keep this overlapping with the fast correctness lanes so green PRs get heavy
# test/build feedback sooner instead of waiting behind a full `check` pass.
@@ -399,7 +452,7 @@ jobs:
contents: read
needs: [preflight]
if: needs.preflight.outputs.run_build_artifacts == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 20
outputs:
channels-result: ${{ steps.built_artifact_checks.outputs['channels-result'] }}
@@ -588,15 +641,6 @@ jobs:
echo "${name}-result=${results[$name]}" >> "$GITHUB_OUTPUT"
done
failures=0
for name in channels core-support-boundary gateway-watch; do
if [ "${results[$name]}" = "failure" ]; then
echo "::error title=${name} failed::${name} failed"
failures=1
fi
done
exit "$failures"
- name: Upload gateway watch regression artifacts
if: always() && needs.preflight.outputs.run_check_additional == 'true'
uses: actions/upload-artifact@v7
@@ -611,7 +655,7 @@ jobs:
name: ${{ matrix.check_name }}
needs: [preflight]
if: needs.preflight.outputs.run_checks_fast_core == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 60
strategy:
fail-fast: false
@@ -678,9 +722,14 @@ jobs:
run: |
set -euo pipefail
case "$TASK" in
bundled-protocol)
bundled)
pnpm test:bundled
pnpm protocol:check
;;
contracts-channels)
pnpm test:contracts:channels
;;
contracts-plugins)
pnpm test:contracts:plugins
;;
contracts-plugins-ci-routing)
pnpm test:contracts:plugins
@@ -701,7 +750,7 @@ jobs:
name: ${{ matrix.checkName }}
needs: [preflight]
if: needs.preflight.outputs.run_plugin_contracts_shards == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 60
strategy:
fail-fast: false
@@ -779,13 +828,35 @@ jobs:
EOF
OPENCLAW_VITEST_INCLUDE_FILE="$include_file" pnpm test:contracts:plugins
checks-fast-plugin-contracts:
permissions:
contents: read
name: checks-fast-contracts-plugins
needs: [preflight, checks-fast-plugin-contracts-shard]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_plugin_contracts_shards == 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify plugin contract shards
env:
SHARD_RESULT: ${{ needs.checks-fast-plugin-contracts-shard.result }}
run: |
if [ "$SHARD_RESULT" = "cancelled" ]; then
echo "Plugin contract shards were cancelled, usually because a newer commit superseded this run." >&2
exit 1
fi
if [ "$SHARD_RESULT" != "success" ]; then
echo "Plugin contract shards failed: $SHARD_RESULT" >&2
exit 1
fi
checks-fast-channel-contracts-shard:
permissions:
contents: read
name: ${{ matrix.checkName }}
needs: [preflight]
if: needs.preflight.outputs.run_checks_fast == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 60
strategy:
fail-fast: false
@@ -863,13 +934,132 @@ jobs:
EOF
OPENCLAW_VITEST_INCLUDE_FILE="$include_file" pnpm test:contracts:channels
checks-fast-channel-contracts:
permissions:
contents: read
name: checks-fast-contracts-channels
needs: [preflight, checks-fast-channel-contracts-shard]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_checks_fast == 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify channel contract shards
env:
SHARD_RESULT: ${{ needs.checks-fast-channel-contracts-shard.result }}
run: |
if [ "$SHARD_RESULT" = "cancelled" ]; then
echo "Channel contract shards were cancelled, usually because a newer commit superseded this run." >&2
exit 1
fi
if [ "$SHARD_RESULT" != "success" ]; then
echo "Channel contract shards failed: $SHARD_RESULT" >&2
exit 1
fi
checks-fast-protocol:
permissions:
contents: read
name: "checks-fast-protocol"
needs: [preflight]
if: needs.preflight.outputs.run_checks_fast == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- name: Checkout
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
workdir="$GITHUB_WORKSPACE"
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
reset_checkout_dir() {
mkdir -p "$workdir"
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
}
checkout_attempt() {
local attempt="$1"
reset_checkout_dir
git init "$workdir" >/dev/null
git config --global --add safe.directory "$workdir"
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
git -C "$workdir" config gc.auto 0
timeout --signal=TERM 30s git -C "$workdir" \
-c protocol.version=2 \
-c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA" || return 1
test -f "$workdir/.github/actions/setup-node-env/action.yml" || return 1
echo "checkout attempt ${attempt}/5 succeeded"
}
for attempt in 1 2 3 4 5; do
if checkout_attempt "$attempt"; then
exit 0
fi
echo "checkout attempt ${attempt}/5 failed"
sleep $((attempt * 5))
done
echo "checkout failed after 5 attempts" >&2
exit 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Run protocol check
run: pnpm protocol:check
checks:
permissions:
contents: read
name: ${{ matrix.check_name }}
needs: [preflight, build-artifacts]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_checks == 'true' && needs.build-artifacts.result == 'success' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.preflight.outputs.checks_matrix) }}
steps:
- name: Verify ${{ matrix.task }} (${{ matrix.runtime }})
env:
TASK: ${{ matrix.task }}
CHANNELS_RESULT: ${{ needs.build-artifacts.outputs['channels-result'] }}
shell: bash
run: |
set -euo pipefail
case "$TASK" in
channels)
if [ "$CHANNELS_RESULT" != "success" ]; then
echo "Channel tests failed in build-artifacts: $CHANNELS_RESULT" >&2
exit 1
fi
;;
*)
echo "Unsupported checks task: $TASK" >&2
exit 1
;;
esac
checks-node-compat:
permissions:
contents: read
name: checks-node-compat-node22
needs: [preflight]
if: needs.preflight.outputs.run_build_artifacts == 'true' && github.event_name == 'workflow_dispatch'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 60
steps:
- name: Checkout
@@ -923,7 +1113,7 @@ jobs:
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: "22.19.0"
node-version: "22.18.0"
cache-key-suffix: "node22-pnpm11"
install-bun: "false"
@@ -946,7 +1136,7 @@ jobs:
name: ${{ matrix.check_name }}
needs: [preflight]
if: needs.preflight.outputs.run_checks_node_core_nondist == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (matrix.runner || 'ubuntu-24.04') || 'ubuntu-24.04') }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && (matrix.runner || 'ubuntu-24.04') || 'ubuntu-24.04' }}
timeout-minutes: 60
strategy:
fail-fast: false
@@ -1050,6 +1240,63 @@ jobs:
}
EOF
checks-node-core-test-dist-shard:
permissions:
contents: read
name: ${{ matrix.check_name }}
needs: [preflight, build-artifacts]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_checks_node_core_dist == 'true' && needs.build-artifacts.result == 'success' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.preflight.outputs.checks_node_core_dist_matrix) }}
steps:
- name: Verify Node test shard
env:
CORE_SUPPORT_BOUNDARY_RESULT: ${{ needs.build-artifacts.outputs['core-support-boundary-result'] }}
SHARD_NAME: ${{ matrix.shard_name }}
shell: bash
run: |
set -euo pipefail
case "$SHARD_NAME" in
core-support-boundary)
if [ "$CORE_SUPPORT_BOUNDARY_RESULT" != "success" ]; then
echo "Core support boundary shard failed in build-artifacts: $CORE_SUPPORT_BOUNDARY_RESULT" >&2
exit 1
fi
;;
*)
echo "Unsupported built-artifact shard: $SHARD_NAME" >&2
exit 1
;;
esac
checks-node-core-test:
permissions:
contents: read
name: checks-node-core
needs: [preflight, checks-node-core-test-nondist-shard, checks-node-core-test-dist-shard]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_checks == 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify node test shards
env:
DIST_SHARD_RESULT: ${{ needs.checks-node-core-test-dist-shard.result }}
NONDIST_SHARD_RESULT: ${{ needs.checks-node-core-test-nondist-shard.result }}
RUN_DIST_SHARDS: ${{ needs.preflight.outputs.run_checks_node_core_dist }}
RUN_NONDIST_SHARDS: ${{ needs.preflight.outputs.run_checks_node_core_nondist }}
run: |
if [ "$RUN_NONDIST_SHARDS" = "true" ] && [ "$NONDIST_SHARD_RESULT" != "success" ]; then
echo "Node non-dist test shards failed: $NONDIST_SHARD_RESULT" >&2
exit 1
fi
if [ "$RUN_DIST_SHARDS" = "true" ] && [ "$DIST_SHARD_RESULT" != "success" ]; then
echo "Node dist test shards failed: $DIST_SHARD_RESULT" >&2
exit 1
fi
# Types, lint, and format check shards.
check-shard:
permissions:
@@ -1057,15 +1304,15 @@ jobs:
name: ${{ matrix.check_name }}
needs: [preflight]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check == 'true' }}
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && matrix.runner || 'ubuntu-24.04') }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && matrix.runner || 'ubuntu-24.04' }}
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
- check_name: check-guards
task: guards
runner: blacksmith-4vcpu-ubuntu-2404
- check_name: check-preflight-guards
task: preflight-guards
runner: ubuntu-24.04
- check_name: check-prod-types
task: prod-types
runner: blacksmith-4vcpu-ubuntu-2404
@@ -1074,10 +1321,16 @@ jobs:
runner: blacksmith-16vcpu-ubuntu-2404
- check_name: check-dependencies
task: dependencies
runner: blacksmith-8vcpu-ubuntu-2404
runner: ubuntu-24.04
- check_name: check-policy-guards
task: policy-guards
runner: ubuntu-24.04
- check_name: check-test-types
task: test-types
runner: blacksmith-4vcpu-ubuntu-2404
- check_name: check-strict-smoke
task: strict-smoke
runner: ubuntu-24.04
steps:
- name: Checkout
shell: bash
@@ -1140,18 +1393,11 @@ jobs:
run: |
set -euo pipefail
case "$TASK" in
guards)
preflight-guards)
pnpm check:no-conflict-markers
pnpm tool-display:check
pnpm check:host-env-policy:swift
pnpm dup:check:coverage
pnpm deps:patches:check
pnpm lint:webhook:no-low-level-body-read
pnpm lint:auth:no-pairing-store-group
pnpm lint:auth:pairing-account-scope
pnpm check:import-cycles
# build-artifacts already runs the tsdown/runtime build for the same Node-relevant changes.
pnpm build:plugin-sdk:strict-smoke
;;
prod-types)
pnpm tsgo:prod
@@ -1168,9 +1414,19 @@ jobs:
pnpm deadcode:ci
fi
;;
policy-guards)
pnpm lint:webhook:no-low-level-body-read
pnpm lint:auth:no-pairing-store-group
pnpm lint:auth:pairing-account-scope
pnpm check:import-cycles
;;
test-types)
pnpm check:test-types
;;
strict-smoke)
# build-artifacts already runs the tsdown/runtime build for the same Node-relevant changes.
pnpm build:plugin-sdk:strict-smoke
;;
*)
echo "Unsupported check task: $TASK" >&2
exit 1
@@ -1185,13 +1441,31 @@ jobs:
path: .artifacts/deadcode
if-no-files-found: ignore
check:
permissions:
contents: read
name: "check"
needs: [preflight, check-shard]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check == 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify check shards
env:
SHARD_RESULT: ${{ needs.check-shard.result }}
run: |
if [ "$SHARD_RESULT" != "success" ]; then
echo "Check shards failed: $SHARD_RESULT" >&2
exit 1
fi
check-additional-shard:
permissions:
contents: read
name: ${{ matrix.check_name }}
needs: [preflight]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check_additional == 'true' }}
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 20
strategy:
fail-fast: false
@@ -1200,9 +1474,15 @@ jobs:
- check_name: check-additional-boundaries-a
group: boundaries
boundary_shard: 1/4
- check_name: check-additional-boundaries-bcd
- check_name: check-additional-boundaries-b
group: boundaries
boundary_shard: 2/4,3/4,4/4
boundary_shard: 2/4
- check_name: check-additional-boundaries-c
group: boundaries
boundary_shard: 3/4
- check_name: check-additional-boundaries-d
group: boundaries
boundary_shard: 4/4
- check_name: check-additional-extension-channels
group: extension-channels
- check_name: check-additional-extension-bundled
@@ -1356,13 +1636,59 @@ jobs:
exit "$failures"
check-additional:
permissions:
contents: read
name: "check-additional"
needs: [preflight, check-additional-shard, build-artifacts]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check_additional == 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify additional check shards
env:
SHARD_RESULT: ${{ needs.check-additional-shard.result }}
BUILD_ARTIFACTS_RESULT: ${{ needs.build-artifacts.result }}
GATEWAY_RESULT: ${{ needs.build-artifacts.outputs.gateway-watch-result }}
run: |
if [ "$SHARD_RESULT" != "success" ]; then
echo "Additional check shards failed: $SHARD_RESULT" >&2
exit 1
fi
if [ "$BUILD_ARTIFACTS_RESULT" != "success" ]; then
echo "Build artifact job failed: $BUILD_ARTIFACTS_RESULT" >&2
exit 1
fi
if [ "$GATEWAY_RESULT" != "success" ]; then
echo "Gateway topology check failed: $GATEWAY_RESULT" >&2
exit 1
fi
build-smoke:
permissions:
contents: read
name: "build-smoke"
needs: [preflight, build-artifacts]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_build_smoke == 'true' && (github.event_name != 'push' || needs.build-artifacts.result == 'success') }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify build smoke
env:
BUILD_ARTIFACTS_RESULT: ${{ needs.build-artifacts.result }}
run: |
if [ "$BUILD_ARTIFACTS_RESULT" != "success" ]; then
echo "Build smoke checks failed in build-artifacts: $BUILD_ARTIFACTS_RESULT" >&2
exit 1
fi
# Validate docs (format, lint, broken links) only when docs files changed.
check-docs:
permissions:
contents: read
needs: [preflight]
if: needs.preflight.outputs.run_check_docs == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- name: Checkout
@@ -1436,7 +1762,7 @@ jobs:
contents: read
needs: [preflight]
if: needs.preflight.outputs.run_skills_python_job == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- name: Checkout
@@ -1468,7 +1794,7 @@ jobs:
name: ${{ matrix.check_name }}
needs: [preflight]
if: needs.preflight.outputs.run_checks_windows == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'windows-2025' || (github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-windows-2025' || 'windows-2025') }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-windows-2025' || 'windows-2025' }}
timeout-minutes: 60
env:
NODE_OPTIONS: --max-old-space-size=8192
@@ -1581,7 +1907,7 @@ jobs:
name: ${{ matrix.check_name }}
needs: [preflight]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_macos_node == 'true' }}
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-latest' || (github.repository == 'openclaw/openclaw' && 'blacksmith-6vcpu-macos-latest' || 'macos-latest') }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-6vcpu-macos-latest' || 'macos-latest' }}
timeout-minutes: 20
strategy:
fail-fast: false
@@ -1625,7 +1951,7 @@ jobs:
name: "macos-swift"
needs: [preflight]
if: needs.preflight.outputs.run_macos_swift == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-26' || (github.repository == 'openclaw/openclaw' && 'blacksmith-12vcpu-macos-latest' || 'macos-26') }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-12vcpu-macos-latest' || 'macos-latest' }}
timeout-minutes: 20
steps:
- name: Checkout
@@ -1722,7 +2048,7 @@ jobs:
name: ${{ matrix.check_name }}
needs: [preflight]
if: needs.preflight.outputs.run_android_job == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 20
strategy:
fail-fast: false

View File

@@ -137,10 +137,8 @@ jobs:
env:
OPENAI_API_KEY: ${{ secrets.OPENCLAW_DOCS_I18N_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
OPENCLAW_CONTROL_UI_I18N_PROVIDER: ${{ secrets.ANTHROPIC_API_KEY != '' && 'anthropic' || 'openai' }}
OPENCLAW_CONTROL_UI_I18N_MODEL: ${{ secrets.ANTHROPIC_API_KEY != '' && 'claude-opus-4-7' || vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
OPENCLAW_CONTROL_UI_I18N_MODEL: ${{ vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
OPENCLAW_CONTROL_UI_I18N_THINKING: low
OPENCLAW_CONTROL_UI_I18N_AUTH_OPTIONAL: "1"
LOCALE: ${{ matrix.locale }}
run: node --import tsx scripts/control-ui-i18n.ts sync --locale "${LOCALE}" --write

View File

@@ -1,171 +0,0 @@
name: Dependency Change Awareness
on:
pull_request_target: # zizmor: ignore[dangerous-triggers] metadata-only workflow; no checkout or untrusted code execution
types: [opened, reopened, synchronize, ready_for_review]
permissions:
pull-requests: write
issues: write
concurrency:
group: dependency-change-awareness-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
dependency-change-awareness:
if: ${{ !github.event.pull_request.draft }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Label and comment on dependency changes
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const marker = "<!-- openclaw:dependency-change-awareness -->";
const labelName = "dependencies-changed";
const maxListedFiles = 25;
const pullRequest = context.payload.pull_request;
if (!pullRequest) {
core.info("No pull_request payload found; skipping.");
return;
}
const isDependencyFile = (filename) =>
filename === "package.json" ||
filename === "pnpm-lock.yaml" ||
filename === "pnpm-workspace.yaml" ||
filename === "ui/package.json" ||
filename.startsWith("patches/") ||
/^packages\/[^/]+\/package\.json$/u.test(filename) ||
/^extensions\/[^/]+\/package\.json$/u.test(filename);
const sanitizeDisplayValue = (value) =>
String(value)
.replace(/[\u0000-\u001f\u007f]/gu, "?")
.slice(0, 240);
const markdownCode = (value) =>
`\`${sanitizeDisplayValue(value).replaceAll("`", "\\`")}\``;
const ignoreUnavailableWritePermission = (action) => (error) => {
if (error?.status === 403) {
core.warning(
`Skipping dependency change ${action}; token does not have issue write permission.`,
);
return;
}
if (error?.status === 404 || error?.status === 422) {
core.warning(`Dependency change ${action} is unavailable.`);
return;
}
throw error;
};
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pullRequest.number,
per_page: 100,
});
const dependencyFiles = files
.map((file) => file.filename)
.filter((filename) => typeof filename === "string" && isDependencyFile(filename))
.sort((left, right) => left.localeCompare(right));
const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
per_page: 100,
});
const existingComment = comments.find(
(comment) =>
comment.user?.login === "github-actions[bot]" && comment.body?.includes(marker),
);
const labels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
per_page: 100,
});
const hasLabel = labels.some((label) => label.name === labelName);
if (dependencyFiles.length === 0) {
if (hasLabel) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
name: labelName,
}).catch(ignoreUnavailableWritePermission("label removal"));
}
if (existingComment) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existingComment.id,
}).catch(ignoreUnavailableWritePermission("comment deletion"));
}
await core.summary
.addHeading("Dependency Change Awareness")
.addRaw("No dependency-related file changes detected.")
.write();
core.info("No dependency-related file changes detected.");
return;
}
if (!hasLabel) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
labels: [labelName],
}).catch(ignoreUnavailableWritePermission(`label "${labelName}" update`));
}
const listedFiles = dependencyFiles.slice(0, maxListedFiles);
const omittedCount = dependencyFiles.length - listedFiles.length;
const fileLines = listedFiles.map((filename) => `- ${markdownCode(filename)}`);
if (omittedCount > 0) {
fileLines.push(`- ${omittedCount} additional dependency-related files not shown`);
}
const body = [
marker,
"",
"### Dependency Changes Detected",
"",
"This PR changes dependency-related files. Maintainers should confirm these changes are intentional.",
"",
"Changed files:",
...fileLines,
"",
"Maintainer follow-up:",
"- Review whether the dependency changes are intentional.",
"- Inspect resolved package deltas when lockfile or workspace dependency policy changes are present.",
"- Run `pnpm deps:changes:report -- --base-ref origin/main --markdown /tmp/dependency-changes.md --json /tmp/dependency-changes.json` locally for detailed release-style evidence.",
].join("\n");
if (existingComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existingComment.id,
body,
}).catch(ignoreUnavailableWritePermission("comment update"));
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
body,
}).catch(ignoreUnavailableWritePermission("comment creation"));
}
await core.summary
.addHeading("Dependency Change Awareness")
.addRaw(`Detected ${dependencyFiles.length} dependency-related file change(s).`)
.addList(dependencyFiles.map((filename) => markdownCode(filename)))
.write();
core.notice(`Detected ${dependencyFiles.length} dependency-related file change(s).`);

View File

@@ -16,37 +16,29 @@ permissions:
jobs:
sync-publish-repo:
runs-on: ubuntu-latest
env:
OPENCLAW_DOCS_SYNC_TOKEN: ${{ secrets.OPENCLAW_DOCS_SYNC_TOKEN }}
steps:
- name: Skip publish sync without token
if: env.OPENCLAW_DOCS_SYNC_TOKEN == ''
run: echo "OPENCLAW_DOCS_SYNC_TOKEN is not configured; skipping docs publish repo sync."
- name: Checkout source repo
if: env.OPENCLAW_DOCS_SYNC_TOKEN != ''
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Checkout ClawHub docs source
if: env.OPENCLAW_DOCS_SYNC_TOKEN != ''
uses: actions/checkout@v6
with:
repository: openclaw/clawhub
path: clawhub-source
fetch-depth: 1
persist-credentials: false
token: ${{ env.OPENCLAW_DOCS_SYNC_TOKEN || github.token }}
token: ${{ secrets.OPENCLAW_DOCS_SYNC_TOKEN || github.token }}
- name: Setup Node
if: env.OPENCLAW_DOCS_SYNC_TOKEN != ''
uses: actions/setup-node@v6
with:
node-version: "24.x"
node-version: "22.18.0"
- name: Clone publish repo
if: env.OPENCLAW_DOCS_SYNC_TOKEN != ''
env:
OPENCLAW_DOCS_SYNC_TOKEN: ${{ secrets.OPENCLAW_DOCS_SYNC_TOKEN }}
run: |
set -euo pipefail
for attempt in 1 2 3 4 5; do
@@ -64,7 +56,6 @@ jobs:
exit 1
- name: Sync docs into publish repo
if: env.OPENCLAW_DOCS_SYNC_TOKEN != ''
run: |
clawhub_sha="$(git -C "$GITHUB_WORKSPACE/clawhub-source" rev-parse HEAD)"
node scripts/docs-sync-publish.mjs \
@@ -76,16 +67,13 @@ jobs:
--clawhub-source-sha "$clawhub_sha"
- name: Install docs MDX checker dependency
if: env.OPENCLAW_DOCS_SYNC_TOKEN != ''
working-directory: publish
run: npm install --no-save --package-lock=false @mdx-js/mdx@3.1.1
- name: Check publish docs MDX
if: env.OPENCLAW_DOCS_SYNC_TOKEN != ''
run: node "$GITHUB_WORKSPACE/publish/.openclaw-sync/check-docs-mdx.mjs" "$GITHUB_WORKSPACE/publish/docs"
- name: Commit publish repo sync
if: env.OPENCLAW_DOCS_SYNC_TOKEN != ''
working-directory: publish
run: |
set -euo pipefail

View File

@@ -6,7 +6,6 @@ on:
paths:
- "**/*.md"
- "docs/**"
- "!CHANGELOG.md"
permissions:
contents: read
@@ -36,15 +35,5 @@ jobs:
with:
install-bun: "false"
- name: Checkout ClawHub docs source
uses: actions/checkout@v6
with:
repository: openclaw/clawhub
path: clawhub-source
fetch-depth: 1
persist-credentials: false
- name: Check docs
env:
OPENCLAW_DOCS_SYNC_CLAWHUB_REPO: ${{ github.workspace }}/clawhub-source
run: pnpm check:docs

View File

@@ -297,7 +297,6 @@ jobs:
echo "conclusion=${conclusion}" >> "$GITHUB_OUTPUT"
if [[ "$conclusion" != "success" ]]; then
gh run view "$run_id" --json jobs --jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' || true
exit 1
fi
}
@@ -397,7 +396,6 @@ jobs:
echo "conclusion=${conclusion}" >> "$GITHUB_OUTPUT"
if [[ "$conclusion" != "success" ]]; then
gh run view "$run_id" --json jobs --jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' || true
exit 1
fi
}
@@ -506,7 +504,6 @@ jobs:
echo "conclusion=${conclusion}" >> "$GITHUB_OUTPUT"
if [[ "$conclusion" != "success" ]]; then
gh run view "$run_id" --json jobs --jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' || true
exit 1
fi
}
@@ -638,7 +635,6 @@ jobs:
name: Run package Telegram E2E
needs: [resolve_target, prepare_release_package]
if: ${{ always() && contains(fromJSON('["all","npm-telegram"]'), inputs.rerun_group) && (inputs.npm_telegram_package_spec != '' || inputs.release_package_spec != '' || (inputs.rerun_group == 'all' && inputs.release_profile == 'full')) }}
continue-on-error: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }}
runs-on: ubuntu-24.04
timeout-minutes: ${{ inputs.release_profile == 'full' && 120 || 60 }}
outputs:
@@ -730,7 +726,6 @@ jobs:
echo "conclusion=${conclusion}" >> "$GITHUB_OUTPUT"
if [[ "$conclusion" != "success" ]]; then
gh run view "$run_id" --json jobs --jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' || true
exit 1
fi
summary:
@@ -740,6 +735,62 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Request private evidence update
env:
RELEASE_PRIVATE_DISPATCH_TOKEN: ${{ secrets.OPENCLAW_RELEASES_PRIVATE_DISPATCH_TOKEN }}
TARGET_REF: ${{ inputs.ref }}
PACKAGE_SPEC: ${{ inputs.evidence_package_spec || inputs.npm_telegram_package_spec }}
GITHUB_RUN_ID_VALUE: ${{ github.run_id }}
RELEASE_CHECKS_RESULT: ${{ needs.release_checks.result }}
run: |
set -euo pipefail
if [[ "$RELEASE_CHECKS_RESULT" == "skipped" ]]; then
echo "Release checks were skipped by rerun group; skipping automatic private evidence update."
exit 0
fi
if [[ -z "${RELEASE_PRIVATE_DISPATCH_TOKEN// }" ]]; then
echo "OPENCLAW_RELEASES_PRIVATE_DISPATCH_TOKEN is not configured; skipping automatic private evidence update."
exit 0
fi
release_id="${TARGET_REF#refs/tags/}"
release_id="${release_id#v}"
if [[ "$PACKAGE_SPEC" =~ ^openclaw@(.+)$ ]]; then
release_id="${BASH_REMATCH[1]}"
fi
release_id="$(printf '%s' "$release_id" | tr '/:@ ' '----' | tr -cd 'A-Za-z0-9._-')"
if [[ -z "$release_id" ]]; then
echo "::error::Could not derive release evidence id from target ref '${TARGET_REF}'."
exit 1
fi
payload="$(
jq -cn \
--arg full_validation_run_id "$GITHUB_RUN_ID_VALUE" \
--arg release_id "$release_id" \
--arg release_ref "$TARGET_REF" \
--arg package_spec "$PACKAGE_SPEC" \
--arg notes "Automatically requested by Full Release Validation ${GITHUB_RUN_ID_VALUE} after child workflows completed; the parent summary re-checks current child run conclusions." \
'{
event_type: "openclaw_full_release_validation_completed",
client_payload: {
full_validation_run_id: $full_validation_run_id,
release_id: $release_id,
release_ref: $release_ref,
package_spec: $package_spec,
notes: $notes
}
}'
)"
curl --fail-with-body \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${RELEASE_PRIVATE_DISPATCH_TOKEN}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/openclaw/releases-private/dispatches \
-d "$payload"
- name: Verify child workflow results
env:
GH_TOKEN: ${{ github.token }}
@@ -884,54 +935,6 @@ jobs:
} >> "$GITHUB_STEP_SUMMARY"
}
summarize_failed_child() {
local label="$1"
local run_id="$2"
if [[ -z "${run_id// }" ]]; then
return 0
fi
local run_json status conclusion artifacts_json
run_json="$(gh run view "$run_id" --json status,conclusion,url,jobs)"
status="$(jq -r '.status' <<< "$run_json")"
conclusion="$(jq -r '.conclusion' <<< "$run_json")"
if [[ "$status" == "completed" && "$conclusion" == "success" ]]; then
return 0
fi
{
echo
echo "### Failed child detail: ${label}"
echo
jq -r '
"- Run: " + (.url // ""),
"- Result: `" + (.status // "") + "/" + (.conclusion // "") + "`",
"",
"Failed jobs:",
(.jobs[]
| select(.conclusion != "success" and .conclusion != "skipped")
| "- `" + (.name | gsub("`"; "\\`")) + "`: `" + ((.conclusion // .status // "") | tostring) + "` " + (.url // ""))
' <<< "$run_json" || true
echo
echo "Artifacts:"
artifacts_json="$(
gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/artifacts?per_page=100" 2>/dev/null || true
)"
if [[ -n "${artifacts_json// }" ]]; then
jq -r '
if ((.artifacts // []) | length) == 0 then
"- none"
else
(.artifacts[]
| "- `" + (.name | gsub("`"; "\\`")) + "` (" + ((.size_in_bytes // 0) | tostring) + " bytes)")
end
' <<< "$artifacts_json" || echo "- unable to list artifacts"
else
echo "- unable to list artifacts"
fi
} >> "$GITHUB_STEP_SUMMARY"
}
failed=0
append_child_overview
@@ -956,8 +959,6 @@ jobs:
if [[ "$NPM_TELEGRAM_RESULT" == "skipped" && -z "${NPM_TELEGRAM_RUN_ID// }" ]]; then
check_child "npm_telegram" "" 0 || failed=1
elif [[ "$CHILD_WORKFLOW_REF" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
check_child "npm_telegram" "$NPM_TELEGRAM_RUN_ID" 0 || echo "::warning::npm_telegram is advisory for Tideclaw alpha validation."
else
check_child "npm_telegram" "$NPM_TELEGRAM_RUN_ID" 1 || failed=1
fi
@@ -967,126 +968,4 @@ jobs:
summarize_child_timing "release_checks" "$RELEASE_CHECKS_RUN_ID"
summarize_child_timing "npm_telegram" "$NPM_TELEGRAM_RUN_ID"
if [[ "$failed" != "0" ]]; then
summarize_failed_child "normal_ci" "$NORMAL_CI_RUN_ID"
summarize_failed_child "plugin_prerelease" "$PLUGIN_PRERELEASE_RUN_ID"
summarize_failed_child "release_checks" "$RELEASE_CHECKS_RUN_ID"
summarize_failed_child "npm_telegram" "$NPM_TELEGRAM_RUN_ID"
fi
exit "$failed"
- name: Request private evidence update
env:
RELEASE_PRIVATE_DISPATCH_TOKEN: ${{ secrets.OPENCLAW_RELEASES_PRIVATE_DISPATCH_TOKEN }}
TARGET_REF: ${{ inputs.ref }}
PACKAGE_SPEC: ${{ inputs.evidence_package_spec || inputs.npm_telegram_package_spec }}
GITHUB_RUN_ID_VALUE: ${{ github.run_id }}
RELEASE_CHECKS_RESULT: ${{ needs.release_checks.result }}
run: |
set -euo pipefail
if [[ "$RELEASE_CHECKS_RESULT" == "skipped" ]]; then
echo "Release checks were skipped by rerun group; skipping automatic private evidence update."
exit 0
fi
if [[ -z "${RELEASE_PRIVATE_DISPATCH_TOKEN// }" ]]; then
echo "OPENCLAW_RELEASES_PRIVATE_DISPATCH_TOKEN is not configured; skipping automatic private evidence update."
exit 0
fi
release_id="${TARGET_REF#refs/tags/}"
release_id="${release_id#v}"
if [[ "$PACKAGE_SPEC" =~ ^openclaw@(.+)$ ]]; then
release_id="${BASH_REMATCH[1]}"
fi
release_id="$(printf '%s' "$release_id" | tr '/:@ ' '----' | tr -cd 'A-Za-z0-9._-')"
if [[ -z "$release_id" ]]; then
echo "::warning::Could not derive release evidence id from target ref '${TARGET_REF}'; skipping automatic private evidence update."
exit 0
fi
payload="$(
jq -cn \
--arg full_validation_run_id "$GITHUB_RUN_ID_VALUE" \
--arg release_id "$release_id" \
--arg release_ref "$TARGET_REF" \
--arg package_spec "$PACKAGE_SPEC" \
--arg notes "Automatically requested by Full Release Validation ${GITHUB_RUN_ID_VALUE} after child workflows completed; the parent summary re-checks current child run conclusions." \
'{
event_type: "openclaw_full_release_validation_completed",
client_payload: {
full_validation_run_id: $full_validation_run_id,
release_id: $release_id,
release_ref: $release_ref,
package_spec: $package_spec,
notes: $notes
}
}'
)"
if ! curl --fail-with-body \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${RELEASE_PRIVATE_DISPATCH_TOKEN}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/openclaw/releases-private/dispatches \
-d "$payload"; then
echo "::warning::Automatic private release evidence dispatch failed; child workflow validation remains authoritative."
fi
- name: Write release validation manifest
if: ${{ success() }}
env:
TARGET_REF: ${{ inputs.ref }}
TARGET_SHA: ${{ needs.resolve_target.outputs.sha }}
RELEASE_PROFILE: ${{ inputs.release_profile }}
RERUN_GROUP: ${{ inputs.rerun_group }}
RUN_RELEASE_SOAK: ${{ inputs.run_release_soak || inputs.release_profile == 'full' }}
NORMAL_CI_RUN_ID: ${{ needs.normal_ci.outputs.run_id }}
PLUGIN_PRERELEASE_RUN_ID: ${{ needs.plugin_prerelease.outputs.run_id }}
RELEASE_CHECKS_RUN_ID: ${{ needs.release_checks.outputs.run_id }}
NPM_TELEGRAM_RUN_ID: ${{ needs.npm_telegram.outputs.run_id }}
run: |
set -euo pipefail
manifest_dir="${RUNNER_TEMP}/full-release-validation"
mkdir -p "$manifest_dir"
jq -n \
--arg workflowName "Full Release Validation" \
--arg runId "$GITHUB_RUN_ID" \
--arg runAttempt "$GITHUB_RUN_ATTEMPT" \
--arg workflowRef "$GITHUB_REF_NAME" \
--arg targetRef "$TARGET_REF" \
--arg targetSha "$TARGET_SHA" \
--arg releaseProfile "$RELEASE_PROFILE" \
--arg rerunGroup "$RERUN_GROUP" \
--arg runReleaseSoak "$RUN_RELEASE_SOAK" \
--arg normalCiRunId "$NORMAL_CI_RUN_ID" \
--arg pluginPrereleaseRunId "$PLUGIN_PRERELEASE_RUN_ID" \
--arg releaseChecksRunId "$RELEASE_CHECKS_RUN_ID" \
--arg npmTelegramRunId "$NPM_TELEGRAM_RUN_ID" \
'{
version: 1,
workflowName: $workflowName,
runId: $runId,
runAttempt: $runAttempt,
workflowRef: $workflowRef,
targetRef: $targetRef,
targetSha: $targetSha,
releaseProfile: $releaseProfile,
rerunGroup: $rerunGroup,
runReleaseSoak: $runReleaseSoak,
childRuns: {
normalCi: $normalCiRunId,
pluginPrerelease: $pluginPrereleaseRunId,
releaseChecks: $releaseChecksRunId,
npmTelegram: $npmTelegramRunId
}
}' > "${manifest_dir}/full-release-validation-manifest.json"
- name: Upload release validation manifest
if: ${{ success() }}
uses: actions/upload-artifact@v7
with:
name: full-release-validation-${{ github.run_id }}
path: ${{ runner.temp }}/full-release-validation
if-no-files-found: error

View File

@@ -100,7 +100,7 @@ jobs:
install-smoke-fast:
needs: [preflight]
if: needs.preflight.outputs.run_fast_install_smoke == 'true' && needs.preflight.outputs.run_full_install_smoke != 'true'
runs-on: ubuntu-24.04
runs-on: blacksmith-16vcpu-ubuntu-2404
env:
DOCKER_BUILD_SUMMARY: "false"
DOCKER_BUILD_RECORD_UPLOAD: "false"
@@ -208,7 +208,7 @@ jobs:
root_dockerfile_image:
needs: [preflight]
if: needs.preflight.outputs.run_full_install_smoke == 'true'
runs-on: ubuntu-24.04
runs-on: blacksmith-16vcpu-ubuntu-2404
outputs:
image_ref: ${{ steps.image.outputs.image_ref }}
env:
@@ -284,7 +284,7 @@ jobs:
qr_package_install_smoke:
needs: [preflight]
if: needs.preflight.outputs.run_full_install_smoke == 'true'
runs-on: ubuntu-24.04
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout CLI
uses: actions/checkout@v6
@@ -299,7 +299,7 @@ jobs:
root_dockerfile_smokes:
needs: [preflight, root_dockerfile_image]
if: needs.preflight.outputs.run_full_install_smoke == 'true'
runs-on: ubuntu-24.04
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout CLI
uses: actions/checkout@v6
@@ -401,7 +401,7 @@ jobs:
installer_smoke:
needs: [preflight, root_dockerfile_image]
if: needs.preflight.outputs.run_full_install_smoke == 'true'
runs-on: ubuntu-24.04
runs-on: blacksmith-16vcpu-ubuntu-2404
env:
DOCKER_BUILD_SUMMARY: "false"
DOCKER_BUILD_RECORD_UPLOAD: "false"
@@ -471,7 +471,7 @@ jobs:
bun_global_install_smoke:
needs: [preflight, root_dockerfile_image]
if: needs.preflight.outputs.run_full_install_smoke == 'true' && needs.preflight.outputs.run_bun_global_install_smoke == 'true'
runs-on: ubuntu-24.04
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout CLI
uses: actions/checkout@v6
@@ -505,7 +505,7 @@ jobs:
docker-e2e-fast:
needs: [preflight]
if: needs.preflight.outputs.run_fast_install_smoke == 'true' || needs.preflight.outputs.run_full_install_smoke == 'true'
runs-on: ubuntu-24.04
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 12
env:
DOCKER_BUILD_SUMMARY: "false"

View File

@@ -33,11 +33,8 @@ jobs:
authorize_actor:
name: Authorize workflow actor
runs-on: blacksmith-8vcpu-ubuntu-2404
outputs:
authorized: ${{ steps.permission.outputs.authorized }}
steps:
- name: Require maintainer-level repository access
id: permission
uses: actions/github-script@v8
with:
script: |
@@ -51,18 +48,14 @@ jobs:
const permission = data.permission;
core.info(`Actor ${context.actor} permission: ${permission}`);
if (!allowed.has(permission)) {
core.notice(
core.setFailed(
`Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`,
);
core.setOutput("authorized", "false");
return;
}
core.setOutput("authorized", "true");
validate_selected_ref:
name: Validate selected ref
needs: authorize_actor
if: needs.authorize_actor.outputs.authorized == 'true'
runs-on: blacksmith-8vcpu-ubuntu-2404
outputs:
selected_revision: ${{ steps.validate.outputs.selected_revision }}
@@ -168,7 +161,7 @@ jobs:
- name: Upload Mantis artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: mantis-discord-smoke-${{ github.run_id }}-${{ github.run_attempt }}
path: .artifacts/qa-e2e/mantis/

View File

@@ -21,7 +21,7 @@ on:
type: string
permissions:
contents: read
contents: write
issues: write
pull-requests: write
@@ -46,17 +46,15 @@ jobs:
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
(
contains(github.event.comment.body, '@openclaw-mantis') ||
contains(github.event.comment.body, '/openclaw-mantis')
contains(github.event.comment.body, '@Mantis') ||
contains(github.event.comment.body, '@mantis') ||
contains(github.event.comment.body, '/mantis')
)
)
}}
runs-on: blacksmith-8vcpu-ubuntu-2404
outputs:
authorized: ${{ steps.permission.outputs.authorized }}
steps:
- name: Require maintainer-level repository access
id: permission
uses: actions/github-script@v8
with:
script: |
@@ -70,18 +68,14 @@ jobs:
const permission = data.permission;
core.info(`Actor ${context.actor} permission: ${permission}`);
if (!allowed.has(permission)) {
core.notice(
core.setFailed(
`Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`,
);
core.setOutput("authorized", "false");
return;
}
core.setOutput("authorized", "true");
resolve_request:
name: Resolve Mantis request
needs: authorize_actor
if: needs.authorize_actor.outputs.authorized == 'true'
runs-on: blacksmith-8vcpu-ubuntu-2404
outputs:
baseline_ref: ${{ steps.resolve.outputs.baseline_ref }}
@@ -127,7 +121,7 @@ jobs:
const normalized = body.toLowerCase();
const requested =
(normalized.includes("@openclaw-mantis") || normalized.includes("/openclaw-mantis")) &&
(normalized.includes("@mantis") || normalized.includes("/mantis")) &&
normalized.includes("discord") &&
normalized.includes("status") &&
normalized.includes("reaction");
@@ -348,8 +342,8 @@ jobs:
--repo-root "$repo_root" \
--output-dir "$output_dir" \
--provider-mode live-frontier \
--model openai/gpt-5.5 \
--alt-model openai/gpt-5.5 \
--model openai/gpt-5.4 \
--alt-model openai/gpt-5.4 \
--fast \
--credential-source convex \
--credential-role ci \
@@ -528,7 +522,7 @@ jobs:
- name: Upload Mantis status reaction artifacts
id: upload_artifact
if: ${{ always() && steps.run_mantis.outputs.output_dir != '' }}
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: mantis-discord-status-reactions-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_mantis.outputs.output_dir }}
@@ -544,6 +538,7 @@ jobs:
private-key: ${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: ${{ github.event.repository.name }}
permission-contents: write
permission-issues: write
permission-pull-requests: write
@@ -551,15 +546,9 @@ jobs:
if: ${{ always() && needs.resolve_request.outputs.pr_number != '' && steps.run_mantis.outputs.output_dir != '' }}
env:
GH_TOKEN: ${{ steps.mantis_app_token.outputs.token }}
ARTIFACT_URL: ${{ steps.upload_artifact.outputs.artifact-url }}
MANTIS_ARTIFACT_R2_ACCESS_KEY_ID: ${{ secrets.MANTIS_ARTIFACT_R2_ACCESS_KEY_ID }}
MANTIS_ARTIFACT_R2_BUCKET: openclaw-crabbox-artifacts
MANTIS_ARTIFACT_R2_ENDPOINT: ${{ vars.MANTIS_ARTIFACT_R2_ENDPOINT }}
MANTIS_ARTIFACT_R2_PUBLIC_BASE_URL: https://artifacts.openclaw.ai
MANTIS_ARTIFACT_R2_REGION: auto
MANTIS_ARTIFACT_R2_SECRET_ACCESS_KEY: ${{ secrets.MANTIS_ARTIFACT_R2_SECRET_ACCESS_KEY }}
REQUEST_SOURCE: ${{ needs.resolve_request.outputs.request_source }}
TARGET_PR: ${{ needs.resolve_request.outputs.pr_number }}
ARTIFACT_URL: ${{ steps.upload_artifact.outputs.artifact-url }}
REQUEST_SOURCE: ${{ needs.resolve_request.outputs.request_source }}
shell: bash
run: |
set -euo pipefail
@@ -573,44 +562,3 @@ jobs:
--artifact-url "$ARTIFACT_URL" \
--run-url "https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
--request-source "$REQUEST_SOURCE"
clear_issue_comment_reaction:
name: Clear Mantis command reaction
needs: [resolve_request, validate_refs, run_status_reactions]
if: ${{ always() && github.event_name == 'issue_comment' && needs.resolve_request.outputs.request_source == 'issue_comment' }}
runs-on: ubuntu-24.04
permissions:
issues: write
steps:
- name: Remove workflow eyes reaction
uses: actions/github-script@v8
with:
script: |
const { owner, repo } = context.repo;
const commentId = context.payload.comment?.id;
if (!commentId) {
core.info("No issue comment id found; skipping reaction cleanup.");
return;
}
const reactions = await github.paginate(github.rest.reactions.listForIssueComment, {
owner,
repo,
comment_id: commentId,
per_page: 100,
});
const eyes = reactions.filter(
(reaction) => reaction.content === "eyes" && reaction.user?.login === "github-actions[bot]",
);
for (const reaction of eyes) {
await github.rest.reactions.deleteForIssueComment({
owner,
repo,
comment_id: commentId,
reaction_id: reaction.id,
});
core.info(`Removed eyes reaction ${reaction.id} from comment ${commentId}.`);
}
if (eyes.length === 0) {
core.info(`No workflow eyes reaction found on comment ${commentId}.`);
}

View File

@@ -21,7 +21,7 @@ on:
type: string
permissions:
contents: read
contents: write
issues: write
pull-requests: write
@@ -46,17 +46,15 @@ jobs:
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
(
contains(github.event.comment.body, '@openclaw-mantis') ||
contains(github.event.comment.body, '/openclaw-mantis')
contains(github.event.comment.body, '@Mantis') ||
contains(github.event.comment.body, '@mantis') ||
contains(github.event.comment.body, '/mantis')
)
)
}}
runs-on: blacksmith-8vcpu-ubuntu-2404
outputs:
authorized: ${{ steps.permission.outputs.authorized }}
steps:
- name: Require maintainer-level repository access
id: permission
uses: actions/github-script@v8
with:
script: |
@@ -70,18 +68,14 @@ jobs:
const permission = data.permission;
core.info(`Actor ${context.actor} permission: ${permission}`);
if (!allowed.has(permission)) {
core.notice(
core.setFailed(
`Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`,
);
core.setOutput("authorized", "false");
return;
}
core.setOutput("authorized", "true");
resolve_request:
name: Resolve Mantis request
needs: authorize_actor
if: needs.authorize_actor.outputs.authorized == 'true'
runs-on: blacksmith-8vcpu-ubuntu-2404
outputs:
baseline_ref: ${{ steps.resolve.outputs.baseline_ref }}
@@ -127,7 +121,7 @@ jobs:
const normalized = body.toLowerCase();
const requested =
(normalized.includes("@openclaw-mantis") || normalized.includes("/openclaw-mantis")) &&
(normalized.includes("@mantis") || normalized.includes("/mantis")) &&
normalized.includes("discord") &&
normalized.includes("thread") &&
(normalized.includes("attachment") ||
@@ -536,7 +530,7 @@ jobs:
- name: Upload Mantis thread attachment artifacts
id: upload_artifact
if: ${{ always() && steps.run_mantis.outputs.output_dir != '' }}
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: mantis-discord-thread-attachment-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_mantis.outputs.output_dir }}
@@ -552,6 +546,7 @@ jobs:
private-key: ${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: ${{ github.event.repository.name }}
permission-contents: write
permission-issues: write
permission-pull-requests: write
@@ -559,15 +554,9 @@ jobs:
if: ${{ always() && needs.resolve_request.outputs.pr_number != '' && steps.run_mantis.outputs.output_dir != '' }}
env:
GH_TOKEN: ${{ steps.mantis_app_token.outputs.token }}
ARTIFACT_URL: ${{ steps.upload_artifact.outputs.artifact-url }}
MANTIS_ARTIFACT_R2_ACCESS_KEY_ID: ${{ secrets.MANTIS_ARTIFACT_R2_ACCESS_KEY_ID }}
MANTIS_ARTIFACT_R2_BUCKET: openclaw-crabbox-artifacts
MANTIS_ARTIFACT_R2_ENDPOINT: ${{ vars.MANTIS_ARTIFACT_R2_ENDPOINT }}
MANTIS_ARTIFACT_R2_PUBLIC_BASE_URL: https://artifacts.openclaw.ai
MANTIS_ARTIFACT_R2_REGION: auto
MANTIS_ARTIFACT_R2_SECRET_ACCESS_KEY: ${{ secrets.MANTIS_ARTIFACT_R2_SECRET_ACCESS_KEY }}
REQUEST_SOURCE: ${{ needs.resolve_request.outputs.request_source }}
TARGET_PR: ${{ needs.resolve_request.outputs.pr_number }}
ARTIFACT_URL: ${{ steps.upload_artifact.outputs.artifact-url }}
REQUEST_SOURCE: ${{ needs.resolve_request.outputs.request_source }}
shell: bash
run: |
set -euo pipefail
@@ -595,44 +584,3 @@ jobs:
run: |
echo "Mantis comparison failed." >&2
exit 1
clear_issue_comment_reaction:
name: Clear Mantis command reaction
needs: [resolve_request, validate_candidate, run_thread_attachment]
if: ${{ always() && github.event_name == 'issue_comment' && needs.resolve_request.outputs.request_source == 'issue_comment' }}
runs-on: ubuntu-24.04
permissions:
issues: write
steps:
- name: Remove workflow eyes reaction
uses: actions/github-script@v8
with:
script: |
const { owner, repo } = context.repo;
const commentId = context.payload.comment?.id;
if (!commentId) {
core.info("No issue comment id found; skipping reaction cleanup.");
return;
}
const reactions = await github.paginate(github.rest.reactions.listForIssueComment, {
owner,
repo,
comment_id: commentId,
per_page: 100,
});
const eyes = reactions.filter(
(reaction) => reaction.content === "eyes" && reaction.user?.login === "github-actions[bot]",
);
for (const reaction of eyes) {
await github.rest.reactions.deleteForIssueComment({
owner,
repo,
comment_id: commentId,
reaction_id: reaction.id,
});
core.info(`Removed eyes reaction ${reaction.id} from comment ${commentId}.`);
}
if (eyes.length === 0) {
core.info(`No workflow eyes reaction found on comment ${commentId}.`);
}

View File

@@ -44,7 +44,7 @@ on:
- prehydrated
permissions:
contents: read
contents: write
issues: write
pull-requests: write
@@ -64,11 +64,8 @@ jobs:
authorize_actor:
name: Authorize workflow actor
runs-on: ubuntu-24.04
outputs:
authorized: ${{ steps.permission.outputs.authorized }}
steps:
- name: Require maintainer-level repository access
id: permission
uses: actions/github-script@v8
with:
script: |
@@ -82,18 +79,14 @@ jobs:
const permission = data.permission;
core.info(`Actor ${context.actor} permission: ${permission}`);
if (!allowed.has(permission)) {
core.notice(
core.setFailed(
`Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`,
);
core.setOutput("authorized", "false");
return;
}
core.setOutput("authorized", "true");
validate_ref:
name: Validate candidate ref
needs: authorize_actor
if: needs.authorize_actor.outputs.authorized == 'true'
runs-on: ubuntu-24.04
outputs:
candidate_revision: ${{ steps.validate.outputs.candidate_revision }}
@@ -281,8 +274,8 @@ jobs:
--credential-role ci \
--provider-mode live-frontier \
--hydrate-mode "$HYDRATE_MODE" \
--model openai/gpt-5.5 \
--alt-model openai/gpt-5.5 \
--model openai/gpt-5.4 \
--alt-model openai/gpt-5.4 \
--fast \
--scenario "$SCENARIO_ID" \
"${keep_args[@]}" \
@@ -359,7 +352,7 @@ jobs:
- name: Upload Mantis Slack desktop artifacts
id: upload_artifact
if: ${{ always() && steps.run_mantis.outputs.output_dir != '' }}
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: mantis-slack-desktop-smoke-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_mantis.outputs.output_dir }}
@@ -375,6 +368,7 @@ jobs:
private-key: ${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: ${{ github.event.repository.name }}
permission-contents: write
permission-issues: write
permission-pull-requests: write
@@ -382,15 +376,9 @@ jobs:
if: ${{ always() && inputs.pr_number != '' && steps.run_mantis.outputs.output_dir != '' && steps.upload_artifact.outputs.artifact-url != '' }}
env:
GH_TOKEN: ${{ steps.mantis_app_token.outputs.token }}
ARTIFACT_URL: ${{ steps.upload_artifact.outputs.artifact-url }}
MANTIS_ARTIFACT_R2_ACCESS_KEY_ID: ${{ secrets.MANTIS_ARTIFACT_R2_ACCESS_KEY_ID }}
MANTIS_ARTIFACT_R2_BUCKET: openclaw-crabbox-artifacts
MANTIS_ARTIFACT_R2_ENDPOINT: ${{ vars.MANTIS_ARTIFACT_R2_ENDPOINT }}
MANTIS_ARTIFACT_R2_PUBLIC_BASE_URL: https://artifacts.openclaw.ai
MANTIS_ARTIFACT_R2_REGION: auto
MANTIS_ARTIFACT_R2_SECRET_ACCESS_KEY: ${{ secrets.MANTIS_ARTIFACT_R2_SECRET_ACCESS_KEY }}
REQUEST_SOURCE: workflow_dispatch
TARGET_PR: ${{ inputs.pr_number }}
ARTIFACT_URL: ${{ steps.upload_artifact.outputs.artifact-url }}
REQUEST_SOURCE: workflow_dispatch
shell: bash
run: |
set -euo pipefail

View File

@@ -3,8 +3,6 @@ name: Mantis Telegram Desktop Proof
on:
issue_comment:
types: [created]
pull_request_target: # zizmor: ignore[dangerous-triggers] maintainer-owned Mantis label trigger; trusted base workflow validates refs before checkout/use
types: [labeled]
workflow_dispatch:
inputs:
pr_number:
@@ -27,18 +25,10 @@ on:
description: Optional existing Crabbox desktop lease id or slug to reuse
required: false
type: string
publish_artifact_name:
description: Optional existing proof artifact name to publish without recapturing
required: false
type: string
publish_run_id:
description: Workflow run id that owns publish_artifact_name; required with publish_artifact_name
required: false
type: string
permissions:
actions: read
contents: read
contents: write
issues: write
pull-requests: write
@@ -57,11 +47,6 @@ jobs:
if: >-
${{
github.event_name == 'workflow_dispatch' ||
(
github.event_name == 'pull_request_target' &&
github.event.action == 'labeled' &&
github.event.label.name == 'mantis: telegram-visible-proof'
) ||
(
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
@@ -73,20 +58,11 @@ jobs:
)
}}
runs-on: ubuntu-24.04
outputs:
authorized: ${{ steps.permission.outputs.authorized }}
steps:
- name: Require maintainer-level repository access
id: permission
uses: actions/github-script@v8
with:
script: |
if (context.eventName === "pull_request_target") {
core.info(`Accepted Mantis label trigger from ${context.actor}.`);
core.setOutput("authorized", "true");
return;
}
const allowed = new Set(["admin", "maintain", "write"]);
const { owner, repo } = context.repo;
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
@@ -97,18 +73,14 @@ jobs:
const permission = data.permission;
core.info(`Actor ${context.actor} permission: ${permission}`);
if (!allowed.has(permission)) {
core.notice(
core.setFailed(
`Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`,
);
core.setOutput("authorized", "false");
return;
}
core.setOutput("authorized", "true");
resolve_request:
name: Resolve Mantis request
needs: authorize_actor
if: needs.authorize_actor.outputs.authorized == 'true'
runs-on: ubuntu-24.04
outputs:
baseline_ref: ${{ steps.resolve.outputs.baseline_ref }}
@@ -116,11 +88,8 @@ jobs:
crabbox_provider: ${{ steps.resolve.outputs.crabbox_provider }}
instructions: ${{ steps.resolve.outputs.instructions }}
lease_id: ${{ steps.resolve.outputs.lease_id }}
publish_artifact_name: ${{ steps.resolve.outputs.publish_artifact_name }}
publish_run_id: ${{ steps.resolve.outputs.publish_run_id }}
pr_number: ${{ steps.resolve.outputs.pr_number }}
request_source: ${{ steps.resolve.outputs.request_source }}
should_run: ${{ steps.resolve.outputs.should_run }}
steps:
- name: Resolve refs and target PR
id: resolve
@@ -136,70 +105,31 @@ jobs:
const inputs = context.payload.inputs ?? {};
const prNumber =
eventName === "workflow_dispatch"
? inputs.pr_number
: eventName === "pull_request_target"
? String(context.payload.pull_request?.number ?? "")
: String(context.payload.issue?.number ?? "");
eventName === "workflow_dispatch" ? inputs.pr_number : String(context.payload.issue?.number ?? "");
if (!prNumber) {
core.setFailed("Mantis Telegram desktop proof requires a pull request.");
return;
}
const body =
eventName === "workflow_dispatch"
? inputs.instructions || ""
: eventName === "issue_comment"
? context.payload.comment?.body || ""
: "";
if (eventName === "issue_comment") {
const normalized = body.toLowerCase();
const requestedDesktopProof =
(normalized.includes("@openclaw-mantis") || normalized.includes("/openclaw-mantis")) &&
(normalized.includes("desktop proof") ||
normalized.includes("desktop-proof") ||
normalized.includes("telegram desktop") ||
normalized.includes("native telegram") ||
normalized.includes("visible proof") ||
normalized.includes("visible-proof") ||
normalized.includes("telegram-visible-proof"));
if (!requestedDesktopProof) {
core.notice("Comment mentioned Mantis but did not request Telegram desktop proof.");
setOutput("should_run", "false");
setOutput("baseline_ref", "");
setOutput("candidate_ref", "");
setOutput("pr_number", "");
setOutput("instructions", "");
setOutput("crabbox_provider", "");
setOutput("lease_id", "");
setOutput("publish_artifact_name", "");
setOutput("publish_run_id", "");
setOutput("request_source", "unsupported_issue_comment");
return;
}
}
const { owner, repo } = context.repo;
const { data: pr } = await github.rest.pulls.get({
owner,
repo,
pull_number: Number(prNumber),
});
const body = eventName === "workflow_dispatch" ? inputs.instructions || "" : context.payload.comment?.body || "";
const provider = inputs.crabbox_provider || "aws";
if (!["aws", "hetzner"].includes(provider)) {
core.setFailed(`Unsupported Crabbox provider for Mantis Telegram desktop proof: ${provider}`);
return;
}
setOutput("should_run", "true");
setOutput("baseline_ref", pr.base.sha);
setOutput("candidate_ref", pr.head.sha);
setOutput("pr_number", String(pr.number));
setOutput("instructions", body);
setOutput("crabbox_provider", provider);
setOutput("lease_id", inputs.crabbox_lease_id || "");
setOutput("publish_artifact_name", inputs.publish_artifact_name || "");
setOutput("publish_run_id", inputs.publish_run_id || "");
setOutput("request_source", eventName);
if (eventName === "issue_comment") {
@@ -214,7 +144,6 @@ jobs:
validate_refs:
name: Validate selected refs
needs: resolve_request
if: needs.resolve_request.outputs.should_run == 'true' && needs.resolve_request.outputs.publish_artifact_name == ''
runs-on: ubuntu-24.04
outputs:
baseline_revision: ${{ steps.validate.outputs.baseline_revision }}
@@ -293,7 +222,6 @@ jobs:
run_telegram_desktop_proof:
name: Run agentic native Telegram proof
needs: [resolve_request, validate_refs]
if: needs.resolve_request.outputs.should_run == 'true' && needs.resolve_request.outputs.publish_artifact_name == ''
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 360
environment: qa-live-shared
@@ -311,13 +239,11 @@ jobs:
while true; do
blockers="$(
for workflow in mantis-telegram-desktop-proof.yml mantis-telegram-live.yml; do
for status in queued in_progress waiting pending requested; do
gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --status "$status" --limit 100 --json databaseId,status,createdAt,url \
| jq -r \
--argjson current_id "$GITHUB_RUN_ID" \
--arg current_created "$current_created" \
'.[] | select(.databaseId != $current_id) | select(.createdAt < $current_created or (.createdAt == $current_created and .databaseId < $current_id)) | "\(.createdAt)\t#\(.databaseId)\t\(.status)\t\(.url)"'
done
gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --limit 100 --json databaseId,status,createdAt,url \
| jq -r \
--argjson current_id "$GITHUB_RUN_ID" \
--arg current_created "$current_created" \
'.[] | select(.databaseId != $current_id) | select(.createdAt < $current_created or (.createdAt == $current_created and .databaseId < $current_id)) | select(.status == "queued" or .status == "in_progress" or .status == "waiting" or .status == "pending" or .status == "requested") | "\(.createdAt)\t#\(.databaseId)\t\(.status)\t\(.url)"'
done | sort -u
)"
if [[ -z "$blockers" ]]; then
@@ -454,13 +380,11 @@ jobs:
openai-api-key: ${{ secrets.OPENCLAW_MANTIS_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
prompt-file: .github/codex/prompts/mantis-telegram-desktop-proof.md
model: ${{ vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
effort: medium
effort: high
sandbox: danger-full-access
codex-args: '["-c","service_tier=\"fast\""]'
codex-home: /tmp/mantis-codex-home-${{ github.run_id }}
safety-strategy: unprivileged-user
codex-user: codex
allow-bot-users: clawsweeper[bot]
- name: Inspect Mantis evidence manifest
id: inspect
@@ -481,7 +405,7 @@ jobs:
- name: Upload Mantis Telegram desktop artifacts
id: upload_artifact
if: ${{ always() && steps.inspect.outputs.output_dir != '' }}
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: mantis-telegram-desktop-proof-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.inspect.outputs.output_dir }}
@@ -497,6 +421,7 @@ jobs:
private-key: ${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: ${{ github.event.repository.name }}
permission-contents: write
permission-issues: write
permission-pull-requests: write
@@ -505,12 +430,6 @@ jobs:
env:
ARTIFACT_URL: ${{ steps.upload_artifact.outputs.artifact-url }}
GH_TOKEN: ${{ steps.mantis_app_token.outputs.token }}
MANTIS_ARTIFACT_R2_ACCESS_KEY_ID: ${{ secrets.MANTIS_ARTIFACT_R2_ACCESS_KEY_ID }}
MANTIS_ARTIFACT_R2_BUCKET: openclaw-crabbox-artifacts
MANTIS_ARTIFACT_R2_ENDPOINT: ${{ vars.MANTIS_ARTIFACT_R2_ENDPOINT }}
MANTIS_ARTIFACT_R2_PUBLIC_BASE_URL: https://artifacts.openclaw.ai
MANTIS_ARTIFACT_R2_REGION: auto
MANTIS_ARTIFACT_R2_SECRET_ACCESS_KEY: ${{ secrets.MANTIS_ARTIFACT_R2_SECRET_ACCESS_KEY }}
REQUEST_SOURCE: ${{ needs.resolve_request.outputs.request_source }}
TARGET_PR: ${{ needs.resolve_request.outputs.pr_number }}
shell: bash
@@ -541,133 +460,3 @@ jobs:
run: |
echo "Mantis Telegram desktop proof failed: comparison=${COMPARISON_STATUS:-unset}." >&2
exit 1
publish_existing_telegram_desktop_proof:
name: Publish existing native Telegram proof
needs: resolve_request
if: needs.resolve_request.outputs.should_run == 'true' && needs.resolve_request.outputs.publish_artifact_name != ''
runs-on: ubuntu-24.04
environment: qa-live-shared
steps:
- name: Checkout harness ref
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "true"
- name: Download existing proof artifact
env:
GH_TOKEN: ${{ github.token }}
PUBLISH_ARTIFACT_NAME: ${{ needs.resolve_request.outputs.publish_artifact_name }}
PUBLISH_RUN_ID: ${{ needs.resolve_request.outputs.publish_run_id }}
shell: bash
run: |
set -euo pipefail
if [[ -z "${PUBLISH_RUN_ID:-}" ]]; then
echo "publish_run_id is required when publish_artifact_name is set." >&2
exit 1
fi
run_id="$PUBLISH_RUN_ID"
gh run download "$run_id" \
--repo "$GITHUB_REPOSITORY" \
--name "$PUBLISH_ARTIFACT_NAME" \
--dir "$MANTIS_OUTPUT_DIR"
artifacts_json="$(
gh api \
-H "Accept: application/vnd.github+json" \
"repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/artifacts"
)"
artifact_id="$(jq -r --arg name "$PUBLISH_ARTIFACT_NAME" '.artifacts[] | select(.name == $name) | .id' <<<"$artifacts_json" | head -n 1)"
if [[ -z "$artifact_id" || "$artifact_id" == "null" ]]; then
echo "Could not resolve artifact id for '${PUBLISH_ARTIFACT_NAME}' in run ${run_id}." >&2
exit 1
fi
echo "PUBLISH_RUN_ID=${run_id}" >> "$GITHUB_ENV"
echo "PUBLISH_ARTIFACT_URL=https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}/artifacts/${artifact_id}" >> "$GITHUB_ENV"
- name: Create Mantis GitHub App token
id: mantis_app_token
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.MANTIS_GITHUB_APP_ID }}
private-key: ${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: ${{ github.event.repository.name }}
permission-issues: write
permission-pull-requests: write
- name: Comment PR with inline QA evidence
env:
GH_TOKEN: ${{ steps.mantis_app_token.outputs.token }}
MANTIS_ARTIFACT_R2_ACCESS_KEY_ID: ${{ secrets.MANTIS_ARTIFACT_R2_ACCESS_KEY_ID }}
MANTIS_ARTIFACT_R2_BUCKET: openclaw-crabbox-artifacts
MANTIS_ARTIFACT_R2_ENDPOINT: ${{ vars.MANTIS_ARTIFACT_R2_ENDPOINT }}
MANTIS_ARTIFACT_R2_PUBLIC_BASE_URL: https://artifacts.openclaw.ai
MANTIS_ARTIFACT_R2_REGION: auto
MANTIS_ARTIFACT_R2_SECRET_ACCESS_KEY: ${{ secrets.MANTIS_ARTIFACT_R2_SECRET_ACCESS_KEY }}
REQUEST_SOURCE: ${{ needs.resolve_request.outputs.request_source }}
TARGET_PR: ${{ needs.resolve_request.outputs.pr_number }}
shell: bash
run: |
set -euo pipefail
root="$MANTIS_OUTPUT_DIR"
if [[ ! -f "$root/mantis-evidence.json" ]]; then
echo "Downloaded artifact does not contain ${root}/mantis-evidence.json." >&2
exit 1
fi
node scripts/mantis/publish-pr-evidence.mjs \
--manifest "$root/mantis-evidence.json" \
--target-pr "$TARGET_PR" \
--artifact-root "mantis/telegram-desktop/pr-${TARGET_PR}/published-${PUBLISH_RUN_ID}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \
--marker "<!-- mantis-telegram-desktop-proof -->" \
--artifact-url "$PUBLISH_ARTIFACT_URL" \
--run-url "https://github.com/${GITHUB_REPOSITORY}/actions/runs/${PUBLISH_RUN_ID}" \
--request-source "$REQUEST_SOURCE"
clear_issue_comment_reaction:
name: Clear Mantis command reaction
needs: [resolve_request, validate_refs, run_telegram_desktop_proof]
if: ${{ always() && github.event_name == 'issue_comment' && needs.resolve_request.outputs.request_source == 'issue_comment' }}
runs-on: ubuntu-24.04
permissions:
issues: write
steps:
- name: Remove workflow eyes reaction
uses: actions/github-script@v8
with:
script: |
const { owner, repo } = context.repo;
const commentId = context.payload.comment?.id;
if (!commentId) {
core.info("No issue comment id found; skipping reaction cleanup.");
return;
}
const reactions = await github.paginate(github.rest.reactions.listForIssueComment, {
owner,
repo,
comment_id: commentId,
per_page: 100,
});
const eyes = reactions.filter(
(reaction) => reaction.content === "eyes" && reaction.user?.login === "github-actions[bot]",
);
for (const reaction of eyes) {
await github.rest.reactions.deleteForIssueComment({
owner,
repo,
comment_id: commentId,
reaction_id: reaction.id,
});
core.info(`Removed eyes reaction ${reaction.id} from comment ${commentId}.`);
}
if (eyes.length === 0) {
core.info(`No workflow eyes reaction found on comment ${commentId}.`);
}

View File

@@ -34,7 +34,7 @@ on:
permissions:
actions: read
contents: read
contents: write
issues: write
pull-requests: write
@@ -56,17 +56,15 @@ jobs:
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
(
contains(github.event.comment.body, '@openclaw-mantis') ||
contains(github.event.comment.body, '/openclaw-mantis')
contains(github.event.comment.body, '@Mantis') ||
contains(github.event.comment.body, '@mantis') ||
contains(github.event.comment.body, '/mantis')
)
)
}}
runs-on: ubuntu-24.04
outputs:
authorized: ${{ steps.permission.outputs.authorized }}
steps:
- name: Require maintainer-level repository access
id: permission
uses: actions/github-script@v8
with:
script: |
@@ -80,18 +78,14 @@ jobs:
const permission = data.permission;
core.info(`Actor ${context.actor} permission: ${permission}`);
if (!allowed.has(permission)) {
core.notice(
core.setFailed(
`Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`,
);
core.setOutput("authorized", "false");
return;
}
core.setOutput("authorized", "true");
resolve_request:
name: Resolve Mantis request
needs: authorize_actor
if: needs.authorize_actor.outputs.authorized == 'true'
runs-on: ubuntu-24.04
outputs:
candidate_ref: ${{ steps.resolve.outputs.candidate_ref }}
@@ -139,18 +133,9 @@ jobs:
}
const normalized = body.toLowerCase();
const requestedDesktopProof =
normalized.includes("desktop proof") ||
normalized.includes("desktop-proof") ||
normalized.includes("telegram desktop") ||
normalized.includes("native telegram") ||
normalized.includes("visible proof") ||
normalized.includes("visible-proof") ||
normalized.includes("telegram-visible-proof");
const requested =
(normalized.includes("@openclaw-mantis") || normalized.includes("/openclaw-mantis")) &&
normalized.includes("telegram") &&
!requestedDesktopProof;
(normalized.includes("@mantis") || normalized.includes("/mantis")) &&
normalized.includes("telegram");
if (!requested) {
core.notice("Comment mentioned Mantis but did not request Telegram live QA.");
setOutput("should_run", "false");
@@ -275,13 +260,11 @@ jobs:
while true; do
blockers="$(
for workflow in mantis-telegram-desktop-proof.yml mantis-telegram-live.yml; do
for status in queued in_progress waiting pending requested; do
gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --status "$status" --limit 100 --json databaseId,status,createdAt,url \
| jq -r \
--argjson current_id "$GITHUB_RUN_ID" \
--arg current_created "$current_created" \
'.[] | select(.databaseId != $current_id) | select(.createdAt < $current_created or (.createdAt == $current_created and .databaseId < $current_id)) | "\(.createdAt)\t#\(.databaseId)\t\(.status)\t\(.url)"'
done
gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --limit 100 --json databaseId,status,createdAt,url \
| jq -r \
--argjson current_id "$GITHUB_RUN_ID" \
--arg current_created "$current_created" \
'.[] | select(.databaseId != $current_id) | select(.createdAt < $current_created or (.createdAt == $current_created and .databaseId < $current_id)) | select(.status == "queued" or .status == "in_progress" or .status == "waiting" or .status == "pending" or .status == "requested") | "\(.createdAt)\t#\(.databaseId)\t\(.status)\t\(.url)"'
done | sort -u
)"
if [[ -z "$blockers" ]]; then
@@ -396,7 +379,7 @@ jobs:
output_rel=".artifacts/qa-e2e/mantis/telegram-live"
root="$candidate_repo/$output_rel"
echo "output_dir=${root}" >> "$GITHUB_OUTPUT"
model="${OPENCLAW_CI_OPENAI_MODEL:-openai/gpt-5.5}"
model="${OPENCLAW_CI_OPENAI_MODEL:-openai/gpt-5.4}"
scenario_args=()
if [[ -n "${SCENARIO_INPUT// }" ]]; then
@@ -481,7 +464,7 @@ jobs:
- name: Upload Mantis Telegram artifacts
id: upload_artifact
if: ${{ always() && steps.run_mantis.outputs.output_dir != '' }}
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: mantis-telegram-live-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_mantis.outputs.output_dir }}
@@ -497,6 +480,7 @@ jobs:
private-key: ${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: ${{ github.event.repository.name }}
permission-contents: write
permission-issues: write
permission-pull-requests: write
@@ -504,15 +488,9 @@ jobs:
if: ${{ always() && needs.resolve_request.outputs.pr_number != '' && steps.run_mantis.outputs.output_dir != '' }}
env:
GH_TOKEN: ${{ steps.mantis_app_token.outputs.token }}
ARTIFACT_URL: ${{ steps.upload_artifact.outputs.artifact-url }}
MANTIS_ARTIFACT_R2_ACCESS_KEY_ID: ${{ secrets.MANTIS_ARTIFACT_R2_ACCESS_KEY_ID }}
MANTIS_ARTIFACT_R2_BUCKET: openclaw-crabbox-artifacts
MANTIS_ARTIFACT_R2_ENDPOINT: ${{ vars.MANTIS_ARTIFACT_R2_ENDPOINT }}
MANTIS_ARTIFACT_R2_PUBLIC_BASE_URL: https://artifacts.openclaw.ai
MANTIS_ARTIFACT_R2_REGION: auto
MANTIS_ARTIFACT_R2_SECRET_ACCESS_KEY: ${{ secrets.MANTIS_ARTIFACT_R2_SECRET_ACCESS_KEY }}
REQUEST_SOURCE: ${{ needs.resolve_request.outputs.request_source }}
TARGET_PR: ${{ needs.resolve_request.outputs.pr_number }}
ARTIFACT_URL: ${{ steps.upload_artifact.outputs.artifact-url }}
REQUEST_SOURCE: ${{ needs.resolve_request.outputs.request_source }}
shell: bash
run: |
set -euo pipefail
@@ -542,44 +520,3 @@ jobs:
run: |
echo "Mantis Telegram live failed: comparison=${COMPARISON_STATUS:-unset} telegram_exit=${TELEGRAM_EXIT:-unset}." >&2
exit 1
clear_issue_comment_reaction:
name: Clear Mantis command reaction
needs: [resolve_request, validate_ref, run_telegram_live]
if: ${{ always() && github.event_name == 'issue_comment' && needs.resolve_request.outputs.request_source == 'issue_comment' }}
runs-on: ubuntu-24.04
permissions:
issues: write
steps:
- name: Remove workflow eyes reaction
uses: actions/github-script@v8
with:
script: |
const { owner, repo } = context.repo;
const commentId = context.payload.comment?.id;
if (!commentId) {
core.info("No issue comment id found; skipping reaction cleanup.");
return;
}
const reactions = await github.paginate(github.rest.reactions.listForIssueComment, {
owner,
repo,
comment_id: commentId,
per_page: 100,
});
const eyes = reactions.filter(
(reaction) => reaction.content === "eyes" && reaction.user?.login === "github-actions[bot]",
);
for (const reaction of eyes) {
await github.rest.reactions.deleteForIssueComment({
owner,
repo,
comment_id: commentId,
reaction_id: reaction.id,
});
core.info(`Removed eyes reaction ${reaction.id} from comment ${commentId}.`);
}
if (eyes.length === 0) {
core.info(`No workflow eyes reaction found on comment ${commentId}.`);
}

View File

@@ -40,18 +40,8 @@ on:
description: Optional comma-separated Telegram scenario ids
required: false
type: string
advisory:
description: Treat package Telegram failures as advisory for the caller
required: false
default: false
type: boolean
workflow_call:
inputs:
advisory:
description: Treat package Telegram failures as advisory for the caller
required: false
default: false
type: boolean
package_spec:
description: Published OpenClaw package spec to test when no artifact is supplied
required: true
@@ -110,7 +100,6 @@ jobs:
run_package_telegram_e2e:
name: Run package Telegram E2E
runs-on: blacksmith-32vcpu-ubuntu-2404
continue-on-error: ${{ inputs.advisory }}
timeout-minutes: 60
environment: qa-live-shared
permissions:
@@ -270,7 +259,7 @@ jobs:
- name: Upload npm Telegram E2E artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: npm-telegram-beta-e2e-${{ github.run_id }}-${{ github.run_attempt }}
path: .artifacts/qa-e2e/

View File

@@ -86,18 +86,8 @@ on:
required: false
default: ""
type: string
advisory:
description: Treat failures as advisory for the caller
required: false
default: false
type: boolean
workflow_call:
inputs:
advisory:
description: Treat failures as advisory for the caller
required: false
default: false
type: boolean
ref:
description: Public OpenClaw ref to validate (tag, branch, or full commit SHA)
required: true
@@ -196,12 +186,11 @@ env:
PNPM_VERSION: "11.0.8"
OPENCLAW_REPOSITORY: openclaw/openclaw
TSX_VERSION: "4.21.0"
OPENCLAW_CROSS_OS_OPENAI_MODEL: ${{ inputs.openai_model || vars.OPENCLAW_CROSS_OS_OPENAI_MODEL || 'openai/gpt-5.5' }}
OPENCLAW_CROSS_OS_OPENAI_MODEL: ${{ inputs.openai_model || vars.OPENCLAW_CROSS_OS_OPENAI_MODEL || 'openai/gpt-5.4' }}
jobs:
prepare:
runs-on: ubuntu-24.04
continue-on-error: ${{ inputs.advisory }}
outputs:
baseline_file_name: ${{ steps.baseline_metadata.outputs.file_name }}
baseline_spec: ${{ steps.baseline.outputs.value }}
@@ -524,7 +513,6 @@ jobs:
cross_os_release_checks:
name: "${{ matrix.display_name }} / ${{ matrix.suite_label }}"
needs: prepare
continue-on-error: ${{ inputs.advisory }}
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.prepare.outputs.matrix) }}

View File

@@ -97,18 +97,8 @@ on:
- beta
- stable
- full
advisory:
description: Treat failures as advisory for the caller
required: false
default: false
type: boolean
workflow_call:
inputs:
advisory:
description: Treat failures as advisory for the caller
required: false
default: false
type: boolean
ref:
description: Ref, tag, or SHA to validate
required: true
@@ -321,6 +311,9 @@ jobs:
set -euo pipefail
trusted_reason=""
git fetch --no-tags origin '+refs/heads/*:refs/remotes/origin/*'
git fetch --tags origin '+refs/tags/*:refs/tags/*'
# Resolve here instead of in actions/checkout so short SHAs work too.
if ! selected_sha="$(git rev-parse --verify "${INPUT_REF}^{commit}")"; then
echo "Ref '${INPUT_REF}' could not be resolved to a commit." >&2
@@ -434,7 +427,6 @@ jobs:
add_profile_suite live-cli-backend-docker "stable full"
add_profile_suite live-acp-bind-docker "stable full"
add_profile_suite live-codex-harness-docker "stable full"
add_profile_suite live-subagent-announce-docker "stable full"
add_profile_suite native-live-extensions-a-k "full"
add_profile_suite native-live-extensions-media-audio "full"
@@ -462,8 +454,7 @@ jobs:
validate_release_live_cache:
needs: validate_selected_ref
if: inputs.include_live_suites && !inputs.live_models_only && (inputs.live_suite_filter == '' || inputs.live_suite_filter == 'live-cache')
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }}
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 20
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
@@ -513,8 +504,7 @@ jobs:
validate_repo_e2e:
needs: validate_selected_ref
if: inputs.include_repo_e2e && inputs.live_suite_filter == ''
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }}
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: ${{ inputs.release_test_profile == 'full' && 90 || 60 }}
env:
OPENCLAW_VITEST_MAX_WORKERS: "2"
@@ -543,8 +533,7 @@ jobs:
validate_special_e2e:
needs: validate_selected_ref
if: inputs.include_repo_e2e && (inputs.live_suite_filter == '' || inputs.live_suite_filter == 'openshell-e2e')
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }}
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: ${{ matrix.timeout_minutes }}
strategy:
fail-fast: false
@@ -618,8 +607,7 @@ jobs:
needs: [validate_selected_ref, prepare_docker_e2e_image]
if: inputs.include_release_path_suites && inputs.docker_lanes == ''
name: Docker E2E (${{ matrix.label }})
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: ${{ matrix.timeout_minutes }}
strategy:
fail-fast: false
@@ -887,8 +875,7 @@ jobs:
plan_docker_lane_groups:
needs: validate_selected_ref
if: inputs.docker_lanes != ''
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-4vcpu-ubuntu-2404' }}
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 5
outputs:
groups_json: ${{ steps.groups.outputs.groups_json }}
@@ -915,8 +902,7 @@ jobs:
needs: [validate_selected_ref, prepare_docker_e2e_image, plan_docker_lane_groups]
if: inputs.docker_lanes != ''
name: Docker E2E targeted lanes (${{ matrix.group.label }})
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 60
strategy:
fail-fast: false
@@ -1125,8 +1111,7 @@ jobs:
needs: [validate_selected_ref, prepare_docker_e2e_image]
if: inputs.include_openwebui && !inputs.include_release_path_suites && inputs.docker_lanes == ''
name: Docker E2E (openwebui)
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 60
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
@@ -1253,8 +1238,7 @@ jobs:
prepare_docker_e2e_image:
needs: validate_selected_ref
if: inputs.include_release_path_suites || inputs.include_openwebui || inputs.docker_lanes != ''
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: ${{ inputs.release_test_profile == 'full' && 90 || 60 }}
permissions:
actions: read
@@ -1498,8 +1482,7 @@ jobs:
prepare_live_test_image:
needs: validate_selected_ref
if: inputs.include_live_suites && (inputs.live_suite_filter == '' || startsWith(inputs.live_suite_filter, 'live-') || startsWith(inputs.live_suite_filter, 'docker-live-models'))
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 60
permissions:
contents: read
@@ -1572,8 +1555,7 @@ jobs:
name: Docker live models (${{ matrix.provider_label }})
needs: [validate_selected_ref, prepare_live_test_image]
if: inputs.include_live_suites && inputs.live_model_providers == '' && (inputs.live_suite_filter == '' || inputs.live_suite_filter == 'docker-live-models')
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 45
strategy:
fail-fast: false
@@ -1725,8 +1707,7 @@ jobs:
name: Docker live models (selected providers)
needs: [validate_selected_ref, prepare_live_test_image]
if: inputs.include_live_suites && inputs.live_model_providers != '' && (inputs.live_suite_filter == '' || inputs.live_suite_filter == 'docker-live-models')
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 45
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
@@ -1901,8 +1882,7 @@ jobs:
validate_live_provider_suites:
needs: validate_selected_ref
if: inputs.include_live_suites && !inputs.live_models_only && (inputs.live_suite_filter == '' || (startsWith(inputs.live_suite_filter, 'native-live-') && !startsWith(inputs.live_suite_filter, 'native-live-extensions-media') && inputs.live_suite_filter != 'native-live-extensions-a-k'))
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }}
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: ${{ matrix.timeout_minutes }}
strategy:
fail-fast: false
@@ -1930,7 +1910,7 @@ jobs:
- suite_id: native-live-src-gateway-profiles-anthropic-opus
suite_group: native-live-src-gateway-profiles-anthropic
label: Native live gateway profiles Anthropic Opus
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic OPENCLAW_LIVE_GATEWAY_MODELS=anthropic/claude-opus-4-7 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic OPENCLAW_LIVE_GATEWAY_MODELS=anthropic/claude-opus-4-7,anthropic/claude-opus-4-6 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 30
profile_env_only: false
advisory: true
@@ -2173,11 +2153,27 @@ jobs:
fi
case "${{ matrix.suite_id }}" in
live-cli-backend-docker)
echo "OPENCLAW_LIVE_CLI_BACKEND_MODEL=claude-cli/claude-sonnet-4-6" >> "$GITHUB_ENV"
echo "OPENCLAW_LIVE_CLI_BACKEND_MODEL=codex-cli/gpt-5.4" >> "$GITHUB_ENV"
# Keep the release-blocking CI lane on Codex API-key auth. The
# staged auth-file path remains supported for local maintainer
# reruns, but it can hang on stale subscription/session state in
# an otherwise healthy release run.
echo "OPENCLAW_LIVE_CLI_BACKEND_AUTH=api-key" >> "$GITHUB_ENV"
# Replace the staged config.toml with a minimal CI-safe config so
# the repo stays trusted for MCP/tool use without inheriting
# maintainer-local provider/profile overrides that do not exist
# inside CI.
# Codex's workspace-write sandbox relies on user namespaces that
# this Docker lane does not provide, so run Codex unsandboxed
# inside the already-isolated container to keep MCP cron/tool
# execution representative instead of failing on nested sandbox
# setup.
echo 'OPENCLAW_LIVE_CLI_BACKEND_ARGS=["exec","--json","--color","never","--sandbox","danger-full-access","-c","service_tier=\"fast\"","--skip-git-repo-check"]' >> "$GITHUB_ENV"
echo 'OPENCLAW_LIVE_CLI_BACKEND_RESUME_ARGS=["exec","resume","{sessionId}","-c","sandbox_mode=\"danger-full-access\"","-c","service_tier=\"fast\"","--skip-git-repo-check"]' >> "$GITHUB_ENV"
echo "OPENCLAW_LIVE_CLI_BACKEND_DEBUG=1" >> "$GITHUB_ENV"
echo "OPENCLAW_CLI_BACKEND_LOG_OUTPUT=1" >> "$GITHUB_ENV"
echo "OPENCLAW_TEST_CONSOLE=1" >> "$GITHUB_ENV"
echo "OPENCLAW_LIVE_CLI_BACKEND_USE_CI_SAFE_CODEX_CONFIG=1" >> "$GITHUB_ENV"
;;
live-codex-harness-docker)
# Keep CI on the API-key path for now. The staged Codex auth secret
@@ -2223,8 +2219,7 @@ jobs:
name: Docker live suites (${{ matrix.label }})
needs: [validate_selected_ref, prepare_live_test_image]
if: inputs.include_live_suites && !inputs.live_models_only && (inputs.live_suite_filter == '' || startsWith(inputs.live_suite_filter, 'live-'))
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: ${{ matrix.timeout_minutes }}
strategy:
fail-fast: false
@@ -2296,12 +2291,6 @@ jobs:
timeout_minutes: 40
profile_env_only: false
profiles: stable full
- suite_id: live-subagent-announce-docker
label: Docker live subagent announce
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 20m bash .release-harness/scripts/test-live-subagent-announce-docker.sh
timeout_minutes: 25
profile_env_only: false
profiles: stable full
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
@@ -2399,11 +2388,14 @@ jobs:
fi
case "${{ matrix.suite_id }}" in
live-cli-backend-docker)
echo "OPENCLAW_LIVE_CLI_BACKEND_MODEL=claude-cli/claude-sonnet-4-6" >> "$GITHUB_ENV"
echo "OPENCLAW_LIVE_CLI_BACKEND_MODEL=codex-cli/gpt-5.4" >> "$GITHUB_ENV"
echo "OPENCLAW_LIVE_CLI_BACKEND_AUTH=api-key" >> "$GITHUB_ENV"
echo 'OPENCLAW_LIVE_CLI_BACKEND_ARGS=["exec","--json","--color","never","--sandbox","danger-full-access","-c","service_tier=\"fast\"","--skip-git-repo-check"]' >> "$GITHUB_ENV"
echo 'OPENCLAW_LIVE_CLI_BACKEND_RESUME_ARGS=["exec","resume","{sessionId}","-c","sandbox_mode=\"danger-full-access\"","-c","service_tier=\"fast\"","--skip-git-repo-check"]' >> "$GITHUB_ENV"
echo "OPENCLAW_LIVE_CLI_BACKEND_DEBUG=1" >> "$GITHUB_ENV"
echo "OPENCLAW_CLI_BACKEND_LOG_OUTPUT=1" >> "$GITHUB_ENV"
echo "OPENCLAW_TEST_CONSOLE=1" >> "$GITHUB_ENV"
echo "OPENCLAW_LIVE_CLI_BACKEND_USE_CI_SAFE_CODEX_CONFIG=1" >> "$GITHUB_ENV"
;;
live-codex-harness-docker)
echo "OPENCLAW_LIVE_CODEX_HARNESS_AUTH=api-key" >> "$GITHUB_ENV"
@@ -2443,8 +2435,7 @@ jobs:
name: Live media suites (${{ matrix.label }})
needs: validate_selected_ref
if: inputs.include_live_suites && !inputs.live_models_only && (inputs.live_suite_filter == '' || startsWith(inputs.live_suite_filter, 'native-live-extensions-media') || inputs.live_suite_filter == 'native-live-extensions-a-k')
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }}
runs-on: blacksmith-8vcpu-ubuntu-2404
container:
image: ghcr.io/openclaw/openclaw-live-media-runner:ubuntu-24.04
credentials:

View File

@@ -16,10 +16,6 @@ on:
description: Existing successful preflight workflow run id to promote without rebuilding
required: false
type: string
full_release_validation_run_id:
description: Successful Full Release Validation run id for this tag/SHA, required for real publish
required: false
type: string
npm_dist_tag:
description: npm dist-tag to publish to
required: true
@@ -88,28 +84,6 @@ jobs:
ref: ${{ inputs.tag }}
fetch-depth: 0
- name: Validate Tideclaw alpha preflight target
if: startsWith(github.ref, 'refs/heads/tideclaw/alpha/')
env:
RELEASE_REF: ${{ inputs.tag }}
WORKFLOW_REF: ${{ github.ref }}
run: |
set -euo pipefail
if [[ ! "${RELEASE_REF}" == *"-alpha."* && ! "${RELEASE_REF}" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "Tideclaw alpha preflight runs must target an alpha prerelease tag or SHA." >&2
exit 1
fi
if [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
echo "Tideclaw alpha preflight runs must run from tideclaw/alpha/YYYY-MM-DD-HHMMZ." >&2
exit 1
fi
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
echo "Alpha preflight target must be reachable from ${alpha_branch}." >&2
exit 1
fi
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
@@ -195,27 +169,12 @@ jobs:
- name: Verify release contents
run: pnpm release:check
- name: Generate dependency release evidence
id: dependency_evidence
env:
RELEASE_REF: ${{ inputs.tag }}
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
run: |
set -euo pipefail
node scripts/generate-dependency-release-evidence.mjs \
--release-ref "$RELEASE_REF" \
--npm-dist-tag "$RELEASE_NPM_DIST_TAG" \
--output-dir "$RUNNER_TEMP/openclaw-release-dependency-evidence" \
--github-output "$GITHUB_OUTPUT" \
--github-step-summary "$GITHUB_STEP_SUMMARY"
- name: Pack prepared npm tarball
id: packed_tarball
env:
OPENCLAW_PREPACK_PREPARED: "1"
RELEASE_REF: ${{ inputs.tag }}
RELEASE_TAG: ${{ inputs.tag }}
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
DEPENDENCY_EVIDENCE_DIR: ${{ steps.dependency_evidence.outputs.dir }}
run: |
set -euo pipefail
PACK_OUTPUT="$RUNNER_TEMP/npm-pack-output.txt"
@@ -281,18 +240,12 @@ jobs:
fi
RELEASE_SHA="$(git rev-parse HEAD)"
PACKAGE_VERSION="$(node -p "require('./package.json').version")"
if [[ "${RELEASE_REF}" =~ ^[0-9a-fA-F]{40}$ ]]; then
RELEASE_TAG="v${PACKAGE_VERSION}"
else
RELEASE_TAG="${RELEASE_REF}"
fi
TARBALL_NAME="$(basename "$PACK_PATH")"
TARBALL_SHA256="$(sha256sum "$PACK_PATH" | awk '{print $1}')"
ARTIFACT_DIR="$RUNNER_TEMP/openclaw-npm-preflight"
rm -rf "$ARTIFACT_DIR"
mkdir -p "$ARTIFACT_DIR"
cp "$PACK_PATH" "$ARTIFACT_DIR/"
cp -R "$DEPENDENCY_EVIDENCE_DIR" "$ARTIFACT_DIR/dependency-evidence"
printf '%s\n' "$RELEASE_TAG" > "$ARTIFACT_DIR/release-tag.txt"
printf '%s\n' "$RELEASE_SHA" > "$ARTIFACT_DIR/release-sha.txt"
printf '%s\n' "$RELEASE_NPM_DIST_TAG" > "$ARTIFACT_DIR/release-npm-dist-tag.txt"
@@ -308,8 +261,6 @@ jobs:
packageVersion: process.env.PACKAGE_VERSION,
tarballName: process.env.TARBALL_NAME,
tarballSha256: process.env.TARBALL_SHA256,
dependencyEvidenceDir: "dependency-evidence",
dependencyEvidenceManifest: "dependency-evidence/dependency-evidence-manifest.json",
};
fs.writeFileSync(
path.join(process.env.ARTIFACT_DIR, "preflight-manifest.json"),
@@ -317,36 +268,6 @@ jobs:
);
NODE
echo "dir=$ARTIFACT_DIR" >> "$GITHUB_OUTPUT"
echo "release_tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT"
- name: Verify prepared npm tarball install
env:
PREFLIGHT_ARTIFACT_DIR: ${{ steps.packed_tarball.outputs.dir }}
run: |
set -euo pipefail
TARBALL_PATH="$(find "$PREFLIGHT_ARTIFACT_DIR" -maxdepth 1 -type f -name '*.tgz' -print | sort | tail -n 1)"
if [[ -z "$TARBALL_PATH" ]]; then
echo "Prepared preflight tarball not found." >&2
ls -la "$PREFLIGHT_ARTIFACT_DIR" >&2 || true
exit 1
fi
PACKAGE_VERSION="$(node -p "require('./package.json').version")"
node --import tsx scripts/openclaw-npm-prepublish-verify.ts "$TARBALL_PATH" "$PACKAGE_VERSION"
- name: Upload dependency release evidence
uses: actions/upload-artifact@v7
with:
name: openclaw-release-dependency-evidence-${{ inputs.tag }}
path: ${{ steps.dependency_evidence.outputs.dir }}
if-no-files-found: error
- name: Upload dependency release evidence tag alias
if: ${{ steps.packed_tarball.outputs.release_tag != inputs.tag }}
uses: actions/upload-artifact@v7
with:
name: openclaw-release-dependency-evidence-${{ steps.packed_tarball.outputs.release_tag }}
path: ${{ steps.dependency_evidence.outputs.dir }}
if-no-files-found: error
- name: Upload prepared npm publish bundle
uses: actions/upload-artifact@v7
@@ -355,50 +276,31 @@ jobs:
path: ${{ steps.packed_tarball.outputs.dir }}
if-no-files-found: error
- name: Upload prepared npm publish bundle tag alias
if: ${{ steps.packed_tarball.outputs.release_tag != inputs.tag }}
uses: actions/upload-artifact@v7
with:
name: openclaw-npm-preflight-${{ steps.packed_tarball.outputs.release_tag }}
path: ${{ steps.packed_tarball.outputs.dir }}
if-no-files-found: error
validate_publish_request:
if: ${{ !inputs.preflight_only }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Require trusted workflow ref for publish
- name: Require main or release workflow ref for publish
env:
RELEASE_TAG: ${{ inputs.tag }}
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
WORKFLOW_REF: ${{ github.ref }}
run: |
set -euo pipefail
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
fi
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]] && [[ "${tideclaw_alpha_publish}" != "true" ]]; then
echo "Real publish runs must be dispatched from main, release/YYYY.M.D, or a Tideclaw alpha branch for alpha prereleases. Use preflight_only=true for other branch validation."
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]]; then
echo "Real publish runs must be dispatched from main or release/YYYY.M.D. Use preflight_only=true for other branch validation."
exit 1
fi
- name: Require preflight artifact promotion on real publish
env:
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
run: |
set -euo pipefail
if [[ -z "${PREFLIGHT_RUN_ID}" ]]; then
echo "Real publish requires preflight_run_id from a successful npm preflight run." >&2
exit 1
fi
if [[ -z "${FULL_RELEASE_VALIDATION_RUN_ID}" ]]; then
echo "Real publish requires full_release_validation_run_id from a successful Full Release Validation run." >&2
exit 1
fi
publish_openclaw_npm:
# KEEP THE REAL RELEASE/PUBLISH PATH ON A GITHUB-HOSTED RUNNER.
@@ -437,28 +339,6 @@ jobs:
ref: refs/tags/${{ inputs.tag }}
fetch-depth: 0
- name: Validate Tideclaw alpha publish target
if: startsWith(github.ref, 'refs/heads/tideclaw/alpha/')
env:
RELEASE_TAG: ${{ inputs.tag }}
WORKFLOW_REF: ${{ github.ref }}
run: |
set -euo pipefail
if [[ ! "${RELEASE_TAG}" == *"-alpha."* ]]; then
echo "Tideclaw alpha publish runs must target an alpha prerelease tag." >&2
exit 1
fi
if [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
echo "Tideclaw alpha publish runs must run from tideclaw/alpha/YYYY-MM-DD-HHMMZ." >&2
exit 1
fi
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
echo "Alpha publish tag must be reachable from ${alpha_branch}." >&2
exit 1
fi
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
@@ -488,64 +368,13 @@ jobs:
RUN_JSON="$(gh run view "$PREFLIGHT_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,conclusion,url)"
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "OpenClaw NPM Release"], ["headBranch", process.env.EXPECTED_PREFLIGHT_BRANCH], ["event", "workflow_dispatch"], ["conclusion", "success"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced npm preflight run ${process.env.PREFLIGHT_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } console.log(`Using npm preflight run ${process.env.PREFLIGHT_RUN_ID}: ${run.url}`);'
- name: Verify full release validation run metadata
env:
GH_TOKEN: ${{ github.token }}
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
EXPECTED_WORKFLOW_BRANCH: ${{ github.ref_name }}
run: |
set -euo pipefail
RUN_JSON="$(gh run view "$FULL_RELEASE_VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "Full Release Validation"], ["headBranch", process.env.EXPECTED_WORKFLOW_BRANCH], ["event", "workflow_dispatch"], ["status", "completed"], ["conclusion", "success"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced full release validation run ${process.env.FULL_RELEASE_VALIDATION_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } console.log(`Using full release validation run ${process.env.FULL_RELEASE_VALIDATION_RUN_ID}: ${run.url}`);'
- name: Download prepared npm tarball
env:
GH_TOKEN: ${{ github.token }}
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
download_preflight_artifact() {
local preferred_name fallback_name
preferred_name="openclaw-npm-preflight-${RELEASE_TAG}"
rm -rf preflight-tarball
mkdir -p preflight-tarball
if gh run download "${PREFLIGHT_RUN_ID}" \
--repo "${GITHUB_REPOSITORY}" \
--name "${preferred_name}" \
--dir preflight-tarball; then
echo "Downloaded ${preferred_name}."
return 0
fi
echo "::warning::${preferred_name} not found; checking run artifacts for a single compatible preflight artifact."
mapfile -t matches < <(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/runs/${PREFLIGHT_RUN_ID}/artifacts" \
--jq '.artifacts[] | select(.expired != true) | .name' |
grep '^openclaw-npm-preflight-' || true)
if [[ "${#matches[@]}" != "1" ]]; then
echo "Expected ${preferred_name}, or exactly one openclaw-npm-preflight-* fallback artifact in run ${PREFLIGHT_RUN_ID}." >&2
printf 'Available preflight candidates:\n' >&2
printf -- '- %s\n' "${matches[@]:-<none>}" >&2
exit 1
fi
fallback_name="${matches[0]}"
gh run download "${PREFLIGHT_RUN_ID}" \
--repo "${GITHUB_REPOSITORY}" \
--name "${fallback_name}" \
--dir preflight-tarball
echo "Downloaded fallback preflight artifact ${fallback_name}."
}
download_preflight_artifact
- name: Download full release validation manifest
uses: actions/download-artifact@v8
with:
name: full-release-validation-${{ inputs.full_release_validation_run_id }}
path: full-release-validation
name: openclaw-npm-preflight-${{ inputs.tag }}
path: preflight-tarball
repository: ${{ github.repository }}
run-id: ${{ inputs.full_release_validation_run_id }}
run-id: ${{ inputs.preflight_run_id }}
github-token: ${{ github.token }}
- name: Validate release tag and package metadata
@@ -604,32 +433,6 @@ jobs:
exit 1
fi
- name: Verify full release validation target
run: |
set -euo pipefail
EXPECTED_RELEASE_SHA="$(git rev-parse HEAD)"
MANIFEST_FILE="full-release-validation/full-release-validation-manifest.json"
if [[ ! -f "$MANIFEST_FILE" ]]; then
echo "Full release validation manifest is missing." >&2
ls -la full-release-validation >&2 || true
exit 1
fi
WORKFLOW_NAME="$(jq -r '.workflowName // ""' "$MANIFEST_FILE")"
TARGET_SHA="$(jq -r '.targetSha // ""' "$MANIFEST_FILE")"
RERUN_GROUP="$(jq -r '.rerunGroup // ""' "$MANIFEST_FILE")"
if [[ "$WORKFLOW_NAME" != "Full Release Validation" ]]; then
echo "Full release validation manifest workflow mismatch: $WORKFLOW_NAME" >&2
exit 1
fi
if [[ "$TARGET_SHA" != "$EXPECTED_RELEASE_SHA" ]]; then
echo "Full release validation target SHA mismatch: expected $EXPECTED_RELEASE_SHA, got $TARGET_SHA" >&2
exit 1
fi
if [[ "$RERUN_GROUP" != "all" ]]; then
echo "Full release validation must run rerun_group=all before npm publish; got $RERUN_GROUP" >&2
exit 1
fi
- name: Resolve publish tarball
id: publish_tarball
run: |

View File

@@ -30,8 +30,8 @@ on:
required: false
default: false
type: boolean
live_openai_candidate:
description: Run the live OpenAI GPT 5.5 agent-turn lane
live_gpt54:
description: Run the live OpenAI GPT 5.4 agent-turn lane
required: false
default: false
type: boolean
@@ -57,7 +57,7 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
OCM_VERSION: v0.2.15
KOVA_REPOSITORY: openclaw/Kova
PERFORMANCE_MODEL_ID: gpt-5.5
PERFORMANCE_MODEL_ID: gpt-5.4
jobs:
kova:
@@ -82,8 +82,8 @@ jobs:
deep_profile: "true"
live: "false"
include_filters: "scenario:fresh-install scenario:gateway-performance scenario:agent-cold-warm-message"
- lane: live-openai-candidate
title: Kova live OpenAI GPT 5.5 agent turn
- lane: live-gpt54
title: Kova live OpenAI GPT 5.4 agent turn
auth: live
repeat: "1"
deep_profile: "false"
@@ -119,9 +119,9 @@ jobs:
run_lane=false
reason="deep_profile input is false"
fi
if [[ "$LANE_ID" == "live-openai-candidate" && "${{ github.event_name }}" != "schedule" && "${{ inputs.live_openai_candidate || 'false' }}" != "true" ]]; then
if [[ "$LANE_ID" == "live-gpt54" && "${{ github.event_name }}" != "schedule" && "${{ inputs.live_gpt54 || 'false' }}" != "true" ]]; then
run_lane=false
reason="live_openai_candidate input is false"
reason="live_gpt54 input is false"
fi
echo "run=$run_lane" >> "$GITHUB_OUTPUT"
if [[ "$run_lane" != "true" ]]; then
@@ -200,7 +200,7 @@ jobs:
chmod 0755 "$HOME/.local/bin/kova"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Pin Kova OpenAI model to GPT 5.5
- name: Pin Kova OpenAI model to GPT 5.4
if: steps.lane.outputs.run == 'true'
shell: bash
run: |
@@ -244,7 +244,7 @@ jobs:
run: |
set -euo pipefail
if [[ -z "${OPENAI_API_KEY:-}" ]]; then
echo "OPENAI_API_KEY is not configured; live GPT 5.5 lane will be skipped." >> "$GITHUB_STEP_SUMMARY"
echo "OPENAI_API_KEY is not configured; live GPT 5.4 lane will be skipped." >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
kova setup --ci --json
@@ -468,7 +468,7 @@ jobs:
- name: Upload Kova artifacts
if: ${{ always() && steps.lane.outputs.run == 'true' }}
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v5
with:
name: openclaw-performance-${{ matrix.lane }}-${{ github.run_id }}-${{ github.run_attempt }}
path: |
@@ -489,7 +489,9 @@ jobs:
reports_root=".artifacts/clawgrit-reports"
mkdir -p "$reports_root"
git -C "$reports_root" init -b main
git -C "$reports_root" remote add origin "https://x-access-token:${CLAWGRIT_REPORTS_TOKEN}@github.com/openclaw/clawgrit-reports.git"
git -C "$reports_root" remote add origin https://github.com/openclaw/clawgrit-reports.git
auth_header="$(printf 'x-access-token:%s' "$CLAWGRIT_REPORTS_TOKEN" | base64 -w0)"
git -C "$reports_root" config http.https://github.com/.extraheader "AUTHORIZATION: basic ${auth_header}"
if git -C "$reports_root" ls-remote --exit-code --heads origin main >/dev/null 2>&1; then
git -C "$reports_root" fetch --depth=1 origin main
git -C "$reports_root" checkout -B main FETCH_HEAD
@@ -499,13 +501,10 @@ jobs:
- name: Publish to clawgrit reports
if: ${{ steps.kova.outputs.report_json != '' && steps.clawgrit.outputs.present == 'true' }}
env:
CLAWGRIT_REPORTS_TOKEN: ${{ secrets.CLAWGRIT_REPORTS_TOKEN }}
shell: bash
run: |
set -euo pipefail
reports_root=".artifacts/clawgrit-reports"
git -C "$reports_root" remote set-url origin "https://x-access-token:${CLAWGRIT_REPORTS_TOKEN}@github.com/openclaw/clawgrit-reports.git"
ref_slug="$(printf '%s' "${TESTED_REF}" | tr -c 'A-Za-z0-9._-' '-')"
run_slug="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
dest="${reports_root}/openclaw-performance/${ref_slug}/${run_slug}/${LANE_ID}"
@@ -561,14 +560,7 @@ jobs:
exit 0
fi
if [[ "$attempt" == "5" ]]; then
{
echo "### Clawgrit report publish skipped"
echo
echo "Kova artifacts were uploaded, but publishing the optional clawgrit report failed after ${attempt} attempts."
echo "Check the \`CLAWGRIT_REPORTS_TOKEN\` secret or the reports repository permissions."
} >> "$GITHUB_STEP_SUMMARY"
echo "::warning::Kova artifacts uploaded, but optional clawgrit report publish failed after ${attempt} attempts."
exit 0
exit 1
fi
sleep $((attempt * 2))
git -C "$reports_root" fetch --depth=1 origin main

View File

@@ -113,21 +113,13 @@ jobs:
release_package_spec: ${{ steps.inputs.outputs.release_package_spec }}
package_acceptance_package_spec: ${{ steps.inputs.outputs.package_acceptance_package_spec }}
steps:
- name: Require trusted workflow ref for release checks
- name: Require main or release workflow ref for release checks
env:
RELEASE_REF: ${{ inputs.ref }}
WORKFLOW_REF: ${{ github.ref }}
run: |
set -euo pipefail
tideclaw_alpha_check=false
if [[ "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
workflow_branch="${WORKFLOW_REF#refs/heads/}"
if [[ "${RELEASE_REF}" == *"-alpha."* || "${RELEASE_REF}" =~ ^[0-9a-fA-F]{40}$ || "${RELEASE_REF}" == "${workflow_branch}" || "${RELEASE_REF}" == "refs/heads/${workflow_branch}" ]]; then
tideclaw_alpha_check=true
fi
fi
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release-ci/[0-9a-f]{12}-[0-9]+$ ]] && [[ "${tideclaw_alpha_check}" != "true" ]]; then
echo "Release checks must be dispatched from main, release/YYYY.M.D, a Full Release Validation release-ci/<sha>-<timestamp> ref, or a Tideclaw alpha branch for alpha prereleases." >&2
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release-ci/[0-9a-f]{12}-[0-9]+$ ]]; then
echo "Release checks must be dispatched from main, release/YYYY.M.D, or a Full Release Validation release-ci/<sha>-<timestamp> ref so workflow logic and secrets stay controlled." >&2
exit 1
fi
@@ -227,25 +219,6 @@ jobs:
fi
echo "sha=${selected_sha,,}" >> "$GITHUB_OUTPUT"
- name: Validate Tideclaw alpha target matches workflow branch
if: startsWith(github.ref, 'refs/heads/tideclaw/alpha/')
working-directory: workflow
env:
SELECTED_SHA: ${{ steps.ref.outputs.sha }}
WORKFLOW_REF: ${{ github.ref }}
run: |
set -euo pipefail
if [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
echo "Tideclaw alpha release checks must run from tideclaw/alpha/YYYY-MM-DD-HHMMZ." >&2
exit 1
fi
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 "${SELECTED_SHA}" "refs/remotes/origin/${alpha_branch}"; then
echo "Alpha release target ${SELECTED_SHA} must be reachable from ${alpha_branch}." >&2
exit 1
fi
- name: Capture selected inputs
id: inputs
env:
@@ -534,7 +507,6 @@ jobs:
permissions: read-all
uses: ./.github/workflows/openclaw-cross-os-release-checks-reusable.yml
with:
advisory: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }}
ref: ${{ needs.resolve_target.outputs.revision }}
provider: ${{ needs.resolve_target.outputs.provider }}
mode: ${{ needs.resolve_target.outputs.mode }}
@@ -543,10 +515,7 @@ jobs:
candidate_file_name: openclaw-current.tgz
candidate_version: ${{ needs.prepare_release_package.outputs.package_version }}
candidate_source_sha: ${{ needs.prepare_release_package.outputs.source_sha }}
openai_model: openai/gpt-5.5
ubuntu_runner: ubuntu-24.04
windows_runner: windows-2025
macos_runner: macos-26
openai_model: openai/gpt-5.4
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
@@ -566,7 +535,6 @@ jobs:
pull-requests: read
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
with:
advisory: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }}
ref: ${{ needs.resolve_target.outputs.revision }}
include_repo_e2e: true
include_release_path_suites: false
@@ -632,7 +600,6 @@ jobs:
pull-requests: read
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
with:
advisory: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }}
ref: ${{ needs.resolve_target.outputs.revision }}
include_repo_e2e: false
include_release_path_suites: true
@@ -653,18 +620,17 @@ jobs:
pull-requests: read
uses: ./.github/workflows/package-acceptance.yml
with:
advisory: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }}
workflow_ref: ${{ github.ref_name }}
source: ${{ (needs.resolve_target.outputs.package_acceptance_package_spec != '' || needs.resolve_target.outputs.release_package_spec != '') && 'npm' || 'artifact' }}
package_spec: ${{ needs.resolve_target.outputs.package_acceptance_package_spec || needs.resolve_target.outputs.release_package_spec || 'openclaw@beta' }}
artifact_name: ${{ needs.prepare_release_package.outputs.artifact_name }}
package_sha256: ${{ (needs.resolve_target.outputs.package_acceptance_package_spec == '' && needs.resolve_target.outputs.release_package_spec == '') && needs.prepare_release_package.outputs.package_sha256 || '' }}
suite_profile: custom
docker_lanes: doctor-switch update-channel-switch skill-install update-corrupt-plugin upgrade-survivor published-upgrade-survivor root-managed-vps-upgrade update-restart-auth plugins-offline plugin-update
docker_lanes: doctor-switch update-channel-switch skill-install update-corrupt-plugin upgrade-survivor published-upgrade-survivor update-restart-auth plugins-offline plugin-update
published_upgrade_survivor_baselines: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'last-stable-4 2026.4.23 2026.5.2 2026.4.15' || '' }}
published_upgrade_survivor_scenarios: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'reported-issues' || '' }}
telegram_mode: mock-openai
telegram_scenarios: telegram-help-command,telegram-commands-command,telegram-tools-compact-command,telegram-whoami-command,telegram-status-command,telegram-other-bot-command-gating,telegram-context-command,telegram-mentioned-message-reply,telegram-long-final-reuses-preview,telegram-mention-gating
telegram_scenarios: telegram-help-command,telegram-commands-command,telegram-tools-compact-command,telegram-whoami-command,telegram-status-command,telegram-other-bot-command-gating,telegram-context-command,telegram-mentioned-message-reply,telegram-reply-chain-exact-marker,telegram-stream-final-single-message,telegram-long-final-reuses-preview,telegram-mention-gating
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
@@ -719,7 +685,7 @@ jobs:
needs: [resolve_target]
if: contains(fromJSON('["all","qa","qa-parity"]'), needs.resolve_target.outputs.rerun_group)
continue-on-error: true
runs-on: ubuntu-24.04
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 30
permissions:
contents: read
@@ -728,9 +694,9 @@ jobs:
matrix:
include:
- lane: candidate
output_dir: openai-candidate
output_dir: gpt54
- lane: baseline
output_dir: anthropic-baseline
output_dir: opus46
env:
QA_PARITY_CONCURRENCY: "1"
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"
@@ -776,7 +742,7 @@ jobs:
;;
baseline)
model="anthropic/claude-opus-4-7"
alt_model="anthropic/claude-sonnet-4-6"
alt_model="anthropic/claude-sonnet-4-7"
;;
*)
echo "Unknown QA parity lane: ${QA_PARITY_LANE}" >&2
@@ -794,7 +760,7 @@ jobs:
- name: Upload parity lane artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: release-qa-parity-${{ matrix.lane }}-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
@@ -806,7 +772,7 @@ jobs:
needs: [resolve_target, qa_lab_parity_lane_release_checks]
if: contains(fromJSON('["all","qa","qa-parity"]'), needs.resolve_target.outputs.rerun_group)
continue-on-error: true
runs-on: ubuntu-24.04
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 20
permissions:
contents: read
@@ -830,7 +796,7 @@ jobs:
install-bun: "true"
- name: Download parity lane artifacts
uses: actions/download-artifact@v8
uses: actions/download-artifact@v4
with:
pattern: release-qa-parity-*-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
@@ -845,173 +811,27 @@ jobs:
run: |
pnpm openclaw qa parity-report \
--repo-root . \
--candidate-summary .artifacts/qa-e2e/openai-candidate/qa-suite-summary.json \
--baseline-summary .artifacts/qa-e2e/anthropic-baseline/qa-suite-summary.json \
--candidate-summary .artifacts/qa-e2e/gpt54/qa-suite-summary.json \
--baseline-summary .artifacts/qa-e2e/opus46/qa-suite-summary.json \
--candidate-label "${OPENCLAW_CI_OPENAI_MODEL}" \
--baseline-label anthropic/claude-opus-4-7 \
--output-dir .artifacts/qa-e2e/parity
- name: Upload parity artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: release-qa-parity-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: warn
qa_lab_runtime_parity_release_checks:
name: Run QA Lab runtime parity lane
needs: [resolve_target]
if: contains(fromJSON('["all","qa","qa-parity"]'), needs.resolve_target.outputs.rerun_group)
continue-on-error: true
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 30
permissions:
contents: read
env:
QA_PARITY_CONCURRENCY: "1"
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"
OPENAI_API_KEY: ""
ANTHROPIC_API_KEY: ""
OPENCLAW_LIVE_OPENAI_KEY: ""
OPENCLAW_LIVE_ANTHROPIC_KEY: ""
OPENCLAW_LIVE_GEMINI_KEY: ""
OPENCLAW_LIVE_SETUP_TOKEN_VALUE: ""
OPENCLAW_BUILD_PRIVATE_QA: "1"
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
steps:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ needs.resolve_target.outputs.revision }}
fetch-depth: 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "true"
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
run: pnpm build
- name: Run runtime parity lane
id: runtime_parity_lane
run: |
set -euo pipefail
pnpm openclaw qa suite \
--provider-mode mock-openai \
--parity-pack agentic \
--concurrency "${QA_PARITY_CONCURRENCY}" \
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model "openai/gpt-5.5-alt" \
--runtime-pair pi,codex \
--output-dir ".artifacts/qa-e2e/runtime-parity"
- name: Run standard runtime parity tier
if: ${{ always() && steps.runtime_parity_lane.outcome != 'skipped' && steps.runtime_parity_lane.outcome != 'cancelled' }}
run: |
set -euo pipefail
pnpm openclaw qa suite \
--provider-mode mock-openai \
--runtime-parity-tier standard \
--concurrency "${QA_PARITY_CONCURRENCY}" \
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model "openai/gpt-5.5-alt" \
--runtime-pair pi,codex \
--output-dir ".artifacts/qa-e2e/runtime-parity-standard"
- name: Generate runtime parity report
if: always()
run: |
set -euo pipefail
pnpm openclaw qa parity-report \
--repo-root . \
--runtime-axis \
--summary .artifacts/qa-e2e/runtime-parity/qa-suite-summary.json \
--output-dir .artifacts/qa-e2e/runtime-parity-report
- name: Generate standard runtime parity report
if: always()
run: |
set -euo pipefail
pnpm openclaw qa parity-report \
--repo-root . \
--runtime-axis \
--summary .artifacts/qa-e2e/runtime-parity-standard/qa-suite-summary.json \
--output-dir .artifacts/qa-e2e/runtime-parity-standard-report
- name: Upload runtime parity artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: release-qa-runtime-parity-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: warn
runtime_tool_coverage_release_checks:
name: Enforce QA Lab runtime tool coverage
needs: [resolve_target, qa_lab_runtime_parity_release_checks]
if: always() && contains(fromJSON('["all","qa","qa-parity"]'), needs.resolve_target.outputs.rerun_group)
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
contents: read
actions: read
env:
OPENCLAW_BUILD_PRIVATE_QA: "1"
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
steps:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ needs.resolve_target.outputs.revision }}
fetch-depth: 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "true"
- name: Download runtime parity artifacts
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
run: |
set -euo pipefail
pnpm openclaw qa coverage \
--repo-root . \
--tools \
--summary .artifacts/qa-e2e/runtime-parity-standard/qa-suite-summary.json \
--output .artifacts/qa-e2e/runtime-parity-standard-report/qa-runtime-tool-coverage-report.md
- name: Upload runtime tool coverage artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: release-qa-runtime-tool-coverage-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/runtime-parity-standard-report/
retention-days: 14
if-no-files-found: warn
qa_live_matrix_release_checks:
name: Run QA Lab live Matrix lane
needs: [resolve_target]
if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_matrix_enabled == 'true'
continue-on-error: true
runs-on: ubuntu-24.04
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 60
permissions:
contents: read
@@ -1079,7 +899,7 @@ jobs:
- name: Upload Matrix QA artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: release-qa-live-matrix-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
@@ -1091,7 +911,7 @@ jobs:
needs: [resolve_target]
if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_telegram_enabled == 'true'
continue-on-error: true
runs-on: ubuntu-24.04
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 60
permissions:
contents: read
@@ -1175,7 +995,7 @@ jobs:
- name: Upload Telegram QA artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: release-qa-live-telegram-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
@@ -1187,7 +1007,7 @@ jobs:
needs: [resolve_target]
if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_discord_enabled == 'true' && vars.OPENCLAW_RELEASE_QA_DISCORD_LIVE_CI_ENABLED == 'true'
continue-on-error: true
runs-on: ubuntu-24.04
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 60
permissions:
contents: read
@@ -1271,7 +1091,7 @@ jobs:
- name: Upload Discord QA artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: release-qa-live-discord-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
@@ -1283,11 +1103,8 @@ jobs:
needs: [resolve_target]
if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_whatsapp_enabled == 'true' && vars.OPENCLAW_RELEASE_QA_WHATSAPP_LIVE_CI_ENABLED == 'true'
continue-on-error: true
runs-on: ubuntu-24.04
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 60
concurrency:
group: qa-live-whatsapp-shared
cancel-in-progress: false
permissions:
contents: read
pull-requests: read
@@ -1370,7 +1187,7 @@ jobs:
- name: Upload WhatsApp QA artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: release-qa-live-whatsapp-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
@@ -1382,7 +1199,7 @@ jobs:
needs: [resolve_target]
if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_slack_enabled == 'true' && vars.OPENCLAW_RELEASE_QA_SLACK_LIVE_CI_ENABLED == 'true'
continue-on-error: true
runs-on: ubuntu-24.04
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 60
permissions:
contents: read
@@ -1466,7 +1283,7 @@ jobs:
- name: Upload Slack QA artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: release-qa-live-slack-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
@@ -1484,8 +1301,6 @@ jobs:
- package_acceptance_release_checks
- qa_lab_parity_lane_release_checks
- qa_lab_parity_report_release_checks
- qa_lab_runtime_parity_release_checks
- runtime_tool_coverage_release_checks
- qa_live_matrix_release_checks
- qa_live_telegram_release_checks
- qa_live_discord_release_checks
@@ -1498,15 +1313,9 @@ jobs:
steps:
- name: Verify release check results
shell: bash
env:
WORKFLOW_REF: ${{ github.ref }}
run: |
set -euo pipefail
failed=0
tideclaw_alpha=false
if [[ "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
tideclaw_alpha=true
fi
for item in \
"prepare_release_package=${{ needs.prepare_release_package.result }}" \
"install_smoke_release_checks=${{ needs.install_smoke_release_checks.result }}" \
@@ -1516,8 +1325,6 @@ jobs:
"package_acceptance_release_checks=${{ needs.package_acceptance_release_checks.result }}" \
"qa_lab_parity_lane_release_checks=${{ needs.qa_lab_parity_lane_release_checks.result }}" \
"qa_lab_parity_report_release_checks=${{ needs.qa_lab_parity_report_release_checks.result }}" \
"qa_lab_runtime_parity_release_checks=${{ needs.qa_lab_runtime_parity_release_checks.result }}" \
"runtime_tool_coverage_release_checks=${{ needs.runtime_tool_coverage_release_checks.result }}" \
"qa_live_matrix_release_checks=${{ needs.qa_live_matrix_release_checks.result }}" \
"qa_live_telegram_release_checks=${{ needs.qa_live_telegram_release_checks.result }}" \
"qa_live_discord_release_checks=${{ needs.qa_live_discord_release_checks.result }}" \
@@ -1527,15 +1334,6 @@ jobs:
name="${item%%=*}"
result="${item#*=}"
if [[ "$result" != "success" && "$result" != "skipped" ]]; then
if [[ "$tideclaw_alpha" == "true" ]]; then
case "$name" in
prepare_release_package|install_smoke_release_checks) ;;
*)
echo "::warning::${name} ended with ${result}; Tideclaw alpha treats non-package-safety release-check lanes as advisory."
continue
;;
esac
fi
if [[ "$name" == qa_* ]]; then
echo "::warning::${name} ended with ${result}; QA release-check lanes are advisory and do not block release validation."
continue

View File

@@ -11,14 +11,6 @@ on:
description: Successful OpenClaw NPM Release preflight run id, required when publish_openclaw_npm=true
required: false
type: string
full_release_validation_run_id:
description: Successful Full Release Validation run id for this tag/SHA, required when publish_openclaw_npm=true
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
type: string
npm_dist_tag:
description: npm dist-tag for the OpenClaw package
required: true
@@ -80,13 +72,11 @@ jobs:
timeout-minutes: 20
outputs:
sha: ${{ steps.manifest.outputs.sha || steps.ref.outputs.sha }}
preflight_artifact_name: ${{ steps.preflight_artifact.outputs.name }}
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 }}
PUBLISH_OPENCLAW_NPM: ${{ inputs.publish_openclaw_npm && 'true' || 'false' }}
PLUGIN_PUBLISH_SCOPE: ${{ inputs.plugin_publish_scope }}
PLUGINS: ${{ inputs.plugins }}
@@ -111,16 +101,8 @@ jobs:
echo "publish_openclaw_npm=true requires preflight_run_id." >&2
exit 1
fi
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && -z "${FULL_RELEASE_VALIDATION_RUN_ID}" ]]; then
echo "publish_openclaw_npm=true requires full_release_validation_run_id." >&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
fi
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && "${WORKFLOW_REF}" != "refs/heads/main" && ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ && "${tideclaw_alpha_publish}" != "true" ]]; then
echo "publish_openclaw_npm=true requires dispatching this workflow from main, release/YYYY.M.D, or a Tideclaw alpha branch for alpha prereleases." >&2
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && "${WORKFLOW_REF}" != "refs/heads/main" && ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]]; then
echo "publish_openclaw_npm=true requires dispatching this workflow from main or release/YYYY.M.D." >&2
exit 1
fi
if [[ "${PLUGIN_PUBLISH_SCOPE}" == "selected" && -z "${PLUGINS}" ]]; then
@@ -140,52 +122,13 @@ jobs:
esac
- name: Download OpenClaw npm preflight manifest
id: preflight_artifact
if: ${{ inputs.publish_openclaw_npm }}
env:
GH_TOKEN: ${{ github.token }}
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
preferred_name="openclaw-npm-preflight-${RELEASE_TAG}"
preflight_dir="${RUNNER_TEMP}/openclaw-npm-preflight-manifest"
rm -rf "${preflight_dir}"
mkdir -p "${preflight_dir}"
if gh run download "${PREFLIGHT_RUN_ID}" \
--repo "${GITHUB_REPOSITORY}" \
--name "${preferred_name}" \
--dir "${preflight_dir}"; then
echo "name=${preferred_name}" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "::warning::${preferred_name} not found; checking run artifacts for a single compatible preflight artifact."
mapfile -t matches < <(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/runs/${PREFLIGHT_RUN_ID}/artifacts" \
--jq '.artifacts[] | select(.expired != true) | .name' |
grep '^openclaw-npm-preflight-' || true)
if [[ "${#matches[@]}" != "1" ]]; then
echo "Expected ${preferred_name}, or exactly one openclaw-npm-preflight-* fallback artifact in run ${PREFLIGHT_RUN_ID}." >&2
printf 'Available preflight candidates:\n' >&2
printf -- '- %s\n' "${matches[@]:-<none>}" >&2
exit 1
fi
fallback_name="${matches[0]}"
gh run download "${PREFLIGHT_RUN_ID}" \
--repo "${GITHUB_REPOSITORY}" \
--name "${fallback_name}" \
--dir "${preflight_dir}"
echo "name=${fallback_name}" >> "$GITHUB_OUTPUT"
- name: Download full release validation manifest
if: ${{ inputs.publish_openclaw_npm }}
uses: actions/download-artifact@v8
with:
name: full-release-validation-${{ inputs.full_release_validation_run_id }}
path: ${{ runner.temp }}/full-release-validation-manifest
name: openclaw-npm-preflight-${{ inputs.tag }}
path: ${{ runner.temp }}/openclaw-npm-preflight-manifest
repository: ${{ github.repository }}
run-id: ${{ inputs.full_release_validation_run_id }}
run-id: ${{ inputs.preflight_run_id }}
github-token: ${{ github.token }}
- name: Checkout release tag
@@ -243,50 +186,7 @@ jobs:
fi
echo "sha=$release_sha" >> "$GITHUB_OUTPUT"
- name: Validate full release validation manifest
if: ${{ inputs.publish_openclaw_npm }}
env:
GH_TOKEN: ${{ github.token }}
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
EXPECTED_SHA: ${{ steps.ref.outputs.sha }}
EXPECTED_RELEASE_PROFILE: ${{ inputs.release_profile }}
EXPECTED_WORKFLOW_BRANCH: ${{ github.ref_name }}
run: |
set -euo pipefail
RUN_JSON="$(gh run view "$FULL_RELEASE_VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "Full Release Validation"], ["headBranch", process.env.EXPECTED_WORKFLOW_BRANCH], ["event", "workflow_dispatch"], ["status", "completed"], ["conclusion", "success"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced full release validation run ${process.env.FULL_RELEASE_VALIDATION_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } console.log(`Using full release validation run ${process.env.FULL_RELEASE_VALIDATION_RUN_ID}: ${run.url}`);'
manifest="${RUNNER_TEMP}/full-release-validation-manifest/full-release-validation-manifest.json"
if [[ ! -f "$manifest" ]]; then
echo "Full release validation manifest is missing." >&2
ls -la "${RUNNER_TEMP}/full-release-validation-manifest" >&2 || true
exit 1
fi
workflow_name="$(jq -r '.workflowName // ""' "$manifest")"
target_sha="$(jq -r '.targetSha // ""' "$manifest")"
release_profile="$(jq -r '.releaseProfile // ""' "$manifest")"
rerun_group="$(jq -r '.rerunGroup // ""' "$manifest")"
if [[ "$workflow_name" != "Full Release Validation" ]]; then
echo "Full release validation manifest workflow mismatch: $workflow_name" >&2
exit 1
fi
if [[ "$target_sha" != "$EXPECTED_SHA" ]]; then
echo "Full release validation target SHA mismatch: expected $EXPECTED_SHA, got $target_sha" >&2
exit 1
fi
if [[ "$release_profile" != "$EXPECTED_RELEASE_PROFILE" ]]; then
echo "Full release validation profile mismatch: expected $EXPECTED_RELEASE_PROFILE, got $release_profile" >&2
exit 1
fi
if [[ "$rerun_group" != "all" ]]; then
echo "Full release validation must run rerun_group=all before npm publish; got $rerun_group" >&2
exit 1
fi
- name: Validate release tag is reachable from a trusted release branch
env:
RELEASE_TAG: ${{ inputs.tag }}
WORKFLOW_REF_NAME: ${{ github.ref_name }}
- name: Validate release tag is reachable from main or release branch
run: |
set -euo pipefail
git fetch --no-tags origin \
@@ -300,17 +200,7 @@ jobs:
exit 0
fi
done < <(git for-each-ref --format='%(refname)' refs/remotes/origin/release)
if [[ "${RELEASE_TAG}" == *"-alpha."* ]]; then
if [[ ! "${WORKFLOW_REF_NAME}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
echo "Alpha publish tags must be dispatched from tideclaw/alpha/YYYY-MM-DD-HHMMZ." >&2
exit 1
fi
git fetch --no-tags origin "+refs/heads/${WORKFLOW_REF_NAME}:refs/remotes/origin/${WORKFLOW_REF_NAME}"
if git merge-base --is-ancestor HEAD "refs/remotes/origin/${WORKFLOW_REF_NAME}"; then
exit 0
fi
fi
echo "Release tag must point to a commit reachable from main, release/*, or the matching Tideclaw alpha branch for alpha prereleases." >&2
echo "Release tag must point to a commit reachable from main or release/*." >&2
exit 1
- name: Summarize release target
@@ -318,7 +208,6 @@ jobs:
RELEASE_TAG: ${{ inputs.tag }}
TARGET_SHA: ${{ steps.manifest.outputs.sha || steps.ref.outputs.sha }}
RELEASE_PROFILE: ${{ inputs.release_profile }}
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
run: |
{
echo "### Release target"
@@ -326,9 +215,6 @@ jobs:
echo "- Tag: \`${RELEASE_TAG}\`"
echo "- SHA: \`${TARGET_SHA}\`"
echo "- Release profile: \`${RELEASE_PROFILE}\`"
if [[ -n "${FULL_RELEASE_VALIDATION_RUN_ID// }" ]]; then
echo "- Full release validation: \`${FULL_RELEASE_VALIDATION_RUN_ID}\`"
fi
} >> "$GITHUB_STEP_SUMMARY"
publish:
@@ -344,12 +230,6 @@ jobs:
fetch-depth: 1
persist-credentials: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
cache-key-suffix: release-publish
- name: Dispatch publish workflows
env:
GH_TOKEN: ${{ github.token }}
@@ -357,15 +237,11 @@ jobs:
CHILD_WORKFLOW_REF: ${{ github.ref_name }}
RELEASE_TAG: ${{ inputs.tag }}
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
PLUGIN_PUBLISH_SCOPE: ${{ inputs.plugin_publish_scope }}
PLUGINS: ${{ inputs.plugins }}
PUBLISH_OPENCLAW_NPM: ${{ inputs.publish_openclaw_npm && 'true' || 'false' }}
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 }}
POSTPUBLISH_EVIDENCE_DIR: ${{ runner.temp }}/openclaw-release-postpublish-evidence
run: |
set -euo pipefail
@@ -374,10 +250,7 @@ jobs:
shift
local before_json dispatch_output run_id
before_json="$(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow}/runs" \
-F event=workflow_dispatch \
-F per_page=100 \
--jq '[.workflow_runs[].id]')"
before_json="$(gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
dispatch_output="$(gh workflow run --repo "$GITHUB_REPOSITORY" "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)"
printf '%s\n' "$dispatch_output" >&2
@@ -390,10 +263,8 @@ jobs:
if [[ -z "$run_id" ]]; then
for _ in $(seq 1 60); do
run_id="$(
BEFORE_IDS="$before_json" gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow}/runs" \
-F event=workflow_dispatch \
-F per_page=50 \
--jq '.workflow_runs | map({databaseId:.id, createdAt:.created_at}) | map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
BEFORE_IDS="$before_json" gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --event workflow_dispatch --limit 50 --json databaseId,createdAt \
--jq 'map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
)"
if [[ -n "$run_id" ]]; then
break
@@ -414,73 +285,6 @@ jobs:
printf '%s\n' "${run_id}"
}
print_pending_deployments() {
local workflow="$1"
local run_id="$2"
local pending_json
pending_json="$(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/pending_deployments" 2>/dev/null || true)"
if [[ -z "${pending_json}" ]] || ! printf '%s' "${pending_json}" | jq -e 'length > 0' >/dev/null 2>&1; then
return 0
fi
echo "${workflow} pending environment approval:"
while IFS=$'\t' read -r env_id env_name can_approve; do
echo "- env=${env_name} canApprove=${can_approve}"
echo " approve: gh api -X POST repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/pending_deployments -F 'environment_ids[]=${env_id}' -f state=approved -f comment='Approve release gate'"
done < <(printf '%s' "${pending_json}" | jq -r '.[] | [.environment.id, .environment.name, .current_user_can_approve] | @tsv')
}
approve_pending_deployments() {
local workflow="$1"
local run_id="$2"
local pending_json approved
pending_json="$(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/pending_deployments" 2>/dev/null || true)"
if [[ -z "${pending_json}" ]] || ! printf '%s' "${pending_json}" | jq -e 'length > 0' >/dev/null 2>&1; then
return 0
fi
approved=0
while IFS=$'\t' read -r env_id env_name; do
if [[ -z "${env_id}" ]]; then
continue
fi
echo "${workflow}: approving pending environment ${env_name} (${env_id})"
gh api -X POST "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/pending_deployments" \
-F "environment_ids[]=${env_id}" \
-f state=approved \
-f comment="Approve release gate from OpenClaw Release Publish wrapper" >/dev/null
approved=1
done < <(printf '%s' "${pending_json}" | jq -r '.[] | select(.current_user_can_approve == true) | [.environment.id, .environment.name] | @tsv')
if [[ "${approved}" == "1" ]]; then
echo "${workflow}: approved available pending environment gates"
fi
}
print_failed_run_summary() {
local run_id="$1"
local failed_json
failed_json="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json jobs \
--jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {databaseId, name, conclusion, url}' || true)"
if [[ -z "${failed_json}" ]]; then
return 0
fi
echo "Failed child job summary:"
printf '%s\n' "${failed_json}"
while IFS=$'\t' read -r job_id job_name; do
if [[ -z "${job_id}" ]]; then
continue
fi
echo "--- ${job_name} (${job_id}) log tail ---"
gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --job "${job_id}" --log 2>/dev/null |
tail -200 || true
done < <(printf '%s\n' "${failed_json}" | jq -r '[.databaseId, .name] | @tsv' 2>/dev/null || true)
}
wait_for_run() {
local workflow="$1"
local run_id="$2"
@@ -498,8 +302,6 @@ jobs:
state="${status}:${updated_at}"
if [[ "$state" != "$last_state" ]]; then
echo "${workflow} still ${status} (updated ${updated_at}): ${url}"
print_pending_deployments "${workflow}" "${run_id}"
approve_pending_deployments "${workflow}" "${run_id}"
last_state="$state"
fi
sleep 30
@@ -527,7 +329,7 @@ jobs:
echo "- ${workflow}: ${conclusion} in ${duration_label} (${url})"
} >> "$GITHUB_STEP_SUMMARY"
if [[ "$conclusion" != "success" ]]; then
print_failed_run_summary "${run_id}"
gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json jobs --jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' || true
return 1
fi
}
@@ -599,70 +401,6 @@ jobs:
echo "- GitHub release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY"
}
upload_dependency_evidence_release_asset() {
local release_version download_dir asset_path asset_name artifact_name
release_version="${RELEASE_TAG#v}"
download_dir="${RUNNER_TEMP}/openclaw-release-dependency-evidence-asset"
asset_name="openclaw-${release_version}-dependency-evidence.zip"
asset_path="${RUNNER_TEMP}/${asset_name}"
artifact_name="${PREFLIGHT_ARTIFACT_NAME:-openclaw-npm-preflight-${RELEASE_TAG}}"
rm -rf "${download_dir}" "${asset_path}"
mkdir -p "${download_dir}"
gh run download "${PREFLIGHT_RUN_ID}" \
--repo "${GITHUB_REPOSITORY}" \
--name "${artifact_name}" \
--dir "${download_dir}"
if [[ ! -d "${download_dir}/dependency-evidence" ]]; then
echo "Dependency evidence is missing from OpenClaw npm preflight artifact." >&2
find "${download_dir}" -maxdepth 2 -type f -print >&2 || true
exit 1
fi
(cd "${download_dir}" && zip -qr "${asset_path}" dependency-evidence)
gh release upload "${RELEASE_TAG}" "${asset_path}#${asset_name}" \
--repo "${GITHUB_REPOSITORY}" \
--clobber
echo "- Dependency evidence asset: \`${asset_name}\`" >> "$GITHUB_STEP_SUMMARY"
}
verify_published_release() {
local release_version evidence_path
local -a verify_args
release_version="${RELEASE_TAG#v}"
evidence_path="${POSTPUBLISH_EVIDENCE_DIR}/release-postpublish-evidence.json"
mkdir -p "${POSTPUBLISH_EVIDENCE_DIR}"
verify_args=(
release:verify-beta
--
"${release_version}"
--tag "${RELEASE_TAG}"
--dist-tag "${RELEASE_NPM_DIST_TAG}"
--repo "${GITHUB_REPOSITORY}"
--workflow-ref "${CHILD_WORKFLOW_REF}"
--full-release-validation-run "${FULL_RELEASE_VALIDATION_RUN_ID}"
--plugin-npm-run "${plugin_npm_run_id}"
--plugin-clawhub-run "${plugin_clawhub_run_id}"
--openclaw-npm-run "${openclaw_npm_run_id}"
--evidence-out "${evidence_path}"
)
if [[ -n "${PLUGINS// }" ]]; then
verify_args+=(--plugins "${PLUGINS}")
fi
if [[ -n "${NPM_TELEGRAM_RUN_ID// }" ]]; then
verify_args+=(--npm-telegram-run "${NPM_TELEGRAM_RUN_ID}")
fi
pnpm "${verify_args[@]}"
{
echo "- Postpublish verification: passed"
echo "- Postpublish evidence: \`${evidence_path}\`"
} >> "$GITHUB_STEP_SUMMARY"
}
{
echo "### Publish sequence"
echo
@@ -671,11 +409,11 @@ jobs:
echo "- Release SHA: \`${TARGET_SHA}\`"
echo "- Plugin npm and ClawHub publish: dispatched in parallel"
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
echo "- OpenClaw npm publish: starts after plugin npm succeeds; final verification waits for ClawHub"
echo "- OpenClaw npm publish: starts after plugin npm succeeds; ClawHub may still be running"
else
echo "- OpenClaw npm publish: skipped by input"
fi
if [[ "${WAIT_FOR_CLAWHUB}" == "true" || "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
echo "- Workflow completion waits for ClawHub"
else
echo "- Workflow completion does not wait for ClawHub; monitor the dispatched ClawHub run separately"
@@ -708,7 +446,6 @@ jobs:
-f tag="${RELEASE_TAG}" \
-f preflight_only=false \
-f preflight_run_id="${PREFLIGHT_RUN_ID}" \
-f full_release_validation_run_id="${FULL_RELEASE_VALIDATION_RUN_ID}" \
-f npm_dist_tag="${RELEASE_NPM_DIST_TAG}")"
echo "- OpenClaw npm run ID: \`${openclaw_npm_run_id}\`" >> "$GITHUB_STEP_SUMMARY"
else
@@ -717,7 +454,7 @@ jobs:
clawhub_result=""
clawhub_pid=""
if [[ "${WAIT_FOR_CLAWHUB}" == "true" || "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; 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}"
@@ -736,39 +473,22 @@ jobs:
fi
failed=0
openclaw_failed=0
if [[ -n "${openclaw_pid}" ]] && ! wait "${openclaw_pid}"; then
failed=1
openclaw_failed=1
fi
if [[ -n "${openclaw_result}" && -f "${openclaw_result}" && "$(cat "${openclaw_result}")" != "success" ]]; then
failed=1
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 [[ -n "${openclaw_pid}" ]] && ! wait "${openclaw_pid}"; then
failed=1
fi
if [[ -f "${clawhub_result}" && "$(cat "${clawhub_result}")" != "success" ]]; then
failed=1
fi
if [[ "${failed}" == "0" && -n "${openclaw_npm_run_id}" ]]; then
verify_published_release
if [[ -n "${openclaw_result}" && -f "${openclaw_result}" && "$(cat "${openclaw_result}")" != "success" ]]; then
failed=1
fi
if [[ "${failed}" != "0" ]]; then
exit 1
fi
- name: Upload postpublish evidence
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: openclaw-release-postpublish-evidence-${{ inputs.tag }}
path: ${{ runner.temp }}/openclaw-release-postpublish-evidence
if-no-files-found: ignore
if [[ -n "${openclaw_npm_run_id}" ]]; then
create_or_update_github_release
fi

View File

@@ -6,7 +6,6 @@ on:
workflow_dispatch:
permissions:
actions: read
contents: read
packages: write
pull-requests: read
@@ -21,7 +20,6 @@ env:
jobs:
live_and_openwebui_checks:
permissions:
actions: read
contents: read
packages: write
pull-requests: read

View File

@@ -62,7 +62,7 @@ jobs:
- name: Upload SARIF as workflow artifact
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: opengrep-full-sarif
path: .opengrep-out/precise.sarif

View File

@@ -92,7 +92,7 @@ jobs:
- name: Upload SARIF as workflow artifact
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: opengrep-pr-diff-sarif
path: .opengrep-out/precise.sarif

View File

@@ -93,18 +93,8 @@ on:
required: false
default: ""
type: string
advisory:
description: Treat acceptance failures as advisory for the caller
required: false
default: false
type: boolean
workflow_call:
inputs:
advisory:
description: Treat acceptance failures as advisory for the caller
required: false
default: false
type: boolean
workflow_ref:
description: Trusted repo ref for workflow scripts and Docker E2E harness
required: false
@@ -294,7 +284,7 @@ env:
jobs:
resolve_package:
name: Resolve package candidate
runs-on: ubuntu-24.04
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 60
outputs:
docker_lanes: ${{ steps.profile.outputs.docker_lanes }}
@@ -396,10 +386,10 @@ jobs:
docker_lanes="npm-onboard-channel-agent gateway-network config-reload"
;;
package)
docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch skill-install update-corrupt-plugin upgrade-survivor published-upgrade-survivor root-managed-vps-upgrade update-restart-auth plugins-offline plugin-update"
docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch skill-install update-corrupt-plugin upgrade-survivor published-upgrade-survivor update-restart-auth plugins-offline plugin-update"
;;
product)
docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch skill-install update-corrupt-plugin upgrade-survivor published-upgrade-survivor root-managed-vps-upgrade update-restart-auth plugins plugin-update mcp-channels cron-mcp-cleanup openai-web-search-minimal openwebui"
docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch skill-install update-corrupt-plugin upgrade-survivor published-upgrade-survivor update-restart-auth plugins plugin-update mcp-channels cron-mcp-cleanup openai-web-search-minimal openwebui"
include_openwebui=true
;;
full)
@@ -519,7 +509,6 @@ jobs:
needs: resolve_package
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
with:
advisory: ${{ inputs.advisory }}
ref: ${{ needs.resolve_package.outputs.package_source_sha || inputs.workflow_ref }}
include_repo_e2e: false
include_release_path_suites: ${{ needs.resolve_package.outputs.include_release_path_suites == 'true' }}
@@ -584,7 +573,6 @@ jobs:
if: needs.resolve_package.outputs.telegram_enabled == 'true'
uses: ./.github/workflows/npm-telegram-beta-e2e.yml
with:
advisory: ${{ inputs.advisory }}
package_spec: ${{ inputs.package_spec }}
package_artifact_name: ${{ needs.resolve_package.outputs.package_artifact_name }}
package_label: openclaw@${{ needs.resolve_package.outputs.package_version }}
@@ -600,7 +588,7 @@ jobs:
name: Verify package acceptance
needs: [resolve_package, docker_acceptance, package_telegram]
if: always()
runs-on: ubuntu-24.04
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 5
steps:
- name: Verify package acceptance results
@@ -611,7 +599,6 @@ jobs:
shell: bash
run: |
set -euo pipefail
advisory="${{ inputs.advisory }}"
failed=0
for item in \
"resolve_package=${RESOLVE_RESULT}" \
@@ -621,10 +608,6 @@ jobs:
name="${item%%=*}"
result="${item#*=}"
if [[ "$result" != "success" && "$result" != "skipped" ]]; then
if [[ "$advisory" == "true" && "$name" != "resolve_package" ]]; then
echo "::warning::${name} ended with ${result}; package acceptance is advisory for this caller."
continue
fi
echo "::error::${name} ended with ${result}"
failed=1
fi

View File

@@ -16,7 +16,7 @@ on:
required: false
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
description: Commit SHA on main or a release branch to publish from; defaults to the workflow ref
required: false
default: ""
type: string
@@ -82,9 +82,7 @@ jobs:
fi
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Validate ref is on a trusted publish branch
env:
WORKFLOW_REF: ${{ github.ref }}
- name: Validate ref is on main or a release branch
run: |
set -euo pipefail
if git merge-base --is-ancestor HEAD origin/main; then
@@ -95,14 +93,7 @@ jobs:
exit 0
fi
done < <(git for-each-ref --format='%(refname)' refs/remotes/origin/release)
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
fi
fi
echo "Plugin ClawHub publishes must target a commit reachable from main, release/*, or the matching Tideclaw alpha branch." >&2
echo "Plugin ClawHub publishes must target a commit reachable from main or release/*." >&2
exit 1
- name: Validate publishable plugin metadata
@@ -177,19 +168,6 @@ jobs:
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
if: startsWith(github.ref, 'refs/heads/tideclaw/alpha/')
run: |
set -euo pipefail
invalid="$(
jq -r '.candidates[]? | select(.publishTag != "alpha" or .channel != "alpha") | "- \(.packageName)@\(.version) [\(.publishTag)]"' .local/plugin-clawhub-release-plan.json
)"
if [[ -n "${invalid}" ]]; then
echo "Tideclaw alpha ClawHub publishes may only publish alpha plugin versions." >&2
printf '%s\n' "${invalid}" >&2
exit 1
fi
- name: Verify OpenClaw ClawHub package ownership
if: steps.plan.outputs.has_candidates == 'true'
env:
@@ -466,14 +444,10 @@ jobs:
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);
const response = await fetch(url, { redirect: "manual", ...options });
lastStatus = response.status;
if (response.status !== 429 && response.status < 500) {
return response;
}
await new Promise((resolve) => setTimeout(resolve, attempt * 5000));
}

View File

@@ -25,7 +25,7 @@ on:
- selected
- all-publishable
ref:
description: Commit SHA on main, a release branch, or the matching Tideclaw alpha branch to publish from
description: Commit SHA on main or a release branch to publish from (copy from the preview run)
required: true
type: string
plugins:
@@ -71,9 +71,7 @@ jobs:
id: ref
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Validate ref is on a trusted publish branch
env:
WORKFLOW_REF: ${{ github.ref }}
- name: Validate ref is on main or a release branch
run: |
set -euo pipefail
git fetch --no-tags origin \
@@ -87,14 +85,7 @@ jobs:
exit 0
fi
done < <(git for-each-ref --format='%(refname)' refs/remotes/origin/release)
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
fi
fi
echo "Plugin npm publishes must target a commit reachable from main, release/*, or the matching Tideclaw alpha branch." >&2
echo "Plugin npm publishes must target a commit reachable from main or release/*." >&2
exit 1
- name: Validate publishable plugin metadata
@@ -160,19 +151,6 @@ jobs:
echo "Already published / skipped:"
jq -r '.skippedPublished[]? | "- \(.packageName)@\(.version)"' .local/plugin-npm-release-plan.json
- name: Validate Tideclaw alpha plugin channels
if: startsWith(github.ref, 'refs/heads/tideclaw/alpha/')
run: |
set -euo pipefail
invalid="$(
jq -r '.candidates[]? | select(.publishTag != "alpha" or .channel != "alpha") | "- \(.packageName)@\(.version) [\(.publishTag)]"' .local/plugin-npm-release-plan.json
)"
if [[ -n "${invalid}" ]]; then
echo "Tideclaw alpha plugin npm publishes may only publish alpha plugin versions." >&2
printf '%s\n' "${invalid}" >&2
exit 1
fi
preview_plugin_pack:
needs: preview_plugins_npm
if: needs.preview_plugins_npm.outputs.has_candidates == 'true'

View File

@@ -209,7 +209,7 @@ jobs:
name: ${{ matrix.check_name }}
needs: [preflight]
if: needs.preflight.outputs.run_plugin_prerelease_static == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }}
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 45
strategy:
fail-fast: false
@@ -245,7 +245,7 @@ jobs:
name: ${{ matrix.check_name }}
needs: [preflight]
if: needs.preflight.outputs.run_plugin_prerelease_node == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (matrix.runner || 'ubuntu-24.04') }}
runs-on: ${{ matrix.runner || 'ubuntu-24.04' }}
timeout-minutes: 60
strategy:
fail-fast: false
@@ -318,7 +318,7 @@ jobs:
name: ${{ matrix.check_name }}
needs: [preflight]
if: needs.preflight.outputs.run_plugin_prerelease_extensions == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || matrix.runner }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 60
strategy:
fail-fast: false

View File

@@ -60,17 +60,13 @@ jobs:
authorize_actor:
name: Authorize workflow actor
runs-on: blacksmith-8vcpu-ubuntu-2404
outputs:
authorized: ${{ steps.permission.outputs.authorized }}
steps:
- name: Require maintainer-level repository access
id: permission
uses: actions/github-script@v8
with:
script: |
if (context.eventName === "schedule") {
core.info("Scheduled default-branch QA run; actor permission check is only required for manual dispatch.");
core.setOutput("authorized", "true");
return;
}
const allowed = new Set(["admin", "maintain", "write"]);
@@ -83,18 +79,14 @@ jobs:
const permission = data.permission;
core.info(`Actor ${context.actor} permission: ${permission}`);
if (!allowed.has(permission)) {
core.notice(
core.setFailed(
`Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`,
);
core.setOutput("authorized", "false");
return;
}
core.setOutput("authorized", "true");
validate_selected_ref:
name: Validate selected ref
needs: authorize_actor
if: needs.authorize_actor.outputs.authorized == 'true'
runs-on: blacksmith-8vcpu-ubuntu-2404
outputs:
selected_revision: ${{ steps.validate.outputs.selected_revision }}
@@ -186,8 +178,6 @@ jobs:
install-bun: "true"
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
run: pnpm build
- name: Run OpenAI candidate lane
@@ -198,7 +188,7 @@ jobs:
--concurrency "${QA_PARITY_CONCURRENCY}" \
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model openai/gpt-5.5-alt \
--output-dir .artifacts/qa-e2e/openai-candidate
--output-dir .artifacts/qa-e2e/gpt54
- name: Run Opus 4.7 lane
run: |
@@ -207,118 +197,28 @@ jobs:
--parity-pack agentic \
--concurrency "${QA_PARITY_CONCURRENCY}" \
--model anthropic/claude-opus-4-7 \
--alt-model anthropic/claude-sonnet-4-6 \
--output-dir .artifacts/qa-e2e/anthropic-baseline
--alt-model anthropic/claude-sonnet-4-7 \
--output-dir .artifacts/qa-e2e/opus46
- name: Generate parity report
run: |
pnpm openclaw qa parity-report \
--repo-root . \
--candidate-summary .artifacts/qa-e2e/openai-candidate/qa-suite-summary.json \
--baseline-summary .artifacts/qa-e2e/anthropic-baseline/qa-suite-summary.json \
--candidate-summary .artifacts/qa-e2e/gpt54/qa-suite-summary.json \
--baseline-summary .artifacts/qa-e2e/opus46/qa-suite-summary.json \
--candidate-label "${OPENCLAW_CI_OPENAI_MODEL}" \
--baseline-label anthropic/claude-opus-4-7 \
--output-dir .artifacts/qa-e2e/parity
- name: Upload parity artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: qa-parity-${{ github.run_id }}-${{ github.run_attempt }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: warn
run_live_runtime_token_efficiency:
name: Run live runtime token-efficiency lane
needs: [authorize_actor, validate_selected_ref]
if: github.event_name == 'schedule'
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 45
environment: qa-live-shared
env:
QA_PARITY_CONCURRENCY: "1"
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
steps:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
fetch-depth: 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "true"
- name: Validate required QA credential env
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
shell: bash
run: |
set -euo pipefail
if [[ -z "${OPENAI_API_KEY:-}" ]]; then
echo "Missing required OPENAI_API_KEY." >&2
exit 1
fi
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
run: pnpm build
- name: Run live runtime parity lane
id: run_lane
shell: bash
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENCLAW_LIVE_OPENAI_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
set -euo pipefail
output_dir=".artifacts/qa-e2e/runtime-token-efficiency-live-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
echo "output_dir=${output_dir}" >> "$GITHUB_OUTPUT"
pnpm openclaw qa suite \
--repo-root . \
--provider-mode live-frontier \
--runtime-parity-tier standard \
--runtime-parity-tier live-only \
--concurrency "${QA_PARITY_CONCURRENCY}" \
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model "${OPENCLAW_CI_OPENAI_MODEL}" \
--runtime-pair pi,codex \
--fast \
--allow-failures \
--output-dir "${output_dir}/runtime-suite"
- name: Generate live runtime token-efficiency report
if: always() && steps.run_lane.outcome != 'skipped' && steps.run_lane.outcome != 'cancelled'
shell: bash
run: |
set -euo pipefail
pnpm openclaw qa parity-report \
--repo-root . \
--runtime-axis \
--token-efficiency \
--summary "${{ steps.run_lane.outputs.output_dir }}/runtime-suite/qa-suite-summary.json" \
--output-dir "${{ steps.run_lane.outputs.output_dir }}/runtime-report"
- name: Upload live runtime token-efficiency artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: qa-live-runtime-token-efficiency-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
retention-days: 14
if-no-files-found: warn
run_live_matrix:
name: Run Matrix live QA lane
needs: [authorize_actor, validate_selected_ref]
@@ -354,8 +254,6 @@ jobs:
fi
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
run: pnpm build
- name: Run Matrix live lane
@@ -389,7 +287,7 @@ jobs:
- name: Upload Matrix QA artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: qa-live-matrix-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
@@ -440,8 +338,6 @@ jobs:
fi
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
run: pnpm build
- name: Run Matrix live lane shard
@@ -474,7 +370,7 @@ jobs:
- name: Upload Matrix QA shard artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: qa-live-matrix-${{ matrix.profile }}-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
@@ -524,8 +420,6 @@ jobs:
require_var OPENCLAW_QA_CONVEX_SECRET_CI
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
run: pnpm build
- name: Run Telegram live lane
@@ -569,7 +463,7 @@ jobs:
- name: Upload Telegram QA artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: qa-live-telegram-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
@@ -619,8 +513,6 @@ jobs:
require_var OPENCLAW_QA_CONVEX_SECRET_CI
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
run: pnpm build
- name: Run Discord live lane
@@ -655,8 +547,8 @@ jobs:
--repo-root . \
--output-dir "${output_dir}" \
--provider-mode live-frontier \
--model openai/gpt-5.5 \
--alt-model openai/gpt-5.5 \
--model openai/gpt-5.4 \
--alt-model openai/gpt-5.4 \
--fast \
--credential-source convex \
--credential-role ci \
@@ -664,7 +556,7 @@ jobs:
- name: Upload Discord QA artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: qa-live-discord-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
@@ -676,9 +568,6 @@ jobs:
needs: [authorize_actor, validate_selected_ref]
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 60
concurrency:
group: qa-live-whatsapp-shared
cancel-in-progress: false
environment: qa-live-shared
steps:
- name: Checkout selected ref
@@ -717,8 +606,6 @@ jobs:
require_var OPENCLAW_QA_CONVEX_SECRET_CI
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
run: pnpm build
- name: Run WhatsApp live lane
@@ -762,7 +649,7 @@ jobs:
- name: Upload WhatsApp QA artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: qa-live-whatsapp-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
@@ -812,8 +699,6 @@ jobs:
require_var OPENCLAW_QA_CONVEX_SECRET_CI
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
run: pnpm build
- name: Run Slack live lane
@@ -857,7 +742,7 @@ jobs:
- name: Upload Slack QA artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: qa-live-slack-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}

View File

@@ -18,7 +18,6 @@ jobs:
name: Real behavior proof
permissions:
contents: read
issues: read
pull-requests: read
runs-on: ubuntu-24.04
steps:
@@ -26,25 +25,5 @@ jobs:
with:
ref: ${{ github.event.pull_request.base.sha }}
persist-credentials: false
- uses: actions/create-github-app-token@v3
id: app-token
continue-on-error: true
with:
app-id: "2729701"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
permission-issues: read
permission-members: read
- uses: actions/create-github-app-token@v3
id: app-token-fallback
if: steps.app-token.outcome == 'failure'
continue-on-error: true
with:
app-id: "2971289"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
permission-issues: read
permission-members: read
- name: Check real behavior proof
env:
GH_APP_TOKEN: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
GITHUB_TOKEN: ${{ github.token }}
run: node scripts/github/real-behavior-proof-check.mjs

View File

@@ -134,29 +134,20 @@ jobs:
(github.event_name == 'push' && github.ref == 'refs/heads/main') ||
(github.event_name == 'workflow_dispatch' && inputs.sync_website)
runs-on: ubuntu-24.04
env:
OPENCLAW_GH_TOKEN: ${{ secrets.OPENCLAW_GH_TOKEN }}
steps:
- name: Skip website sync without token
if: env.OPENCLAW_GH_TOKEN == ''
run: echo "OPENCLAW_GH_TOKEN is not configured; installer verification passed, skipping website sync."
- name: Checkout OpenClaw
if: env.OPENCLAW_GH_TOKEN != ''
uses: actions/checkout@v6
with:
path: openclaw
- name: Checkout openclaw.ai
if: env.OPENCLAW_GH_TOKEN != ''
uses: actions/checkout@v6
with:
repository: openclaw/openclaw.ai
token: ${{ env.OPENCLAW_GH_TOKEN }}
token: ${{ secrets.OPENCLAW_GH_TOKEN }}
path: openclaw.ai
- name: Sync installer scripts
if: env.OPENCLAW_GH_TOKEN != ''
run: |
cp openclaw/scripts/install.sh openclaw.ai/public/install.sh
cp openclaw/scripts/install-cli.sh openclaw.ai/public/install-cli.sh
@@ -165,7 +156,6 @@ jobs:
chmod +x openclaw.ai/public/install.sh openclaw.ai/public/install-cli.sh
- name: Check for changes
if: env.OPENCLAW_GH_TOKEN != ''
id: changes
working-directory: openclaw.ai
run: |
@@ -206,10 +196,7 @@ jobs:
run: |
git config user.name "openclaw-installer-sync[bot]"
git config user.email "openclaw-installer-sync[bot]@users.noreply.github.com"
git add public/install.sh public/install-cli.sh public/install.ps1
if git ls-files --error-unmatch public/install.cmd >/dev/null 2>&1; then
git add -u -- public/install.cmd
fi
git add public/install.sh public/install-cli.sh public/install.ps1 public/install.cmd
git commit -m "chore: sync installers from openclaw ${GITHUB_SHA::12}"
git pull --rebase origin main
git push origin HEAD:main

View File

@@ -2,12 +2,8 @@ name: Workflow Sanity
on:
pull_request:
paths-ignore:
- "CHANGELOG.md"
push:
branches: [main]
paths-ignore:
- "CHANGELOG.md"
workflow_dispatch:
permissions:

9
.gitignore vendored
View File

@@ -41,7 +41,6 @@ apps/macos/.build/
apps/macos-mlx-tts/.build/
apps/shared/MoltbotKit/.build/
apps/shared/OpenClawKit/.build/
apps/shared/*/.build/
apps/shared/OpenClawKit/Package.resolved
**/ModuleCache/
bin/
@@ -51,7 +50,6 @@ apps/macos/.build-local/
apps/macos/.swiftpm/
apps/shared/MoltbotKit/.swiftpm/
apps/shared/OpenClawKit/.swiftpm/
apps/shared/*/.swiftpm/
Core/
apps/ios/*.xcodeproj/
apps/ios/*.xcworkspace/
@@ -110,9 +108,6 @@ USER.md
# local tooling
.serena/
# local QA evidence mirrors; CI publishes canonical Mantis files as Actions artifacts
mantis/
# Local project-agent skill installs. Only repo-owned skills are visible by
# default; promoting a new repo skill should require an intentional `git add -f`.
.agents/skills/*
@@ -120,8 +115,6 @@ mantis/
!.agents/skills/blacksmith-testbox/**
!.agents/skills/crabbox/
!.agents/skills/crabbox/**
!.agents/skills/clawdtributor/
!.agents/skills/clawdtributor/**
!.agents/skills/gitcrawl/
!.agents/skills/gitcrawl/**
!.agents/skills/openclaw-docs/**
@@ -139,8 +132,6 @@ mantis/
!.agents/skills/openclaw-refactor-docs/**
!.agents/skills/openclaw-qa-testing/
!.agents/skills/openclaw-qa-testing/**
!.agents/skills/openclaw-release-ci/
!.agents/skills/openclaw-release-ci/**
!.agents/skills/openclaw-release-maintainer/
!.agents/skills/openclaw-release-maintainer/**
!.agents/skills/openclaw-secret-scanning-maintainer/

View File

@@ -8,7 +8,6 @@
},
"rules": {
"curly": "error",
"eslint/no-underscore-dangle": "error",
"eslint-plugin-unicorn/prefer-array-find": "error",
"eslint/no-array-constructor": "error",
"eslint/no-await-in-loop": "off",

View File

@@ -10,12 +10,12 @@ Skills own workflows; root owns hard policy and routing.
- Docs/user-visible work: `pnpm docs:list`, then read relevant docs only.
- Fix/triage answers need source, tests, current/shipped behavior, and dependency contract proof.
- Dependency-backed behavior: read upstream docs/source/types first. No API/default/error/timing guesses.
- Live-verify when feasible. Never print secrets.
- Live-verify when feasible. Check env/`~/.profile` for keys before saying blocked; never print secrets.
- Missing deps: `pnpm install`, retry once, then report first actionable error.
- CODEOWNERS: maint/refactor/tests ok. Larger behavior/product/security/ownership: owner ask/review.
- Product/docs/UI/changelog wording: "plugin/plugins"; `extensions/` is internal.
- New channel/plugin/app/doc surface: update `.github/labeler.yml` + GH labels.
- New `AGENTS.md`: add sibling `CLAUDE.md` symlink; edit `AGENTS.md` only.
- New `AGENTS.md`: add sibling `CLAUDE.md` symlink.
## Map
@@ -31,36 +31,23 @@ Skills own workflows; root owns hard policy and routing.
- Core/tests: no deep plugin internals (`extensions/*/src/**`, `onboard.js`). Use public barrels, SDK facade, generic contracts.
- Owner boundary: owner-specific repair/detection/onboarding/auth/defaults/provider behavior lives in owner plugin. Shared/core gets generic seams only.
- Dependency ownership follows runtime ownership: plugin-only deps stay plugin-local; root deps only for core imports or intentionally internalized bundled plugin runtime.
- Internal bundled plugins ship in core dist; bundled-only facade loader ok only for them.
- External official plugins own package/deps and are excluded from core dist; core uses registry-aware `facade-runtime` or generic contracts.
- Externalizing a bundled plugin: update package excludes, official catalogs, docs, tests, and prove core runtime paths resolve installed plugin roots before root-dep removal.
- Legacy config repair belongs in `openclaw doctor --fix`, not startup/load-time core migrations. Runtime paths use canonical contracts.
- Fix shape: default to clean bounded refactor, not smallest patch. Move ownership to right boundary; delete stale abstractions, duplicate policy, dead branches, wrappers, fallback stacks.
- Lean code is a goal. No internal shims, aliases, legacy names, broad fallbacks, or defensive branches just to reduce diff or handle unrealistic edge cases.
- Handle real production states, shipped upgrade paths, security boundaries, and dependency contracts. Public/hostile/observed malformed input gets care; hypothetical malformed input does not.
- Public plugin SDK/API is the compat exception. New API first, old path only via named compat/deprecation metadata, docs, warnings when useful, tests for old+new, planned removal.
- Migrate internal/bundled callers to modern API in the same change. Do not let internal compat become permanent architecture.
- New seams: backward-compatible, documented, versioned. Third-party plugins exist.
- Channels are implementation under `src/channels/**`; plugin authors get SDK seams. Providers own auth/catalog/runtime hooks; core owns generic loop.
- Hot paths should carry prepared facts forward: provider id, model ref, channel id, target, capability family, attachment class. Do not rediscover with broad plugin/provider/channel/capability loaders.
- Do not fix repeated request-time discovery with scattered caches. Move the canonical fact earlier; reuse prepared runtime objects; delete duplicate lookup branches.
- Inline code comments: brief notes for tricky, bug-prone, or previously buggy logic.
- Gateway protocol changes: additive first; incompatible needs versioning/docs/client follow-through.
- Protocol version bumps: explicit owner confirmation only; never automatic/generated.
- Config contract: exported types, schema/help, metadata, baselines, docs aligned. Retired public keys stay retired; compat in raw migration/doctor only.
- Prompt cache: deterministic ordering for maps/sets/registries/plugin lists/files/network results before model/tool payloads. Preserve old transcript bytes when possible.
- Agent tool schema cleanup: remove stale args cleanly; no hidden compat for model-facing params just to avoid churn.
## Commands
- Runtime: Node 22.19+; Node 24 recommended. Keep Node + Bun paths working.
- Runtime: Node 22+. Keep Node + Bun paths working.
- Package manager/runtime: repo defaults only. No swaps without approval.
- Install: `pnpm install` (keep Bun lock/patches aligned if touched).
- Sharp/Homebrew libvips source-build fail: `SHARP_IGNORE_GLOBAL_LIBVIPS=1 pnpm install`.
- CLI: `pnpm openclaw ...` or `pnpm dev`; build: `pnpm build`.
- Tests in a normal source checkout: `pnpm test <path-or-filter> [vitest args...]`, `pnpm test:changed`, `pnpm test:serial`, `pnpm test:coverage`; never raw `vitest`.
- Tests in a Codex worktree or linked/sparse checkout: avoid direct local `pnpm test*`; use `node scripts/run-vitest.mjs <path-or-filter>` for tiny explicit-file proof, or Crabbox/Testbox for anything broader.
- Checks in a normal source checkout: `pnpm check:changed`; lanes: `pnpm changed:lanes --json`; staged: `pnpm check:changed --staged`; full: `pnpm check`.
- Checks in a Codex worktree or linked/sparse checkout: avoid direct local `pnpm check*`; use `node scripts/crabbox-wrapper.mjs run ... --shell -- "pnpm check:changed"` so pnpm runs inside Testbox, not locally.
- Tests: `pnpm test <path-or-filter> [vitest args...]`, `pnpm test:changed`, `pnpm test:serial`, `pnpm test:coverage`; never raw `vitest`.
- Checks: `pnpm check:changed`; lanes: `pnpm changed:lanes --json`; staged: `pnpm check:changed --staged`; full: `pnpm check`.
- Extension tests: `pnpm test:extensions`, `pnpm test extensions`, `pnpm test extensions/<id>`.
- Typecheck: `tsgo` lanes only (`pnpm tsgo*`, `pnpm check:test-types`); never add `tsc --noEmit`, `typecheck`, `check:types`.
- Formatting: `oxfmt`, not Prettier. Use repo wrappers (`pnpm format:*`, `pnpm lint:*`, `scripts/run-oxlint.mjs`).
@@ -69,17 +56,13 @@ Skills own workflows; root owns hard policy and routing.
## Validation
- Use `$openclaw-testing` for test/CI choice and `$crabbox` for remote/full/E2E proof.
- Crabbox request means real scenario proof: install/update/call/repro user path; not just copy tests and run them remotely.
- Small/narrow tests, lints, format checks, and type probes are fine locally only in a healthy normal checkout.
- In Codex worktrees, direct local `pnpm test*`, `pnpm check*`, `pnpm crabbox:run`, and `scripts/committer` can trigger pnpm dependency reconciliation or install prompts. Prefer `node` wrappers locally and Crabbox/Testbox for pnpm-gated proof.
- Small/narrow tests, lints, format checks, and type probes are fine locally.
- Full suites, broad changed gates, Docker/package/E2E/live/cross-OS proof, or anything that bogs down the Mac: Crabbox/Testbox.
- One/few files local. If a local command fans out, stop and move broad proof to Crabbox/Testbox.
- Before handoff/push: prove touched surface. Before landing to `main`: issue proof plus appropriate full/broad proof unless scope is clearly narrow.
- Pre-land/pre-commit code changes: use `$autoreview` until no accepted/actionable findings remain, unless equivalent manual review already done, trivial/docs-only, or user opts out.
- If proof is blocked, say exactly what is missing and why.
- Do not land related failing format/lint/type/build/tests. If unrelated on latest `origin/main`, say so with scoped proof.
- Docs/changelog-only and CI/workflow metadata-only: `git diff --check` plus relevant docs/workflow sanity; escalate only if scripts/config/generated/package/runtime behavior changed.
- Prompt snapshots: CI truth is Linux Node 24. If macOS local passes but CI drifts, reproduce/generate in Linux before rerun.
## GitHub / PRs
@@ -87,22 +70,18 @@ Skills own workflows; root owns hard policy and routing.
- PR refs: `gh pr view/diff` or `gh api`, not web search. Prefer `gitcrawl` for maintainer discovery; missing/stale `gitcrawl` falls through to live `gh`, not contributor setup. Verify live with `gh` before mutation.
- Bare issue/PR URL/number means review/report in chat. Suggest comment/close/merge when appropriate; mutate only when asked.
- No unsolicited PR comments/reviews/labels/retitles/rebases/fixups/landing. Exception: close/duplicate action that needs a reason comment after explicit close/sweep/landing request.
- Maintainer decision closes the cluster: if deciding reported behavior/proposed fix is not planned, comment+close all directly associated open issues/PRs unless explicitly told to keep one open. Associated means linked PRs/issues, duplicates, companion workaround PRs, and the canonical issue for the rejected behavior.
- Do not leave associated issues open for hypothetical future repros. Close with rationale; ask for a new issue or reopen only if concrete new evidence appears. Close comment states: decision, why, supported alternative, and what evidence would change the decision.
- PR review answer: bug/behavior, URL(s), affected surface, provenance for regressions when traceable, best-fix judgment, evidence from code/tests/CI/current or shipped behavior.
- PR review answer: bug/behavior, URL(s), affected surface, best-fix judgment, evidence from code/tests/CI/current or shipped behavior.
- Issue/PR final answer: last line is the full GitHub URL.
- Changelog: PR landings/fixes need one unless pure test/internal. Do not mention missing changelog as a review finding; Codex handles it during fix/landing.
- PR verification: before merge, post exact local commands, CI/Testbox run IDs, before/after proof when used, and known proof gaps.
- Issue fixed on `main` with proof: comment proof + commit/PR, then close.
- After landing or requested close/sweep: search duplicates; comment proof + canonical commit/PR/release before closing.
- After landing/ship final: include 2-5 sentence recap of what landed: behavior change, key files/surface, proof run, issue/PR state. Do not answer with only status/links.
- `ship` that fixes an issue: after push, comment proof + commit link, then close the issue.
- GH comments with backticks, `$`, or shell snippets: use heredoc/body file, not inline double-quoted `--body`.
- PR create: real body required. Include Summary + Verification; mention refs, behavior, and proof.
- Real behavior proof section is parsed. Use exact `field: value` labels: `Behavior addressed`, `Real environment tested`, `Exact steps or command run after this patch`, `Evidence after fix`, `Observed result after fix`, `What was not tested`.
- PR artifacts/screenshots: attach to PR/comment/external artifact store. Do not commit `.github/pr-assets`.
- CI polling: exact SHA, relevant checks only, minimal fields. Skip routine noise (`Auto response`, `Labeler`, docs agents, performance/stale). Logs only after failure/completion or concrete need.
- Maintainers: may skip/ignore `Real behavior proof` when local tests or Crabbox verified behavior; record proof in PR verification.
- Maintainers: ignore `Real behavior proof` failures that only say PR body lacks real after-fix evidence.
- `/landpr`: use `~/.codex/prompts/landpr.md`; do not idle on `auto-response` or `check-docs`.
## Code
@@ -111,10 +90,6 @@ Skills own workflows; root owns hard policy and routing.
- No `@ts-nocheck`. Lint suppressions only intentional + explained.
- External boundaries: prefer `zod` or existing schema helpers.
- Runtime branching: discriminated unions/closed codes over freeform strings. Avoid semantic sentinels (`?? 0`, empty object/string).
- Formatter-friendly shape: when oxfmt explodes an expression vertically, extract named booleans, payloads, or small helpers. Do not change width or use format-ignore for local compactness.
- Calls should be boring: complex decisions happen above; call args/object fields are names, literals, or simple property reads.
- Prefer early returns over nested condition pyramids. Split code into gather -> normalize -> decide -> act.
- Use named intermediates only for domain meaning or readability; avoid temp-variable soup.
- Dynamic import: no static+dynamic import for same prod module. Use `*.runtime.ts` lazy boundary. After edits: `pnpm build`; check `[INEFFECTIVE_DYNAMIC_IMPORT]`.
- Cycles: keep `pnpm check:import-cycles` + architecture/madge green.
- Classes: no prototype mixins/mutations. Prefer inheritance/composition. Tests prefer per-instance stubs.
@@ -159,6 +134,7 @@ Skills own workflows; root owns hard policy and routing.
- Never commit real phone numbers, videos, credentials, live config.
- Secrets: channel/provider creds in `~/.openclaw/credentials/`; model auth profiles in `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`.
- Env keys: check `~/.profile`; redact output.
- Dependency patches/overrides/vendor changes need explicit approval. `pnpm-workspace.yaml` patched dependencies use exact versions only.
- Carbon pins owner-only: do not change `@buape/carbon` unless Shadow (`@thewilloftheshadow`, verified by `gh`) asks.
- Releases/publish/version bumps need explicit approval. Use `$openclaw-release-maintainer`.

File diff suppressed because it is too large Load Diff

View File

@@ -198,29 +198,13 @@ RUN install -d -m 0755 "$COREPACK_HOME" && \
chmod -R a+rX "$COREPACK_HOME"
# Install additional system packages needed by your skills or extensions.
# Example: docker build --build-arg OPENCLAW_IMAGE_APT_PACKAGES="python3 wget" .
# Legacy alias: OPENCLAW_DOCKER_APT_PACKAGES is still accepted as a fallback.
ARG OPENCLAW_IMAGE_APT_PACKAGES
# Example: docker build --build-arg OPENCLAW_DOCKER_APT_PACKAGES="python3 wget" .
ARG OPENCLAW_DOCKER_APT_PACKAGES=""
RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
packages="${OPENCLAW_IMAGE_APT_PACKAGES-$OPENCLAW_DOCKER_APT_PACKAGES}"; \
if [ -n "$packages" ]; then \
if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \
apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $packages; \
fi
# Install additional Python packages needed by your plugins or skills.
# Example: docker build --build-arg OPENCLAW_IMAGE_PIP_PACKAGES="requests humanize" .
ARG OPENCLAW_IMAGE_PIP_PACKAGES=""
RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
if [ -n "$OPENCLAW_IMAGE_PIP_PACKAGES" ]; then \
if ! python3 -m pip --version >/dev/null 2>&1; then \
apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends python3-pip; \
fi && \
python3 -m pip install --no-cache-dir --break-system-packages $OPENCLAW_IMAGE_PIP_PACKAGES; \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $OPENCLAW_DOCKER_APT_PACKAGES; \
fi
# Optionally install Chromium and Xvfb for browser automation.
@@ -309,4 +293,4 @@ USER node
HEALTHCHECK --interval=3m --timeout=10s --start-period=15s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:18789/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
ENTRYPOINT ["tini", "-s", "--"]
CMD ["node", "openclaw.mjs", "gateway"]
CMD ["node", "openclaw.mjs", "gateway", "--allow-unconfigured"]

View File

@@ -96,7 +96,7 @@ Model note: while many providers and models are supported, prefer a current flag
## Install (recommended)
Runtime: **Node 24 (recommended) or Node 22.19+**.
Runtime: **Node 24 (recommended) or Node 22.16+**.
```bash
npm install -g openclaw@latest
@@ -109,7 +109,7 @@ OpenClaw Onboard installs the Gateway daemon (launchd/systemd user service) so i
## Quick start (TL;DR)
Runtime: **Node 24 (recommended) or Node 22.19+**.
Runtime: **Node 24 (recommended) or Node 22.16+**.
Full beginner guide (auth, pairing, channels): [Getting started](https://docs.openclaw.ai/start/getting-started)

View File

@@ -312,7 +312,7 @@ OpenClaw's web interface (Gateway Control UI + HTTP endpoints) is intended for *
### Node.js Version
OpenClaw requires **Node.js 22.19.0 or later** (LTS). Node 24 is the recommended default runtime for new installs. The minimum version includes important security patches:
OpenClaw requires **Node.js 22.16.0 or later** (LTS). This version includes important security patches:
- CVE-2025-59466: async_hooks DoS vulnerability
- CVE-2026-21636: Permission model bypass vulnerability
@@ -320,7 +320,7 @@ OpenClaw requires **Node.js 22.19.0 or later** (LTS). Node 24 is the recommended
Verify your Node.js version:
```bash
node --version # Should be v22.19.0 or later
node --version # Should be v22.16.0 or later
```
### Docker Security

File diff suppressed because it is too large Load Diff

View File

@@ -65,8 +65,8 @@ android {
applicationId = "ai.openclaw.app"
minSdk = 31
targetSdk = 36
versionCode = 2026051900
versionName = "2026.5.19"
versionCode = 2026051200
versionName = "2026.5.12"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")

View File

@@ -118,8 +118,6 @@ class MainViewModel(
val talkModeListening: StateFlow<Boolean> = runtimeState(initial = false) { it.talkModeListening }
val talkModeSpeaking: StateFlow<Boolean> = runtimeState(initial = false) { it.talkModeSpeaking }
val talkModeStatusText: StateFlow<String> = runtimeState(initial = "Off") { it.talkModeStatusText }
val talkModeConversation: StateFlow<List<VoiceConversationEntry>> =
runtimeState(initial = emptyList()) { it.talkModeConversation }
val chatSessionKey: StateFlow<String> = runtimeState(initial = "main") { it.chatSessionKey }
val chatSessionId: StateFlow<String?> = runtimeState(initial = null) { it.chatSessionId }

View File

@@ -12,7 +12,6 @@ import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.gateway.GatewayTlsProbeFailure
import ai.openclaw.app.gateway.GatewayTlsProbeResult
import ai.openclaw.app.gateway.normalizeGatewayTlsFingerprint
import ai.openclaw.app.gateway.probeGatewayTlsFingerprint
import ai.openclaw.app.node.A2UIHandler
import ai.openclaw.app.node.CalendarHandler
@@ -50,7 +49,6 @@ import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.SystemClock
import android.util.Base64
import android.util.Log
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CoroutineScope
@@ -250,7 +248,6 @@ class NodeRuntime(
val endpoint: GatewayEndpoint,
val fingerprintSha256: String,
val auth: GatewayConnectAuth,
val previousFingerprintSha256: String? = null,
)
private val _isConnected = MutableStateFlow(false)
@@ -263,7 +260,6 @@ class NodeRuntime(
private val _pendingGatewayTrust = MutableStateFlow<GatewayTrustPrompt?>(null)
val pendingGatewayTrust: StateFlow<GatewayTrustPrompt?> = _pendingGatewayTrust.asStateFlow()
private val connectAttemptSeq = AtomicLong(0)
private fun resolveNodeMainSessionKey(agentId: String? = gatewayDefaultAgentId): String {
val deviceId = identityStore.loadOrCreate().deviceId
@@ -426,42 +422,6 @@ class NodeRuntime(
MicCaptureManager(
context = appContext,
scope = scope,
createTranscriptionSession = {
val params =
buildJsonObject {
put("mode", JsonPrimitive("transcription"))
put("transport", JsonPrimitive("gateway-relay"))
put("brain", JsonPrimitive("none"))
}
val response =
operatorSession.request(
"talk.session.create",
params.toString(),
timeoutMs = 15_000,
)
parseTalkSessionId(response)
},
appendTranscriptionAudio = { sessionId, audio, onError ->
val params =
buildJsonObject {
put("sessionId", JsonPrimitive(sessionId))
put("audioBase64", JsonPrimitive(Base64.encodeToString(audio, Base64.NO_WRAP)))
put("timestamp", JsonPrimitive(SystemClock.elapsedRealtime()))
}
operatorSession.sendRequestFrame(
"talk.session.appendAudio",
params.toString(),
timeoutMs = 8_000,
) { error -> onError(error.message) }
},
closeTranscriptionSession = { sessionId ->
val params = buildJsonObject { put("sessionId", JsonPrimitive(sessionId)) }
operatorSession.request(
"talk.session.close",
params.toString(),
timeoutMs = 5_000,
)
},
sendToGateway = { message, onRunIdKnown ->
val idempotencyKey = UUID.randomUUID().toString()
// Notify MicCaptureManager of the idempotency key *before* the network
@@ -523,7 +483,6 @@ class NodeRuntime(
isConnected = { operatorConnected },
onBeforeSpeak = { micCapture.pauseForTts() },
onAfterSpeak = { micCapture.resumeAfterTts() },
onStoppedByRelay = { finishTalkModeAfterRelayClose() },
)
}
@@ -539,9 +498,6 @@ class NodeRuntime(
val talkModeStatusText: StateFlow<String>
get() = talkMode.statusText
val talkModeConversation: StateFlow<List<VoiceConversationEntry>>
get() = talkMode.conversation
private fun syncMainSessionKey(agentId: String?) {
val resolvedKey = resolveNodeMainSessionKey(agentId)
// Always push the resolved session key into TalkMode, even when the
@@ -1010,14 +966,6 @@ class NodeRuntime(
}
}
private fun finishTalkModeAfterRelayClose() {
if (_voiceCaptureMode.value != VoiceCaptureMode.TalkMode) return
_voiceCaptureMode.value = VoiceCaptureMode.Off
talkMode.ttsOnAllResponses = false
NodeForegroundService.setVoiceCaptureMode(appContext, VoiceCaptureMode.Off)
externalAudioCaptureActive.value = false
}
val speakerEnabled: StateFlow<Boolean>
get() = prefs.speakerEnabled
@@ -1164,58 +1112,23 @@ class NodeRuntime(
endpoint: GatewayEndpoint,
auth: GatewayConnectAuth,
) {
val connectAttemptId = connectAttemptSeq.incrementAndGet()
_pendingGatewayTrust.value = null
val tls = connectionManager.resolveTlsParams(endpoint)
if (tls?.required == true) {
val expectedFingerprint =
tls.expectedFingerprint
?.let(::normalizeGatewayTlsFingerprint)
?.takeIf { it.isNotBlank() }
if (tls?.required == true && tls.expectedFingerprint.isNullOrBlank()) {
// First-time TLS: capture fingerprint, ask user to verify out-of-band, then store and connect.
_statusText.value = "Verify gateway TLS fingerprint…"
scope.launch {
val tlsProbe = tlsFingerprintProbe(endpoint.host, endpoint.port)
if (!isCurrentConnectAttempt(connectAttemptId)) return@launch
val fp =
tlsProbe.fingerprintSha256 ?: run {
if (expectedFingerprint == null) {
_statusText.value = gatewayTlsProbeFailureMessage(tlsProbe.failure)
} else {
connectAfterTlsCheck(endpoint = endpoint, auth = auth, connectAttemptId = connectAttemptId)
}
_statusText.value = gatewayTlsProbeFailureMessage(tlsProbe.failure)
return@launch
}
val observedFingerprint =
normalizeGatewayTlsFingerprint(fp)
.takeIf { it.isNotBlank() }
?: fp
val previousFingerprint = expectedFingerprint?.takeUnless { it == observedFingerprint }
if (expectedFingerprint == null || previousFingerprint != null) {
_pendingGatewayTrust.value =
GatewayTrustPrompt(
endpoint = endpoint,
fingerprintSha256 = observedFingerprint,
auth = auth,
previousFingerprintSha256 = previousFingerprint,
)
return@launch
}
connectAfterTlsCheck(endpoint = endpoint, auth = auth, connectAttemptId = connectAttemptId)
_pendingGatewayTrust.value =
GatewayTrustPrompt(endpoint = endpoint, fingerprintSha256 = fp, auth = auth)
}
return
}
connectAfterTlsCheck(endpoint = endpoint, auth = auth, connectAttemptId = connectAttemptId)
}
private fun isCurrentConnectAttempt(connectAttemptId: Long): Boolean = connectAttemptSeq.get() == connectAttemptId
private fun connectAfterTlsCheck(
endpoint: GatewayEndpoint,
auth: GatewayConnectAuth,
connectAttemptId: Long,
) {
if (!isCurrentConnectAttempt(connectAttemptId)) return
connectedEndpoint = endpoint
operatorStatusText = "Connecting…"
nodeStatusText = "Connecting…"
@@ -1308,7 +1221,6 @@ class NodeRuntime(
}
fun disconnect() {
connectAttemptSeq.incrementAndGet()
stopActiveVoiceSession()
connectedEndpoint = null
activeGatewayAuth = null
@@ -1459,17 +1371,6 @@ class NodeRuntime(
}
}
private fun parseTalkSessionId(response: String): String {
val root = json.parseToJsonElement(response).asObjectOrNull()
val sessionId =
root?.get("transcriptionSessionId").asStringOrNull()
?: root?.get("sessionId").asStringOrNull()
if (sessionId.isNullOrBlank()) {
throw IllegalStateException("talk.session.create returned no session id")
}
return sessionId
}
private suspend fun refreshBrandingFromGateway() {
if (!_isConnected.value) return
try {
@@ -1713,14 +1614,14 @@ internal fun resolveOperatorSessionConnectAuth(
val explicitBootstrapToken = auth.bootstrapToken?.trim()?.takeIf { it.isNotEmpty() }
if (explicitBootstrapToken != null) {
return null
return NodeRuntime.GatewayConnectAuth(
token = null,
bootstrapToken = explicitBootstrapToken,
password = null,
)
}
return NodeRuntime.GatewayConnectAuth(
token = null,
bootstrapToken = null,
password = null,
)
return null
}
internal fun shouldConnectOperatorSession(

View File

@@ -17,148 +17,80 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.resume
class PermissionRequester internal constructor(
class PermissionRequester(
private val activity: ComponentActivity,
launcherFactory: ((Map<String, Boolean>) -> Unit) -> ActivityResultLauncher<Array<String>>,
) {
private data class PendingPermissionRequest(
val deferred: CompletableDeferred<Map<String, Boolean>>,
var timedOut: Boolean = false,
)
private class PermissionRequestSlot(
val launcher: ActivityResultLauncher<Array<String>>,
var request: PendingPermissionRequest? = null,
)
constructor(activity: ComponentActivity) : this(
activity = activity,
launcherFactory = { callback ->
activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions(), callback)
},
)
private val mutex = Mutex()
private val requestSlotsLock = Any()
private var pending: CompletableDeferred<Map<String, Boolean>>? = null
private val mainHandler = Handler(Looper.getMainLooper())
private val launchers = List(4) { createPermissionRequestSlot(launcherFactory) }
private val launcher: ActivityResultLauncher<Array<String>> =
activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
val p = pending
pending = null
p?.complete(result)
}
suspend fun requestIfMissing(
permissions: List<String>,
timeoutMs: Long = 20_000,
): Map<String, Boolean> {
return mutex.withLock {
while (true) {
val missing =
permissions.filter { perm ->
ContextCompat.checkSelfPermission(activity, perm) != PackageManager.PERMISSION_GRANTED
}
if (missing.isEmpty()) {
return permissions.associateWith { true }
): Map<String, Boolean> =
mutex.withLock {
val missing =
permissions.filter { perm ->
ContextCompat.checkSelfPermission(activity, perm) != PackageManager.PERMISSION_GRANTED
}
val needsRationale =
missing.any { ActivityCompat.shouldShowRequestPermissionRationale(activity, it) }
if (needsRationale) {
val proceed = showRationaleDialog(missing)
if (!proceed) {
return permissions.associateWith { perm ->
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
}
}
}
val deferred = CompletableDeferred<Map<String, Boolean>>()
val request = PendingPermissionRequest(deferred)
val slot = reservePermissionRequestSlot(request)
try {
withContext(Dispatchers.Main) {
slot.launcher.launch(missing.toTypedArray())
}
} catch (err: Throwable) {
clearPermissionRequestSlot(slot, request)
throw err
}
val result =
try {
withTimeout(timeoutMs) { deferred.await() }
} catch (err: TimeoutCancellationException) {
request.timedOut = true
throw err
}
val merged =
permissions.associateWith { perm ->
val nowGranted =
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
result[perm] == true || nowGranted
}
val denied =
merged.filterValues { !it }.keys.filter {
!ActivityCompat.shouldShowRequestPermissionRationale(activity, it)
}
if (denied.isNotEmpty()) {
showSettingsDialog(denied)
}
return merged
if (missing.isEmpty()) {
return permissions.associateWith { true }
}
error("unreachable")
}
}
private fun createPermissionRequestSlot(
launcherFactory: ((Map<String, Boolean>) -> Unit) -> ActivityResultLauncher<Array<String>>,
): PermissionRequestSlot {
var slot: PermissionRequestSlot? = null
val launcher = launcherFactory { result -> completePermissionRequest(checkNotNull(slot), result) }
val created = PermissionRequestSlot(launcher)
slot = created
return created
}
private fun reservePermissionRequestSlot(request: PendingPermissionRequest): PermissionRequestSlot =
synchronized(requestSlotsLock) {
val slot = launchers.firstOrNull { it.request == null } ?: error("permission request launcher busy")
slot.request = request
slot
}
private fun completePermissionRequest(
slot: PermissionRequestSlot,
result: Map<String, Boolean>,
) {
val request =
synchronized(requestSlotsLock) {
slot.request.also {
slot.request = null
val needsRationale =
missing.any { ActivityCompat.shouldShowRequestPermissionRationale(activity, it) }
if (needsRationale) {
val proceed = showRationaleDialog(missing)
if (!proceed) {
return permissions.associateWith { perm ->
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
}
}
} ?: return
if (request.timedOut) return
request.deferred.complete(result)
}
private fun clearPermissionRequestSlot(
slot: PermissionRequestSlot,
request: PendingPermissionRequest,
) {
synchronized(requestSlotsLock) {
if (slot.request === request) {
slot.request = null
}
val deferred = CompletableDeferred<Map<String, Boolean>>()
pending = deferred
withContext(Dispatchers.Main) {
launcher.launch(missing.toTypedArray())
}
val result =
withContext(Dispatchers.Default) {
kotlinx.coroutines.withTimeout(timeoutMs) { deferred.await() }
}
// Merge: if something was already granted, treat it as granted even if launcher omitted it.
val merged =
permissions.associateWith { perm ->
val nowGranted =
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
result[perm] == true || nowGranted
}
val denied =
merged.filterValues { !it }.keys.filter {
!ActivityCompat.shouldShowRequestPermissionRationale(activity, it)
}
if (denied.isNotEmpty()) {
showSettingsDialog(denied)
}
return merged
}
}
private suspend fun showRationaleDialog(permissions: List<String>): Boolean =
withContext(Dispatchers.Main) {

View File

@@ -1,4 +1,4 @@
package ai.openclaw.app.gateway
const val GATEWAY_PROTOCOL_VERSION = 4
const val GATEWAY_MIN_PROTOCOL_VERSION = 4
const val GATEWAY_MIN_PROTOCOL_VERSION = 3

View File

@@ -64,7 +64,6 @@ data class GatewayConnectErrorDetails(
val code: String?,
val canRetryWithDeviceToken: Boolean,
val recommendedNextStep: String?,
val pauseReconnect: Boolean? = null,
val reason: String? = null,
)
@@ -149,10 +148,7 @@ class GatewaySession(
val tls: GatewayTlsParams?,
)
private val lifecycleLock = Any()
@Volatile private var desired: DesiredConnection? = null
private var desired: DesiredConnection? = null
private var job: Job? = null
@Volatile private var currentConnection: Connection? = null
@@ -171,39 +167,26 @@ class GatewaySession(
options: GatewayConnectOptions,
tls: GatewayTlsParams? = null,
) {
val connectionToClose: Connection?
synchronized(lifecycleLock) {
desired = DesiredConnection(endpoint, token, bootstrapToken, password, options, tls)
pendingDeviceTokenRetry = false
deviceTokenRetryBudgetUsed = false
reconnectPausedForAuthFailure = false
connectionToClose = currentConnection
if (job?.isActive != true) {
job = scope.launch(Dispatchers.IO) { runLoop() }
}
desired = DesiredConnection(endpoint, token, bootstrapToken, password, options, tls)
pendingDeviceTokenRetry = false
deviceTokenRetryBudgetUsed = false
reconnectPausedForAuthFailure = false
if (job == null) {
job = scope.launch(Dispatchers.IO) { runLoop() }
}
connectionToClose?.closeQuietly()
}
fun disconnect() {
val jobToCancel: Job?
val connectionToClose: Connection?
synchronized(lifecycleLock) {
desired = null
pendingDeviceTokenRetry = false
deviceTokenRetryBudgetUsed = false
reconnectPausedForAuthFailure = false
connectionToClose = currentConnection
jobToCancel = job
job = null
}
connectionToClose?.closeQuietly()
desired = null
pendingDeviceTokenRetry = false
deviceTokenRetryBudgetUsed = false
reconnectPausedForAuthFailure = false
currentConnection?.closeQuietly()
scope.launch(Dispatchers.IO) {
jobToCancel?.cancelAndJoin()
if (desired == null) {
pluginSurfaceUrls = emptyMap()
mainSessionKey = null
}
job?.cancelAndJoin()
job = null
pluginSurfaceUrls = emptyMap()
mainSessionKey = null
onDisconnected("Offline")
}
}
@@ -332,22 +315,6 @@ class GatewaySession(
return RpcResult(ok = res.ok, payloadJson = res.payloadJson, error = res.error)
}
suspend fun sendRequestFrame(
method: String,
paramsJson: String?,
timeoutMs: Long = 15_000,
onError: (ErrorShape) -> Unit = {},
) {
val conn = currentConnection ?: throw IllegalStateException("not connected")
val params =
if (paramsJson.isNullOrBlank()) {
null
} else {
json.parseToJsonElement(paramsJson)
}
conn.sendRequestFrame(method = method, params = params, timeoutMs = timeoutMs, onError = onError)
}
private data class RpcResponse(
val id: String,
val ok: Boolean,
@@ -392,12 +359,14 @@ class GatewaySession(
val id = UUID.randomUUID().toString()
val deferred = CompletableDeferred<RpcResponse>()
pending[id] = deferred
try {
sendJson(buildRequestFrame(id = id, method = method, params = params))
} catch (err: Throwable) {
pending.remove(id)
throw err
}
val frame =
buildJsonObject {
put("type", JsonPrimitive("req"))
put("id", JsonPrimitive(id))
put("method", JsonPrimitive(method))
if (params != null) put("params", params)
}
sendJson(frame)
return try {
withTimeout(timeoutMs) { deferred.await() }
} catch (err: TimeoutCancellationException) {
@@ -406,57 +375,13 @@ class GatewaySession(
}
}
suspend fun sendRequestFrame(
method: String,
params: JsonElement?,
timeoutMs: Long,
onError: (ErrorShape) -> Unit,
) {
val id = UUID.randomUUID().toString()
val deferred = CompletableDeferred<RpcResponse>()
pending[id] = deferred
try {
sendJson(buildRequestFrame(id = id, method = method, params = params))
} catch (err: Throwable) {
pending.remove(id)
throw err
}
scope.launch(Dispatchers.IO) {
val response =
try {
withTimeout(timeoutMs) { deferred.await() }
} catch (_: TimeoutCancellationException) {
pending.remove(id)
onError(ErrorShape("UNAVAILABLE", "request timeout"))
return@launch
}
if (!response.ok) {
onError(response.error ?: ErrorShape("UNAVAILABLE", "request failed"))
}
}
}
suspend fun sendJson(obj: JsonObject) {
val jsonString = obj.toString()
writeLock.withLock {
if (socket?.send(jsonString) != true) {
throw IllegalStateException("gateway send failed")
}
socket?.send(jsonString)
}
}
private fun buildRequestFrame(
id: String,
method: String,
params: JsonElement?,
): JsonObject =
buildJsonObject {
put("type", JsonPrimitive("req"))
put("id", JsonPrimitive(id))
put("method", JsonPrimitive(method))
if (params != null) put("params", params)
}
suspend fun awaitClose() = closedDeferred.await()
fun closeQuietly() {
@@ -811,7 +736,6 @@ class GatewaySession(
code = it["code"].asStringOrNull(),
canRetryWithDeviceToken = it["canRetryWithDeviceToken"].asBooleanOrNull() == true,
recommendedNextStep = it["recommendedNextStep"].asStringOrNull(),
pauseReconnect = it["pauseReconnect"].asBooleanOrNull(),
reason = it["reason"].asStringOrNull(),
)
}
@@ -979,11 +903,9 @@ class GatewaySession(
conn.connect()
conn.awaitClose()
} finally {
if (currentConnection === conn) {
currentConnection = null
pluginSurfaceUrls = emptyMap()
mainSessionKey = null
}
currentConnection = null
pluginSurfaceUrls = emptyMap()
mainSessionKey = null
}
}
@@ -1118,17 +1040,20 @@ class GatewaySession(
detailCode == "AUTH_TOKEN_MISMATCH"
}
private fun shouldPauseReconnectAfterAuthFailure(error: ErrorShape): Boolean {
val target = desired
return shouldPauseGatewayReconnectAfterAuthFailure(
error = error,
hasBootstrapToken = target?.bootstrapToken?.trim()?.isNotEmpty() == true,
role = target?.options?.role,
scopes = target?.options?.scopes ?: emptyList(),
deviceTokenRetryBudgetUsed = deviceTokenRetryBudgetUsed,
pendingDeviceTokenRetry = pendingDeviceTokenRetry,
)
}
private fun shouldPauseReconnectAfterAuthFailure(error: ErrorShape): Boolean =
when (error.details?.code) {
"AUTH_TOKEN_MISSING",
"AUTH_BOOTSTRAP_TOKEN_INVALID",
"AUTH_PASSWORD_MISSING",
"AUTH_PASSWORD_MISMATCH",
"AUTH_RATE_LIMITED",
"PAIRING_REQUIRED",
"CONTROL_UI_DEVICE_IDENTITY_REQUIRED",
"DEVICE_IDENTITY_REQUIRED",
-> true
"AUTH_TOKEN_MISMATCH" -> deviceTokenRetryBudgetUsed && !pendingDeviceTokenRetry
else -> false
}
private fun shouldClearStoredDeviceTokenAfterRetry(error: ErrorShape): Boolean = error.details?.code == "AUTH_DEVICE_TOKEN_MISMATCH"
@@ -1143,38 +1068,6 @@ class GatewaySession(
}
}
internal fun shouldPauseGatewayReconnectAfterAuthFailure(
error: GatewaySession.ErrorShape,
hasBootstrapToken: Boolean,
role: String?,
scopes: List<String>,
deviceTokenRetryBudgetUsed: Boolean,
pendingDeviceTokenRetry: Boolean,
): Boolean =
when (error.details?.code) {
"AUTH_TOKEN_MISSING",
"AUTH_BOOTSTRAP_TOKEN_INVALID",
"AUTH_PASSWORD_MISSING",
"AUTH_PASSWORD_MISMATCH",
"AUTH_RATE_LIMITED",
"CONTROL_UI_DEVICE_IDENTITY_REQUIRED",
"DEVICE_IDENTITY_REQUIRED",
-> true
"PAIRING_REQUIRED" ->
!(
hasBootstrapToken &&
role?.trim() == "node" &&
scopes.isEmpty() &&
error.details.reason == "not-paired" &&
(
error.details.pauseReconnect == false ||
error.details.recommendedNextStep == "wait_then_retry"
)
)
"AUTH_TOKEN_MISMATCH" -> deviceTokenRetryBudgetUsed && !pendingDeviceTokenRetry
else -> false
}
internal fun buildGatewayWebSocketUrl(
host: String,
port: Int,

View File

@@ -53,10 +53,7 @@ fun buildGatewayTlsConfig(
onStore: ((String) -> Unit)? = null,
): GatewayTlsConfig? {
if (params == null) return null
val expected =
params.expectedFingerprint
?.let(::normalizeGatewayTlsFingerprint)
?.takeIf { it.isNotBlank() }
val expected = params.expectedFingerprint?.let(::normalizeFingerprint)
val defaultTrust = defaultTrustManager()
@SuppressLint("CustomX509TrustManager")
@@ -203,7 +200,7 @@ private fun sha256Hex(data: ByteArray): String {
return out.toString()
}
fun normalizeGatewayTlsFingerprint(raw: String): String {
private fun normalizeFingerprint(raw: String): String {
val stripped =
raw
.trim()

View File

@@ -27,23 +27,28 @@ internal object JpegSizeLimiter {
require(initialWidth > 0 && initialHeight > 0) { "Invalid image size" }
require(maxBytes > 0) { "Invalid maxBytes" }
val clampedStartQuality = startQuality.coerceIn(minQuality, 100)
var width = initialWidth
var height = initialHeight
var best: JpegSizeLimiterResult? = null
val clampedStartQuality = startQuality.coerceIn(minQuality, 100)
var best =
JpegSizeLimiterResult(
bytes = encode(width, height, clampedStartQuality),
width = width,
height = height,
quality = clampedStartQuality,
)
if (best.bytes.size <= maxBytes) return best
repeat(maxScaleAttempts + 1) { scaleAttempt ->
repeat(maxScaleAttempts) {
var quality = clampedStartQuality
repeat(maxQualityAttempts) {
val bytes = encode(width, height, quality)
val attempt = JpegSizeLimiterResult(bytes = bytes, width = width, height = height, quality = quality)
best = attempt
best = JpegSizeLimiterResult(bytes = bytes, width = width, height = height, quality = quality)
if (bytes.size <= maxBytes) return best
if (quality <= minQuality) return@repeat
quality = max(minQuality, (quality * 0.75).roundToInt())
}
if (scaleAttempt == maxScaleAttempts) return@repeat
val minScale = (minSize.toDouble() / min(width, height).toDouble()).coerceAtMost(1.0)
val nextScale = max(scaleStep, minScale)
val nextWidth = max(minSize, (width * nextScale).roundToInt())
@@ -53,11 +58,10 @@ internal object JpegSizeLimiter {
height = min(nextHeight, height)
}
val failed = checkNotNull(best)
if (failed.bytes.size > maxBytes) {
throw IllegalStateException("CAMERA_TOO_LARGE: ${failed.bytes.size} bytes > $maxBytes bytes")
if (best.bytes.size > maxBytes) {
throw IllegalStateException("CAMERA_TOO_LARGE: ${best.bytes.size} bytes > $maxBytes bytes")
}
return failed
return best
}
}

View File

@@ -100,14 +100,8 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
containerColor = mobileCardSurface,
title = { Text("Trust this gateway?", style = mobileHeadline, color = mobileText) },
text = {
val message =
if (prompt.previousFingerprintSha256.isNullOrBlank()) {
"First-time TLS connection.\n\nVerify this SHA-256 fingerprint before trusting:\n${prompt.fingerprintSha256}"
} else {
"The gateway TLS certificate changed. Only continue if you expected this.\n\nOld SHA-256 fingerprint:\n${prompt.previousFingerprintSha256}\n\nNew SHA-256 fingerprint:\n${prompt.fingerprintSha256}"
}
Text(
message,
"First-time TLS connection.\n\nVerify this SHA-256 fingerprint before trusting:\n${prompt.fingerprintSha256}",
style = mobileCallout,
color = mobileText,
)

View File

@@ -143,15 +143,27 @@ internal fun parseGatewayEndpointResult(rawInput: String): GatewayEndpointParseR
?.trim()
?.lowercase(Locale.US)
.orEmpty()
if (scheme !in setOf("ws", "wss", "http", "https")) {
return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL)
}
val tls = scheme == "wss" || scheme == "https"
val tls =
when (scheme) {
"ws", "http" -> false
"wss", "https" -> true
else -> true
}
if (!tls && !isLoopbackGatewayHost(host)) {
return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INSECURE_REMOTE_URL)
}
val defaultPort = if (tls) 443 else 18789
val displayPort = if (tls) 443 else 80
val defaultPort =
when (scheme) {
"wss", "https" -> 443
"ws", "http" -> 18789
else -> 443
}
val displayPort =
when (scheme) {
"wss", "https" -> 443
"ws", "http" -> 80
else -> 443
}
val port = uri.port.takeIf { it in 1..65535 } ?: defaultPort
val displayHost = if (host.contains(":")) "[$host]" else host
val displayUrl =

View File

@@ -497,14 +497,8 @@ fun OnboardingFlow(
containerColor = onboardingSurface,
title = { Text("Trust this gateway?", style = onboardingHeadlineStyle, color = onboardingText) },
text = {
val message =
if (prompt.previousFingerprintSha256.isNullOrBlank()) {
"First-time TLS connection.\n\nVerify this SHA-256 fingerprint before trusting:\n${prompt.fingerprintSha256}"
} else {
"The gateway TLS certificate changed. Only continue if you expected this.\n\nOld SHA-256 fingerprint:\n${prompt.previousFingerprintSha256}\n\nNew SHA-256 fingerprint:\n${prompt.fingerprintSha256}"
}
Text(
message,
"First-time TLS connection.\n\nVerify this SHA-256 fingerprint before trusting:\n${prompt.fingerprintSha256}",
style = onboardingCalloutStyle,
color = onboardingText,
)

View File

@@ -96,10 +96,8 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
val talkModeEnabled by viewModel.talkModeEnabled.collectAsState()
val talkModeListening by viewModel.talkModeListening.collectAsState()
val talkModeSpeaking by viewModel.talkModeSpeaking.collectAsState()
val talkModeConversation by viewModel.talkModeConversation.collectAsState()
val activeConversation = if (voiceCaptureMode == VoiceCaptureMode.TalkMode) talkModeConversation else micConversation
val hasStreamingAssistant = activeConversation.any { it.role == VoiceConversationRole.Assistant && it.isStreaming }
val hasStreamingAssistant = micConversation.any { it.role == VoiceConversationRole.Assistant && it.isStreaming }
val showThinkingBubble = micIsSending && !hasStreamingAssistant
var hasMicPermission by remember { mutableStateOf(context.hasRecordAudioPermission()) }
@@ -133,8 +131,8 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
pendingVoicePermissionAction = null
}
LaunchedEffect(voiceCaptureMode, activeConversation.size, showThinkingBubble) {
val total = activeConversation.size + if (showThinkingBubble) 1 else 0
LaunchedEffect(micConversation.size, showThinkingBubble) {
val total = micConversation.size + if (showThinkingBubble) 1 else 0
if (total > 0) {
listState.animateScrollToItem(total - 1)
}
@@ -156,7 +154,7 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
contentPadding = PaddingValues(vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
if (activeConversation.isEmpty() && !showThinkingBubble) {
if (micConversation.isEmpty() && !showThinkingBubble) {
item {
Box(
modifier = Modifier.fillParentMaxHeight().fillMaxWidth(),
@@ -187,7 +185,7 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
}
}
items(items = activeConversation, key = { it.id }) { entry ->
items(items = micConversation, key = { it.id }) { entry ->
VoiceTurnBubble(entry = entry)
}
@@ -349,8 +347,10 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
voiceCaptureMode == VoiceCaptureMode.TalkMode && talkModeSpeaking -> "Talk speaking"
voiceCaptureMode == VoiceCaptureMode.TalkMode && talkModeListening -> "Talk listening"
voiceCaptureMode == VoiceCaptureMode.TalkMode -> "Talk on"
micEnabled || micIsSending || micCooldown -> micStatusText
queueCount > 0 -> "$queueCount queued"
micIsSending -> "Sending"
micCooldown -> "Cooldown"
micEnabled -> "Listening"
else -> "Mic off"
}
val stateColor =

View File

@@ -13,7 +13,7 @@ import kotlin.math.max
import kotlin.math.roundToInt
private const val CHAT_ATTACHMENT_MAX_WIDTH = 1600
internal const val CHAT_IMAGE_MAX_BASE64_CHARS = 300 * 1024
private const val CHAT_ATTACHMENT_MAX_BASE64_CHARS = 300 * 1024
private const val CHAT_ATTACHMENT_START_QUALITY = 85
private const val CHAT_DECODE_MAX_DIMENSION = 1600
private const val CHAT_IMAGE_CACHE_BYTES = 16 * 1024 * 1024
@@ -35,7 +35,7 @@ internal fun loadSizedImageAttachment(
if (bitmap == null) {
throw IllegalStateException("unsupported attachment")
}
val maxBytes = (CHAT_IMAGE_MAX_BASE64_CHARS / 4) * 3
val maxBytes = (CHAT_ATTACHMENT_MAX_BASE64_CHARS / 4) * 3
val encoded =
JpegSizeLimiter.compressToLimit(
initialWidth = bitmap.width,

View File

@@ -30,16 +30,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -75,13 +72,10 @@ import org.commonmark.node.SoftLineBreak
import org.commonmark.node.StrongEmphasis
import org.commonmark.node.ThematicBreak
import org.commonmark.parser.Parser
import java.net.URI
import java.util.Locale
import org.commonmark.node.Image as MarkdownImage
import org.commonmark.node.Text as MarkdownTextNode
private const val LIST_INDENT_DP = 14
private const val DATA_IMAGE_HEADER_MAX_CHARS = 64
private val dataImageRegex = Regex("^data:image/([a-zA-Z0-9+.-]+);base64,([A-Za-z0-9+/=\\n\\r]+)$")
private val markdownParser: Parser by lazy {
@@ -505,12 +499,19 @@ private fun AnnotatedString.Builder.appendInlineNode(
}
}
is Link -> {
appendLinkNode(
link = current,
inlineCodeBg = inlineCodeBg,
inlineCodeColor = inlineCodeColor,
linkColor = linkColor,
)
withStyle(
SpanStyle(
color = linkColor,
textDecoration = TextDecoration.Underline,
),
) {
appendInlineNode(
current.firstChild,
inlineCodeBg = inlineCodeBg,
inlineCodeColor = inlineCodeColor,
linkColor = linkColor,
)
}
}
is MarkdownImage -> {
val alt = buildPlainText(current.firstChild)
@@ -526,75 +527,13 @@ private fun AnnotatedString.Builder.appendInlineNode(
}
}
else -> {
appendInlineNode(
current.firstChild,
inlineCodeBg = inlineCodeBg,
inlineCodeColor = inlineCodeColor,
linkColor = linkColor,
)
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor, linkColor = linkColor)
}
}
current = current.next
}
}
private fun AnnotatedString.Builder.appendLinkNode(
link: Link,
inlineCodeBg: Color,
inlineCodeColor: Color,
linkColor: Color,
) {
val destination = link.destination?.trim().orEmpty()
val linkStyle =
SpanStyle(
color = linkColor,
textDecoration = TextDecoration.Underline,
)
if (destination.isEmpty() || !isSafeMarkdownLinkDestination(destination)) {
appendInlineNode(
link.firstChild,
inlineCodeBg = inlineCodeBg,
inlineCodeColor = inlineCodeColor,
linkColor = linkColor,
)
return
}
withLink(LinkAnnotation.Url(url = destination, styles = TextLinkStyles(style = linkStyle))) {
appendInlineNode(
link.firstChild,
inlineCodeBg = inlineCodeBg,
inlineCodeColor = inlineCodeColor,
linkColor = linkColor,
)
}
}
private fun isSafeMarkdownLinkDestination(destination: String): Boolean {
val scheme =
runCatching { URI(destination).scheme?.lowercase(Locale.US) }
.getOrNull()
?: return false
return scheme == "http" || scheme == "https"
}
internal fun buildChatInlineMarkdown(
text: String,
linkColor: Color = Color.Blue,
): AnnotatedString {
val document = markdownParser.parse(text) as Document
val paragraph = document.firstChild as? Paragraph ?: return AnnotatedString("")
return buildInlineMarkdown(
paragraph.firstChild,
InlineStyles(
inlineCodeBg = Color.Transparent,
inlineCodeColor = Color.Unspecified,
linkColor = linkColor,
baseCallout = TextStyle.Default,
),
)
}
private fun buildPlainText(start: Node?): String {
val sb = StringBuilder()
var node = start
@@ -615,10 +554,9 @@ private fun standaloneDataImage(paragraph: Paragraph): ParsedDataImage? {
return parseDataImageDestination(only.destination)
}
internal fun parseDataImageDestination(destination: String?): ParsedDataImage? {
private fun parseDataImageDestination(destination: String?): ParsedDataImage? {
val raw = destination?.trim().orEmpty()
if (raw.isEmpty()) return null
if (raw.length > CHAT_IMAGE_MAX_BASE64_CHARS + DATA_IMAGE_HEADER_MAX_CHARS) return null
val match = dataImageRegex.matchEntire(raw) ?: return null
val subtype =
match.groupValues
@@ -633,7 +571,6 @@ internal fun parseDataImageDestination(destination: String?): ParsedDataImage? {
?.trim()
.orEmpty()
if (base64.isEmpty()) return null
if (base64.length > CHAT_IMAGE_MAX_BASE64_CHARS) return null
return ParsedDataImage(mimeType = "image/$subtype", base64 = base64)
}
@@ -661,7 +598,7 @@ private data class TableRenderRow(
val cells: List<AnnotatedString>,
)
internal data class ParsedDataImage(
private data class ParsedDataImage(
val mimeType: String,
val base64: String,
)

View File

@@ -1,30 +1,29 @@
package ai.openclaw.app.voice
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.speech.RecognitionListener
import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer
import android.util.Log
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import java.util.UUID
import kotlin.coroutines.coroutineContext
enum class VoiceConversationRole {
User,
@@ -41,13 +40,6 @@ data class VoiceConversationEntry(
class MicCaptureManager(
private val context: Context,
private val scope: CoroutineScope,
private val createTranscriptionSession: suspend () -> String,
private val appendTranscriptionAudio: suspend (
sessionId: String,
audio: ByteArray,
onError: (String) -> Unit,
) -> Unit,
private val closeTranscriptionSession: suspend (sessionId: String) -> Unit,
/**
* Send [message] to the gateway and return the run ID.
* [onRunIdKnown] is called with the idempotency key *before* the network
@@ -58,15 +50,15 @@ class MicCaptureManager(
) {
companion object {
private const val tag = "MicCapture"
private const val transcriptionSampleRateHz = 8_000
private const val transcriptionAudioFrameMs = 100
private const val pcmuBias = 0x84
private const val pcmuClip = 32635
private const val speechMinSessionMs = 30_000L
private const val speechCompleteSilenceMs = 1_500L
private const val speechPossibleSilenceMs = 900L
private const val transcriptIdleFlushMs = 1_600L
private const val maxConversationEntries = 40
private const val pendingRunTimeoutMs = 45_000L
}
private val mainHandler = Handler(Looper.getMainLooper())
private val json = Json { ignoreUnknownKeys = true }
private val _micEnabled = MutableStateFlow(false)
@@ -103,11 +95,9 @@ class MicCaptureManager(
private var pendingAssistantEntryId: String? = null
private var gatewayConnected = false
@Volatile private var transcriptionSessionId: String? = null
private var transcriptionStartJob: Job? = null
private var transcriptionCaptureJob: Job? = null
private var transcriptionAppendJob: Job? = null
private var transcriptionDrainJob: Job? = null
private var recognizer: SpeechRecognizer? = null
private var restartJob: Job? = null
private var drainJob: Job? = null
private var transcriptFlushJob: Job? = null
private var pendingRunTimeoutJob: Job? = null
private var stopRequested = false
@@ -163,23 +153,23 @@ class MicCaptureManager(
_statusText.value = if (_isSending.value) "Speaking · waiting for reply" else "Speaking…"
return
}
transcriptionDrainJob?.cancel()
transcriptionDrainJob = null
_micCooldown.value = false
start()
sendQueuedIfIdle()
} else {
transcriptionDrainJob?.cancel()
// Give the recognizer time to finish processing buffered audio.
// Cancel any prior drain to prevent duplicate sends on rapid toggle.
drainJob?.cancel()
_micCooldown.value = true
transcriptionDrainJob =
drainJob =
scope.launch {
delay(2000L)
stop()
// Capture any partial transcript that didn't get a final result from the recognizer
val partial = _liveTranscript.value?.trim().orEmpty()
if (partial.isNotEmpty()) {
queueRecognizedMessage(partial)
}
transcriptionDrainJob = null
drainJob = null
_micCooldown.value = false
sendQueuedIfIdle()
}
@@ -192,9 +182,11 @@ class MicCaptureManager(
ttsPauseDepth += 1
if (ttsPauseDepth > 1) return@synchronized false
resumeMicAfterTts = _micEnabled.value
val active = resumeMicAfterTts || transcriptionSessionId != null || _isListening.value
val active = resumeMicAfterTts || recognizer != null || _isListening.value
if (!active) return@synchronized false
stopRequested = true
restartJob?.cancel()
restartJob = null
transcriptFlushJob?.cancel()
transcriptFlushJob = null
_isListening.value = false
@@ -204,7 +196,11 @@ class MicCaptureManager(
true
}
if (!shouldPause) return
stopTranscription(preserveStatus = true)
withContext(Dispatchers.Main) {
recognizer?.cancel()
recognizer?.destroy()
recognizer = null
}
}
suspend fun resumeAfterTts() {
@@ -235,14 +231,9 @@ class MicCaptureManager(
fun onGatewayConnectionChanged(connected: Boolean) {
gatewayConnected = connected
if (connected) {
if (_micEnabled.value && transcriptionSessionId == null) {
start()
}
sendQueuedIfIdle()
return
}
stopRequested = true
stopTranscription(preserveStatus = true)
pendingRunTimeoutJob?.cancel()
pendingRunTimeoutJob = null
pendingRunId = null
@@ -257,10 +248,6 @@ class MicCaptureManager(
event: String,
payloadJson: String?,
) {
if (event == "talk.event") {
handleTranscriptionEvent(payloadJson)
return
}
if (event != "chat") return
if (payloadJson.isNullOrBlank()) return
val payload =
@@ -317,96 +304,93 @@ class MicCaptureManager(
private fun start() {
stopRequested = false
if (!SpeechRecognizer.isRecognitionAvailable(context)) {
_statusText.value = "Speech recognizer unavailable"
_micEnabled.value = false
return
}
if (!hasMicPermission()) {
_statusText.value = "Microphone permission required"
_micEnabled.value = false
return
}
if (!gatewayConnected) {
_statusText.value = "Mic on · waiting for gateway"
return
}
if (transcriptionSessionId != null || transcriptionStartJob?.isActive == true) return
val startJob =
scope.launch {
var restartAfterCancellation = false
try {
val sessionId = createTranscriptionSession()
if (stopRequested || !_micEnabled.value) {
closeTranscriptionSession(sessionId)
return@launch
}
transcriptionSessionId = sessionId
_isListening.value = true
_statusText.value = listeningStatus()
startTranscriptionCapture(sessionId)
Log.d(tag, "transcription session started sessionId=$sessionId")
} catch (err: Throwable) {
if (err is CancellationException) {
restartAfterCancellation = _micEnabled.value && gatewayConnected && !stopRequested
return@launch
}
_statusText.value = "Transcription unavailable: ${err.message ?: err::class.simpleName}"
_micEnabled.value = false
stopTranscription(preserveStatus = true)
} finally {
if (transcriptionStartJob === coroutineContext[Job]) {
transcriptionStartJob = null
}
if (restartAfterCancellation) {
start()
}
mainHandler.post {
try {
if (recognizer == null) {
recognizer = SpeechRecognizer.createSpeechRecognizer(context).also { it.setRecognitionListener(listener) }
}
startListeningSession()
} catch (err: Throwable) {
_statusText.value = "Start failed: ${err.message ?: err::class.simpleName}"
_micEnabled.value = false
}
transcriptionStartJob = startJob
}
}
private fun stop() {
stopRequested = true
stopTranscription()
}
private fun stopTranscription(preserveStatus: Boolean = false) {
val status = _statusText.value
val sessionId = transcriptionSessionId
transcriptionSessionId = null
if (sessionId != null) {
transcriptionStartJob?.cancel()
transcriptionStartJob = null
} else if (transcriptionStartJob?.isActive != true) {
transcriptionStartJob = null
}
transcriptionCaptureJob?.cancel()
transcriptionAppendJob?.cancel()
transcriptionCaptureJob = null
transcriptionAppendJob = null
restartJob?.cancel()
restartJob = null
transcriptFlushJob?.cancel()
transcriptFlushJob = null
_isListening.value = false
_statusText.value = if (_isSending.value) "Mic off · sending…" else "Mic off"
_inputLevel.value = 0f
if (!preserveStatus) {
_statusText.value = if (_isSending.value) "Mic off · sending…" else "Mic off"
} else {
_statusText.value = status
mainHandler.post {
recognizer?.cancel()
recognizer?.destroy()
recognizer = null
}
if (!sessionId.isNullOrBlank()) {
}
private fun startListeningSession() {
val recognizerInstance = recognizer ?: return
val intent =
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 3)
putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, context.packageName)
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_MINIMUM_LENGTH_MILLIS, speechMinSessionMs)
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS, speechCompleteSilenceMs)
putExtra(
RecognizerIntent.EXTRA_SPEECH_INPUT_POSSIBLY_COMPLETE_SILENCE_LENGTH_MILLIS,
speechPossibleSilenceMs,
)
}
_statusText.value =
when {
_isSending.value -> "Listening · sending queued voice"
hasQueuedMessages() -> "Listening · ${queuedMessageCount()} queued"
else -> "Listening"
}
_isListening.value = true
recognizerInstance.startListening(intent)
}
private fun scheduleRestart(delayMs: Long = 300L) {
if (stopRequested) return
if (!_micEnabled.value) return
restartJob?.cancel()
restartJob =
scope.launch {
try {
closeTranscriptionSession(sessionId)
} catch (err: Throwable) {
if (err !is CancellationException) {
Log.d(tag, "transcription close ignored: ${err.message ?: err::class.simpleName}")
delay(delayMs)
mainHandler.post {
if (stopRequested || !_micEnabled.value) return@post
try {
startListeningSession()
} catch (_: Throwable) {
// retry through onError
}
}
}
}
}
private fun queueRecognizedMessage(text: String) {
val message = text.trim()
_liveTranscript.value = null
if (!message.hasTranscriptContent()) return
if (message.isEmpty()) return
appendConversation(
role = VoiceConversationRole.User,
text = message,
@@ -588,208 +572,21 @@ class MicCaptureManager(
}
}
@SuppressLint("MissingPermission")
private fun startTranscriptionCapture(sessionId: String) {
transcriptionCaptureJob?.cancel()
transcriptionAppendJob?.cancel()
val audioFrames =
Channel<ByteArray>(
capacity = 4,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
transcriptionAppendJob =
scope.launch(Dispatchers.IO) {
for (frame in audioFrames) {
if (transcriptionSessionId != sessionId) continue
try {
appendTranscriptionAudio(sessionId, pcm16ToPcmu(frame)) { message ->
failTranscription(sessionId, message)
}
} catch (err: Throwable) {
if (err is CancellationException) throw err
failTranscription(sessionId, err.message ?: err::class.simpleName ?: "request failed")
}
}
}
transcriptionCaptureJob =
scope.launch(Dispatchers.IO) {
var audioRecord: AudioRecord? = null
try {
val frameBytes = transcriptionSampleRateHz * 2 * transcriptionAudioFrameMs / 1000
val minBuffer =
AudioRecord.getMinBufferSize(
transcriptionSampleRateHz,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
)
if (minBuffer <= 0) {
throw IllegalStateException("AudioRecord buffer unavailable")
}
audioRecord =
AudioRecord
.Builder()
.setAudioSource(MediaRecorder.AudioSource.VOICE_RECOGNITION)
.setAudioFormat(
AudioFormat
.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(transcriptionSampleRateHz)
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
.build(),
).setBufferSizeInBytes(maxOf(minBuffer, frameBytes * 4))
.build()
val buffer = ByteArray(frameBytes)
audioRecord.startRecording()
while (coroutineContext.isActive && _micEnabled.value && transcriptionSessionId == sessionId) {
val read = audioRecord.read(buffer, 0, buffer.size)
if (read <= 0) continue
_inputLevel.value = pcm16Level(buffer, read)
audioFrames.trySend(buffer.copyOf(read))
}
} catch (err: Throwable) {
if (err is CancellationException) throw err
failTranscription(sessionId, err.message ?: err::class.simpleName ?: "capture failed")
} finally {
audioFrames.close()
audioRecord?.let { record ->
try {
record.stop()
} catch (_: Throwable) {
}
record.release()
}
}
}
}
private fun handleTranscriptionEvent(payloadJson: String?) {
if (payloadJson.isNullOrBlank()) return
val obj =
try {
json.parseToJsonElement(payloadJson).asObjectOrNull()
} catch (_: Throwable) {
null
} ?: return
val sessionId = obj["transcriptionSessionId"].asStringOrNull() ?: obj["sessionId"].asStringOrNull()
val currentSessionId = transcriptionSessionId
if (currentSessionId == null || sessionId != currentSessionId) return
when (obj["type"].asStringOrNull()) {
"ready", "inputAudio", "speechStart" -> {
_isListening.value = true
_statusText.value = listeningStatus()
}
"partial" -> {
val text = obj["text"].asStringOrNull()?.trim().orEmpty()
if (text.isNotEmpty()) {
_liveTranscript.value = text
scheduleTranscriptFlush(text)
}
}
"transcript" -> {
transcriptFlushJob?.cancel()
transcriptFlushJob = null
val text = obj["text"].asStringOrNull()?.trim().orEmpty()
if (text.isNotEmpty()) {
if (text != flushedPartialTranscript) {
queueRecognizedMessage(text)
sendQueuedIfIdle()
} else {
flushedPartialTranscript = null
_liveTranscript.value = null
}
}
}
"error" -> {
val message =
obj["message"]
.asStringOrNull()
?.trim()
.orEmpty()
.ifEmpty { "transcription failed" }
failTranscription(currentSessionId, message)
}
"close" -> {
_micEnabled.value = false
stopTranscription()
}
}
}
private fun failTranscription(
sessionId: String,
message: String,
) {
if (transcriptionSessionId != sessionId) return
_statusText.value = "Transcription failed: $message"
private fun disableMic(status: String) {
stopRequested = true
restartJob?.cancel()
restartJob = null
transcriptFlushJob?.cancel()
transcriptFlushJob = null
_micEnabled.value = false
stopTranscription(preserveStatus = true)
}
private fun listeningStatus(): String =
when {
_isSending.value -> "Listening · sending queued voice"
hasQueuedMessages() -> "Listening · ${queuedMessageCount()} queued"
else -> "Listening"
_isListening.value = false
_inputLevel.value = 0f
_statusText.value = status
mainHandler.post {
recognizer?.cancel()
recognizer?.destroy()
recognizer = null
}
private fun pcm16Level(
frame: ByteArray,
length: Int,
): Float {
var total = 0L
var count = 0
var index = 0
val limit = length - (length % 2)
while (index < limit) {
val sample =
(frame[index].toInt() and 0xff) or
(frame[index + 1].toInt() shl 8)
total += kotlin.math.abs(sample.toShort().toInt())
count += 1
index += 2
}
if (count == 0) return 0f
return ((total / count).toFloat() / Short.MAX_VALUE).coerceIn(0f, 1f)
}
private fun pcm16ToPcmu(pcm16: ByteArray): ByteArray {
val output = ByteArray(pcm16.size / 2)
var inputIndex = 0
var outputIndex = 0
while (inputIndex + 1 < pcm16.size) {
val sample =
(
(pcm16[inputIndex].toInt() and 0xff) or
(pcm16[inputIndex + 1].toInt() shl 8)
).toShort().toInt()
output[outputIndex] = linear16ToPcmu(sample)
inputIndex += 2
outputIndex += 1
}
return output
}
private fun linear16ToPcmu(sample: Int): Byte {
var sign = 0
var magnitude = sample
if (magnitude < 0) {
sign = 0x80
magnitude = -magnitude
}
if (magnitude > pcmuClip) {
magnitude = pcmuClip
}
magnitude += pcmuBias
var exponent = 7
var mask = 0x4000
while ((magnitude and mask) == 0 && exponent > 0) {
exponent -= 1
mask = mask shr 1
}
val mantissa = (magnitude shr (exponent + 3)) and 0x0f
return (sign or (exponent shl 4) or mantissa).inv().toByte()
}
private fun hasMicPermission(): Boolean =
@@ -799,10 +596,103 @@ class MicCaptureManager(
)
private fun parseAssistantText(payload: JsonObject): String? = ChatEventText.assistantTextFromPayload(payload)
private val listener =
object : RecognitionListener {
override fun onReadyForSpeech(params: Bundle?) {
_isListening.value = true
}
override fun onBeginningOfSpeech() {}
override fun onRmsChanged(rmsdB: Float) {
val level = ((rmsdB + 2f) / 12f).coerceIn(0f, 1f)
_inputLevel.value = level
}
override fun onBufferReceived(buffer: ByteArray?) {}
override fun onEndOfSpeech() {
_inputLevel.value = 0f
scheduleRestart()
}
override fun onError(error: Int) {
if (stopRequested) return
_isListening.value = false
_inputLevel.value = 0f
val status =
when (error) {
SpeechRecognizer.ERROR_AUDIO -> "Audio error"
SpeechRecognizer.ERROR_CLIENT -> "Client error"
SpeechRecognizer.ERROR_NETWORK -> "Network error"
SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "Network timeout"
SpeechRecognizer.ERROR_NO_MATCH -> "Listening"
SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "Recognizer busy"
SpeechRecognizer.ERROR_SERVER -> "Server error"
SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "Listening"
SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "Microphone permission required"
SpeechRecognizer.ERROR_LANGUAGE_NOT_SUPPORTED -> "Language not supported on this device"
SpeechRecognizer.ERROR_LANGUAGE_UNAVAILABLE -> "Language unavailable on this device"
SpeechRecognizer.ERROR_SERVER_DISCONNECTED -> "Speech service disconnected"
SpeechRecognizer.ERROR_TOO_MANY_REQUESTS -> "Speech requests limited; retrying"
else -> "Speech error ($error)"
}
_statusText.value = status
if (
error == SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS ||
error == SpeechRecognizer.ERROR_LANGUAGE_NOT_SUPPORTED ||
error == SpeechRecognizer.ERROR_LANGUAGE_UNAVAILABLE
) {
disableMic(status)
return
}
val restartDelayMs =
when (error) {
SpeechRecognizer.ERROR_NO_MATCH,
SpeechRecognizer.ERROR_SPEECH_TIMEOUT,
-> 1_200L
SpeechRecognizer.ERROR_TOO_MANY_REQUESTS -> 2_500L
else -> 600L
}
scheduleRestart(delayMs = restartDelayMs)
}
override fun onResults(results: Bundle?) {
transcriptFlushJob?.cancel()
transcriptFlushJob = null
val text = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty().firstOrNull()
if (!text.isNullOrBlank()) {
val trimmed = text.trim()
if (trimmed != flushedPartialTranscript) {
queueRecognizedMessage(trimmed)
sendQueuedIfIdle()
} else {
flushedPartialTranscript = null
_liveTranscript.value = null
}
}
scheduleRestart()
}
override fun onPartialResults(partialResults: Bundle?) {
val text = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty().firstOrNull()
if (!text.isNullOrBlank()) {
val trimmed = text.trim()
_liveTranscript.value = trimmed
scheduleTranscriptFlush(trimmed)
}
}
override fun onEvent(
eventType: Int,
params: Bundle?,
) {}
}
}
private fun kotlinx.serialization.json.JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
private fun kotlinx.serialization.json.JsonElement?.asStringOrNull(): String? = (this as? JsonPrimitive)?.takeIf { it.isString }?.content
private fun String.hasTranscriptContent(): Boolean = any { it.isLetterOrDigit() }

View File

@@ -11,14 +11,8 @@ internal data class TalkModeGatewayConfigState(
val mainSessionKey: String,
val interruptOnSpeech: Boolean?,
val silenceTimeoutMs: Long,
val executionMode: TalkModeExecutionMode,
)
internal enum class TalkModeExecutionMode {
Native,
RealtimeRelay,
}
internal object TalkModeGatewayConfigParser {
fun parse(config: JsonObject?): TalkModeGatewayConfigState {
val talk = config?.get("talk").asObjectOrNull()
@@ -27,22 +21,9 @@ internal object TalkModeGatewayConfigParser {
mainSessionKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull()),
interruptOnSpeech = talk?.get("interruptOnSpeech").asBooleanOrNull(),
silenceTimeoutMs = resolvedSilenceTimeoutMs(talk),
executionMode = resolvedExecutionMode(talk),
)
}
fun resolvedExecutionMode(talk: JsonObject?): TalkModeExecutionMode {
val realtime = talk?.get("realtime").asObjectOrNull() ?: return TalkModeExecutionMode.Native
val mode = realtime["mode"].asStringOrNull()
val transport = realtime["transport"].asStringOrNull()
val brain = realtime["brain"].asStringOrNull()
return if (mode == "realtime" && transport == "gateway-relay" && (brain == null || brain == "agent-consult")) {
TalkModeExecutionMode.RealtimeRelay
} else {
TalkModeExecutionMode.Native
}
}
fun resolvedSilenceTimeoutMs(talk: JsonObject?): Long {
val fallback = TalkDefaults.defaultSilenceTimeoutMs
val primitive = talk?.get("silenceTimeoutMs") as? JsonPrimitive ?: return fallback

View File

@@ -2,17 +2,12 @@ package ai.openclaw.app.voice
import ai.openclaw.app.gateway.GatewaySession
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioRecord
import android.media.AudioTrack
import android.media.MediaRecorder
import android.os.Bundle
import android.os.Handler
import android.os.Looper
@@ -22,7 +17,6 @@ import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import android.util.Base64
import android.util.Log
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CancellationException
@@ -31,23 +25,17 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import java.util.LinkedHashMap
import java.util.Locale
import java.util.UUID
import java.util.concurrent.atomic.AtomicLong
@@ -74,16 +62,6 @@ data class TalkPttStopPayload(
}.toString()
}
internal data class RealtimeToolRun(
val callId: String,
val relaySessionId: String,
)
private data class RealtimeToolCompletion(
val state: String,
val messageEl: JsonElement?,
)
class TalkModeManager internal constructor(
private val context: Context,
private val scope: CoroutineScope,
@@ -92,19 +70,15 @@ class TalkModeManager internal constructor(
private val isConnected: () -> Boolean,
private val onBeforeSpeak: suspend () -> Unit = {},
private val onAfterSpeak: suspend () -> Unit = {},
private val onStoppedByRelay: () -> Unit = {},
private val talkSpeakClient: TalkSpeechSynthesizing = TalkSpeakClient(session = session),
private val talkAudioPlayer: TalkAudioPlaying = TalkAudioPlayer(context),
) {
companion object {
private const val tag = "TalkMode"
private const val realtimeSampleRateHz = 24_000
private const val realtimeAudioFrameMs = 100
private const val listenWatchdogMs = 12_000L
private const val chatFinalWaitWithSubscribeMs = 45_000L
private const val chatFinalWaitWithoutSubscribeMs = 6_000L
private const val maxCachedRunCompletions = 128
private const val maxConversationEntries = 40
}
private val mainHandler = Handler(Looper.getMainLooper())
@@ -124,9 +98,6 @@ class TalkModeManager internal constructor(
private val _lastAssistantText = MutableStateFlow<String?>(null)
val lastAssistantText: StateFlow<String?> = _lastAssistantText
private val _conversation = MutableStateFlow<List<VoiceConversationEntry>>(emptyList())
val conversation: StateFlow<List<VoiceConversationEntry>> = _conversation
private var recognizer: SpeechRecognizer? = null
private var restartJob: Job? = null
private var stopRequested = false
@@ -155,29 +126,8 @@ class TalkModeManager internal constructor(
private val completedRunTexts = LinkedHashMap<String, String>()
private var chatSubscribedSessionKey: String? = null
private var configLoaded = false
private var executionMode = TalkModeExecutionMode.Native
private val startGeneration = AtomicLong(0L)
@Volatile private var realtimeSessionId: String? = null
private var realtimeCaptureJob: Job? = null
private var realtimeAppendJob: Job? = null
private val realtimeToolRuns = LinkedHashMap<String, RealtimeToolRun>()
private val pendingRealtimeToolCalls = LinkedHashSet<String>()
private val pendingRealtimeToolCompletions = LinkedHashMap<String, RealtimeToolCompletion>()
private var realtimeUserEntryId: String? = null
private var realtimeAssistantEntryId: String? = null
private val realtimePlaybackLock = Any()
private var realtimeAudioTrack: AudioTrack? = null
private var realtimePlaybackIdleJob: Job? = null
@Volatile
private var realtimePlaybackEndsAtMs = 0L
@Volatile
private var realtimeOutputSuppressed = false
@Volatile
private var playbackEnabled = true
@Volatile private var playbackEnabled = true
private val playbackGeneration = AtomicLong(0L)
private var ttsJob: Job? = null
@@ -406,10 +356,6 @@ class TalkModeManager internal constructor(
event: String,
payloadJson: String?,
) {
if (event == "talk.event") {
handleRealtimeTalkEvent(payloadJson)
return
}
if (ttsOnAllResponses) {
Log.d(tag, "gateway event: $event")
}
@@ -433,13 +379,6 @@ class TalkModeManager internal constructor(
val activeSession = mainSessionKey.ifBlank { "main" }
if (eventSession != null && eventSession != activeSession) return
if (maybeCompleteRealtimeToolCall(runId = runId, state = state, messageEl = obj["message"])) {
return
}
if (holdPendingRealtimeToolCompletion(runId = runId, state = state, messageEl = obj["message"])) {
return
}
// If this is a response we initiated, handle normally below.
// Otherwise, if ttsOnAllResponses, finish streaming TTS on terminal events.
val pending = pendingRunId
@@ -484,7 +423,6 @@ class TalkModeManager internal constructor(
if (playbackEnabled == enabled) return
playbackEnabled = enabled
if (!enabled) {
stopRealtimePlayback()
stopSpeaking()
}
}
@@ -504,43 +442,16 @@ class TalkModeManager internal constructor(
}
private fun start() {
if (realtimeSessionId != null || realtimeCaptureJob?.isActive == true) return
val generation = startGeneration.incrementAndGet()
stopRequested = false
listeningMode = true
Log.d(tag, "start")
scope.launch {
try {
ensureConfigLoaded()
if (generation != startGeneration.get() || !_isEnabled.value || stopRequested) return@launch
if (executionMode == TalkModeExecutionMode.RealtimeRelay) {
startRealtimeRelay(generation)
} else {
startNativeRecognition(generation)
}
} catch (err: Throwable) {
if (err is CancellationException) return@launch
_statusText.value = "Start failed: ${err.message ?: err::class.simpleName}"
Log.w(tag, "start failed: ${err.message ?: err::class.simpleName}")
if (executionMode == TalkModeExecutionMode.RealtimeRelay) {
stopRealtimeRelay(closeSession = false, preserveStatus = true)
disableRealtimeModeAndNotifyOwner()
}
}
}
}
private suspend fun startNativeRecognition(generation: Long) {
withContext(Dispatchers.Main) {
if (generation != startGeneration.get()) return@withContext
if (!_isEnabled.value || stopRequested) return@withContext
if (_isListening.value) return@withContext
Log.d(tag, "start native")
mainHandler.post {
if (_isListening.value) return@post
stopRequested = false
listeningMode = true
Log.d(tag, "start")
if (!SpeechRecognizer.isRecognitionAvailable(context)) {
_statusText.value = "Speech recognizer unavailable"
Log.w(tag, "speech recognizer unavailable")
return@withContext
return@post
}
val micOk =
@@ -549,14 +460,19 @@ class TalkModeManager internal constructor(
if (!micOk) {
_statusText.value = "Microphone permission required"
Log.w(tag, "microphone permission required")
return@withContext
return@post
}
recognizer?.destroy()
recognizer = SpeechRecognizer.createSpeechRecognizer(context).also { it.setRecognitionListener(listener) }
startListeningInternal(markListening = true)
startSilenceMonitor()
Log.d(tag, "listening")
try {
recognizer?.destroy()
recognizer = SpeechRecognizer.createSpeechRecognizer(context).also { it.setRecognitionListener(listener) }
startListeningInternal(markListening = true)
startSilenceMonitor()
Log.d(tag, "listening")
} catch (err: Throwable) {
_statusText.value = "Start failed: ${err.message ?: err::class.simpleName}"
Log.w(tag, "start failed: ${err.message ?: err::class.simpleName}")
}
}
}
@@ -568,7 +484,6 @@ class TalkModeManager internal constructor(
pttAutoStopEnabled = false
pttCompletion?.cancel()
pttCompletion = null
startGeneration.incrementAndGet()
pttTimeoutJob?.cancel()
pttTimeoutJob = null
restartJob?.cancel()
@@ -579,7 +494,6 @@ class TalkModeManager internal constructor(
lastHeardAtMs = null
_isListening.value = false
_statusText.value = "Off"
stopRealtimeRelay()
stopSpeaking()
chatSubscribedSessionKey = null
pendingRunId = null
@@ -598,565 +512,6 @@ class TalkModeManager internal constructor(
shutdownTextToSpeech()
}
private suspend fun startRealtimeRelay(generation: Long) {
if (!isConnected()) {
_statusText.value = "Gateway not connected"
Log.w(tag, "realtime start: gateway not connected")
disableRealtimeModeAndNotifyOwner()
return
}
val micOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
if (!micOk) {
_statusText.value = "Microphone permission required"
Log.w(tag, "realtime start: microphone permission required")
disableRealtimeModeAndNotifyOwner()
return
}
ensureConfigLoaded()
cancelActivePlayback()
stopTextToSpeechPlayback()
withContext(Dispatchers.Main) {
recognizer?.cancel()
recognizer?.destroy()
recognizer = null
}
_statusText.value = "Connecting…"
val params =
buildJsonObject {
put("sessionKey", JsonPrimitive(mainSessionKey.ifBlank { "main" }))
put("mode", JsonPrimitive("realtime"))
put("transport", JsonPrimitive("gateway-relay"))
put("brain", JsonPrimitive("agent-consult"))
}
val payload = session.request("talk.session.create", params.toString(), timeoutMs = 15_000)
val root = json.parseToJsonElement(payload).asObjectOrNull()
val relaySession = root?.get("relaySessionId").asStringOrNull()
val sessionId = relaySession ?: root?.get("sessionId").asStringOrNull()
if (sessionId.isNullOrBlank()) {
throw IllegalStateException("talk.session.create returned no session id")
}
if (generation != startGeneration.get() || !_isEnabled.value || stopRequested) {
closeRealtimeSession(sessionId)
throw CancellationException("realtime talk stopped while connecting")
}
realtimeSessionId = sessionId
realtimeOutputSuppressed = false
_isListening.value = true
_statusText.value = "Listening"
startRealtimeCapture(sessionId)
Log.d(tag, "realtime session started relaySessionId=$sessionId")
}
private fun disableRealtimeModeAndNotifyOwner() {
if (!_isEnabled.value) return
_isEnabled.value = false
_isListening.value = false
onStoppedByRelay()
}
private fun failRealtimeRelay(
sessionId: String,
message: String,
) {
if (realtimeSessionId != sessionId) return
_statusText.value = "Talk failed: $message"
stopRealtimeRelay(cancelCapture = false, cancelAppend = false, preserveStatus = true)
disableRealtimeModeAndNotifyOwner()
}
@SuppressLint("MissingPermission")
private fun startRealtimeCapture(sessionId: String) {
realtimeCaptureJob?.cancel()
realtimeAppendJob?.cancel()
val audioFrames =
Channel<ByteArray>(
capacity = 4,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
realtimeAppendJob =
scope.launch(Dispatchers.IO) {
for (frame in audioFrames) {
if (realtimeSessionId != sessionId) continue
if (isRealtimePlaybackActive()) continue
val audioBase64 = Base64.encodeToString(frame, Base64.NO_WRAP)
val params =
buildJsonObject {
put("sessionId", JsonPrimitive(sessionId))
put("audioBase64", JsonPrimitive(audioBase64))
put("timestamp", JsonPrimitive(SystemClock.elapsedRealtime()))
}
try {
session.sendRequestFrame(
"talk.session.appendAudio",
params.toString(),
timeoutMs = 8_000,
) { error ->
Log.w(tag, "realtime appendAudio failed: ${error.message}")
failRealtimeRelay(sessionId, error.message)
}
} catch (err: Throwable) {
if (err is CancellationException) throw err
Log.w(tag, "realtime appendAudio failed: ${err.message ?: err::class.simpleName}")
failRealtimeRelay(sessionId, err.message ?: err::class.simpleName ?: "request failed")
}
}
}
realtimeCaptureJob =
scope.launch(Dispatchers.IO) {
var audioRecord: AudioRecord? = null
try {
val frameBytes = realtimeSampleRateHz * 2 * realtimeAudioFrameMs / 1000
val minBuffer =
AudioRecord.getMinBufferSize(
realtimeSampleRateHz,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
)
if (minBuffer <= 0) {
throw IllegalStateException("AudioRecord buffer unavailable")
}
audioRecord =
AudioRecord
.Builder()
.setAudioSource(MediaRecorder.AudioSource.VOICE_RECOGNITION)
.setAudioFormat(
AudioFormat
.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(realtimeSampleRateHz)
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
.build(),
).setBufferSizeInBytes(maxOf(minBuffer, frameBytes * 4))
.build()
val buffer = ByteArray(frameBytes)
audioRecord.startRecording()
while (coroutineContext.isActive && _isEnabled.value && realtimeSessionId == sessionId) {
val read = audioRecord.read(buffer, 0, buffer.size)
if (read <= 0) continue
if (!shouldAppendRealtimeCapturedFrame(read)) continue
audioFrames.trySend(buffer.copyOf(read))
}
} catch (err: Throwable) {
if (err is CancellationException) throw err
Log.w(tag, "realtime capture failed: ${err.message ?: err::class.simpleName}")
failRealtimeRelay(sessionId, err.message ?: err::class.simpleName ?: "capture failed")
} finally {
audioFrames.close()
audioRecord?.let { record ->
try {
record.stop()
} catch (_: Throwable) {
}
record.release()
}
}
}
}
private fun shouldAppendRealtimeCapturedFrame(length: Int): Boolean = !isRealtimePlaybackActive() && length > 0
private fun isRealtimePlaybackActive(): Boolean = _isSpeaking.value || SystemClock.elapsedRealtime() < realtimePlaybackEndsAtMs
private fun handleRealtimeTalkEvent(payloadJson: String?) {
if (payloadJson.isNullOrBlank()) return
val obj =
try {
json.parseToJsonElement(payloadJson).asObjectOrNull()
} catch (_: Throwable) {
null
} ?: return
val sessionId = obj["relaySessionId"].asStringOrNull() ?: obj["sessionId"].asStringOrNull()
val currentSessionId = realtimeSessionId
if (currentSessionId == null || sessionId != currentSessionId) return
when (val type = obj["type"].asStringOrNull()) {
"ready" -> {
_isListening.value = true
_statusText.value = "Listening"
}
"inputAudio" -> {
_isListening.value = true
}
"audio" -> {
if (realtimeOutputSuppressed) return
val audioBase64 = obj["audioBase64"].asStringOrNull() ?: return
val bytes =
try {
Base64.decode(audioBase64, Base64.DEFAULT)
} catch (err: Throwable) {
Log.w(tag, "realtime audio decode failed: ${err.message ?: err::class.simpleName}")
return
}
playRealtimeAudio(bytes)
}
"clear" -> stopRealtimePlayback()
"mark" -> Unit
"transcript" -> {
val role = obj["role"].asStringOrNull()
val text = obj["text"].asStringOrNull()?.trim().orEmpty()
val isFinal = obj["final"].asBooleanOrNull() == true
if (text.isNotEmpty()) {
when (role) {
"user" -> upsertRealtimeConversation(VoiceConversationRole.User, text, isFinal)
"assistant" -> upsertRealtimeConversation(VoiceConversationRole.Assistant, text, isFinal)
}
}
if (role == "assistant" && text.isNotEmpty()) {
_lastAssistantText.value = text
}
if (isFinal && role == "user") {
realtimeOutputSuppressed = false
_statusText.value = "Thinking…"
} else if (isFinal && role == "assistant") {
scheduleRealtimePlaybackIdle()
}
}
"toolCall" -> {
val callId = obj["callId"].asStringOrNull() ?: return
val name = obj["name"].asStringOrNull() ?: return
handleRealtimeToolCall(
callId = callId,
name = name,
args = obj["args"],
)
}
"toolResult" -> Unit
"error" -> {
val message = obj["message"].asStringOrNull() ?: "realtime talk error"
_statusText.value = "Talk failed: $message"
Log.w(tag, "realtime error: $message")
}
"close" -> {
Log.d(tag, "realtime close reason=${obj["reason"].asStringOrNull()}")
stopRealtimeRelay(closeSession = false)
if (_isEnabled.value) {
_isEnabled.value = false
_statusText.value = "Off"
onStoppedByRelay()
}
}
else -> {
if (type != null) Log.d(tag, "ignored realtime event type=$type")
}
}
}
private fun playRealtimeAudio(bytes: ByteArray) {
if (!playbackEnabled || realtimeOutputSuppressed || bytes.isEmpty()) return
synchronized(realtimePlaybackLock) {
val track =
realtimeAudioTrack ?: run {
val minBuffer =
AudioTrack.getMinBufferSize(
realtimeSampleRateHz,
AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT,
)
val created =
AudioTrack
.Builder()
.setAudioAttributes(
AudioAttributes
.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build(),
).setAudioFormat(
AudioFormat
.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(realtimeSampleRateHz)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
.build(),
).setTransferMode(AudioTrack.MODE_STREAM)
.setBufferSizeInBytes(maxOf(minBuffer, bytes.size * 4))
.build()
created.play()
realtimeAudioTrack = created
created
}
_isSpeaking.value = true
_statusText.value = "Speaking…"
track.write(bytes, 0, bytes.size)
val durationMs = ((bytes.size / 2.0) / realtimeSampleRateHz * 1000.0).toLong()
val now = SystemClock.elapsedRealtime()
realtimePlaybackEndsAtMs = maxOf(now, realtimePlaybackEndsAtMs) + durationMs
scheduleRealtimePlaybackIdle()
}
}
private fun scheduleRealtimePlaybackIdle() {
realtimePlaybackIdleJob?.cancel()
val delayMs = maxOf(0L, realtimePlaybackEndsAtMs - SystemClock.elapsedRealtime())
realtimePlaybackIdleJob =
scope.launch {
delay(delayMs)
val idle =
synchronized(realtimePlaybackLock) {
val playbackIdle = SystemClock.elapsedRealtime() >= realtimePlaybackEndsAtMs
if (playbackIdle) _isSpeaking.value = false
playbackIdle
}
if (idle && _isEnabled.value && realtimeSessionId != null) {
_statusText.value = "Listening"
}
}
}
private fun stopRealtimePlayback() {
realtimePlaybackIdleJob?.cancel()
realtimePlaybackIdleJob = null
realtimePlaybackEndsAtMs = 0L
synchronized(realtimePlaybackLock) {
realtimeAudioTrack?.let { track ->
try {
track.pause()
track.flush()
track.stop()
} catch (_: Throwable) {
}
track.release()
}
realtimeAudioTrack = null
}
_isSpeaking.value = false
if (_isEnabled.value) {
_statusText.value = "Listening"
}
}
private fun stopRealtimeRelay(
closeSession: Boolean = true,
cancelCapture: Boolean = true,
cancelAppend: Boolean = true,
preserveStatus: Boolean = false,
) {
val status = _statusText.value
val sessionId = realtimeSessionId
realtimeSessionId = null
realtimeOutputSuppressed = false
if (cancelCapture) {
realtimeCaptureJob?.cancel()
}
if (cancelAppend) {
realtimeAppendJob?.cancel()
}
realtimeCaptureJob = null
realtimeAppendJob = null
realtimeToolRuns.clear()
pendingRealtimeToolCalls.clear()
pendingRealtimeToolCompletions.clear()
realtimeUserEntryId = null
realtimeAssistantEntryId = null
stopRealtimePlayback()
if (preserveStatus) {
_statusText.value = status
}
_isListening.value = false
if (closeSession && !sessionId.isNullOrBlank()) {
scope.launch {
closeRealtimeSession(sessionId)
}
}
}
private suspend fun closeRealtimeSession(sessionId: String) {
try {
val params = buildJsonObject { put("sessionId", JsonPrimitive(sessionId)) }
session.request("talk.session.close", params.toString(), timeoutMs = 5_000)
} catch (err: Throwable) {
if (err !is CancellationException) {
Log.d(tag, "realtime close ignored: ${err.message ?: err::class.simpleName}")
}
}
}
private fun handleRealtimeToolCall(
callId: String,
name: String,
args: JsonElement?,
) {
val relaySessionId = realtimeSessionId ?: return
pendingRealtimeToolCalls.add(callId)
scope.launch {
try {
val params =
buildJsonObject {
put("sessionKey", JsonPrimitive(mainSessionKey.ifBlank { "main" }))
put("callId", JsonPrimitive(callId))
put("name", JsonPrimitive(name))
put("relaySessionId", JsonPrimitive(relaySessionId))
if (args != null) put("args", args)
}
val response =
session.request("talk.client.toolCall", params.toString(), timeoutMs = 15_000)
val runId = parseRunId(response)
if (!runId.isNullOrBlank()) {
if (realtimeSessionId != relaySessionId) return@launch
realtimeToolRuns[runId] =
RealtimeToolRun(callId = callId, relaySessionId = relaySessionId)
val completion = pendingRealtimeToolCompletions.remove(runId)
if (completion != null) {
maybeCompleteRealtimeToolCall(
runId = runId,
state = completion.state,
messageEl = completion.messageEl,
)
} else {
_statusText.value = "Thinking…"
}
} else {
submitRealtimeToolError(callId, "tool call returned no run id", relaySessionId)
}
} catch (err: Throwable) {
if (err is CancellationException) throw err
Log.w(tag, "realtime toolCall failed: ${err.message ?: err::class.simpleName}")
submitRealtimeToolError(callId, err.message ?: "tool call failed", relaySessionId)
} finally {
pendingRealtimeToolCalls.remove(callId)
}
}
}
private fun holdPendingRealtimeToolCompletion(
runId: String,
state: String,
messageEl: JsonElement?,
): Boolean {
if (realtimeSessionId == null || pendingRealtimeToolCalls.isEmpty()) return false
if (state != "final" && state != "aborted" && state != "error") return false
pendingRealtimeToolCompletions[runId] =
RealtimeToolCompletion(state = state, messageEl = messageEl)
return true
}
private fun maybeCompleteRealtimeToolCall(
runId: String,
state: String,
messageEl: JsonElement?,
): Boolean {
val toolRun = realtimeToolRuns[runId] ?: return false
if (toolRun.relaySessionId != realtimeSessionId) {
realtimeToolRuns.remove(runId)
return true
}
when (state) {
"final" -> {
realtimeToolRuns.remove(runId)
val text = extractTextFromChatEventMessage(messageEl).orEmpty()
scope.launch {
submitRealtimeToolResult(
callId = toolRun.callId,
result = buildJsonObject { put("text", JsonPrimitive(text)) },
sessionId = toolRun.relaySessionId,
)
}
return true
}
"aborted", "error" -> {
realtimeToolRuns.remove(runId)
scope.launch {
submitRealtimeToolError(toolRun.callId, state, toolRun.relaySessionId)
}
return true
}
}
return false
}
private suspend fun submitRealtimeToolError(
callId: String,
message: String,
sessionId: String? = realtimeSessionId,
) {
submitRealtimeToolResult(
callId = callId,
result = buildJsonObject { put("error", JsonPrimitive(message)) },
sessionId = sessionId,
)
}
private suspend fun submitRealtimeToolResult(
callId: String,
result: JsonObject,
sessionId: String? = realtimeSessionId,
) {
val activeSessionId = sessionId ?: return
val params =
buildJsonObject {
put("sessionId", JsonPrimitive(activeSessionId))
put("callId", JsonPrimitive(callId))
put("result", result)
}
try {
session.request("talk.session.submitToolResult", params.toString(), timeoutMs = 15_000)
} catch (err: Throwable) {
if (err is CancellationException) throw err
Log.w(tag, "realtime submitToolResult failed: ${err.message ?: err::class.simpleName}")
}
}
private fun upsertRealtimeConversation(
role: VoiceConversationRole,
text: String,
isFinal: Boolean,
) {
val entryId =
when (role) {
VoiceConversationRole.User -> realtimeUserEntryId
VoiceConversationRole.Assistant -> realtimeAssistantEntryId
}
val resolvedEntryId =
if (entryId == null) {
appendConversation(role = role, text = text, isStreaming = !isFinal)
} else {
updateConversationEntry(id = entryId, text = text, isStreaming = !isFinal)
entryId
}
when (role) {
VoiceConversationRole.User -> realtimeUserEntryId = if (isFinal) null else resolvedEntryId
VoiceConversationRole.Assistant -> realtimeAssistantEntryId = if (isFinal) null else resolvedEntryId
}
}
private fun appendConversation(
role: VoiceConversationRole,
text: String,
isStreaming: Boolean,
): String {
val id = UUID.randomUUID().toString()
_conversation.value =
(_conversation.value + VoiceConversationEntry(id = id, role = role, text = text, isStreaming = isStreaming))
.takeLast(maxConversationEntries)
return id
}
private fun updateConversationEntry(
id: String,
text: String,
isStreaming: Boolean,
) {
val current = _conversation.value
val targetIndex =
when {
current.isEmpty() -> -1
current[current.lastIndex].id == id -> current.lastIndex
else -> current.indexOfFirst { it.id == id }
}
if (targetIndex < 0) return
val entry = current[targetIndex]
if (entry.text == text && entry.isStreaming == isStreaming) return
val updated = current.toMutableList()
updated[targetIndex] = entry.copy(text = text, isStreaming = isStreaming)
_conversation.value = updated
}
private fun startListeningInternal(markListening: Boolean) {
val r = recognizer ?: return
val intent =
@@ -1167,8 +522,8 @@ class TalkModeManager internal constructor(
putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, context.packageName)
// Use cloud recognition — it handles natural speech and pauses better
// than on-device which cuts off aggressively after short silences.
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS, 2500)
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_POSSIBLY_COMPLETE_SILENCE_LENGTH_MILLIS, 1800)
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS, 2500L)
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_POSSIBLY_COMPLETE_SILENCE_LENGTH_MILLIS, 1800L)
}
if (markListening) {
@@ -1407,7 +762,7 @@ class TalkModeManager internal constructor(
}
}
internal suspend fun waitForChatFinal(runId: String): Boolean {
private suspend fun waitForChatFinal(runId: String): Boolean {
consumeRunCompletion(runId)?.let { return it }
val deferred =
if (pendingRunId == runId) {
@@ -1418,12 +773,13 @@ class TalkModeManager internal constructor(
consumeRunCompletion(runId)?.let { return it }
val timeoutMs = if (supportsChatSubscribe) chatFinalWaitWithSubscribeMs else chatFinalWaitWithoutSubscribeMs
val result =
try {
withTimeout(timeoutMs) { deferred.await() }
} catch (_: TimeoutCancellationException) {
false
withContext(Dispatchers.IO) {
try {
kotlinx.coroutines.withTimeout(120_000) { deferred.await() }
} catch (_: Throwable) {
false
}
}
if (!result && pendingRunId == runId) {
@@ -1721,32 +1077,11 @@ class TalkModeManager internal constructor(
}
fun stopTts() {
realtimeOutputSuppressed = true
stopRealtimePlayback()
cancelRealtimeOutput(reason = "android-stop-tts")
stopSpeaking(resetInterrupt = true)
_isSpeaking.value = false
_statusText.value = "Listening"
}
private fun cancelRealtimeOutput(reason: String) {
val sessionId = realtimeSessionId ?: return
scope.launch {
try {
val params =
buildJsonObject {
put("sessionId", JsonPrimitive(sessionId))
put("reason", JsonPrimitive(reason))
}
session.request("talk.session.cancelOutput", params.toString(), timeoutMs = 5_000)
} catch (err: Throwable) {
if (err !is CancellationException) {
Log.d(tag, "realtime cancelOutput ignored: ${err.message ?: err::class.simpleName}")
}
}
}
}
private fun stopSpeaking(resetInterrupt: Boolean = true) {
playbackGeneration.incrementAndGet()
if (!_isSpeaking.value) {
@@ -1910,11 +1245,9 @@ class TalkModeManager internal constructor(
val parsed = TalkModeGatewayConfigParser.parse(root?.get("config").asObjectOrNull())
silenceWindowMs = parsed.silenceTimeoutMs
parsed.interruptOnSpeech?.let { interruptOnSpeech = it }
executionMode = parsed.executionMode
configLoaded = true
} catch (_: Throwable) {
silenceWindowMs = TalkDefaults.defaultSilenceTimeoutMs
executionMode = TalkModeExecutionMode.Native
configLoaded = false
}
}

View File

@@ -33,7 +33,7 @@ class VoiceWakeManager(
private var recognizer: SpeechRecognizer? = null
private var restartJob: Job? = null
private var lastCycleDispatched: String? = null
private var lastDispatched: String? = null
private var stopRequested = false
fun setTriggerWords(words: List<String>) {
@@ -110,8 +110,8 @@ class VoiceWakeManager(
private fun handleTranscription(text: String) {
val command = VoiceWakeCommandExtractor.extractCommand(text, triggerWords) ?: return
if (command == lastCycleDispatched) return
lastCycleDispatched = command
if (command == lastDispatched) return
lastDispatched = command
scope.launch { onCommand(command) }
_statusText.value = "Triggered"
@@ -121,7 +121,6 @@ class VoiceWakeManager(
private val listener =
object : RecognitionListener {
override fun onReadyForSpeech(params: Bundle?) {
lastCycleDispatched = null
_statusText.value = "Listening"
}

View File

@@ -10,7 +10,6 @@ import ai.openclaw.app.node.InvokeDispatcher
import ai.openclaw.app.protocol.OpenClawTalkCommand
import ai.openclaw.app.voice.TalkModeManager
import android.Manifest
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
@@ -30,14 +29,14 @@ import java.util.UUID
@Config(sdk = [34])
class GatewayBootstrapAuthTest {
@Test
fun doesNotConnectOperatorSessionWhenOnlyBootstrapAuthExists() {
assertFalse(
fun connectsOperatorSessionWhenOnlyBootstrapAuthExists() {
assertTrue(
shouldConnectOperatorSession(
NodeRuntime.GatewayConnectAuth(token = "", bootstrapToken = "bootstrap-1", password = ""),
storedOperatorToken = "",
),
)
assertFalse(
assertTrue(
shouldConnectOperatorSession(
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null),
storedOperatorToken = null,
@@ -65,7 +64,7 @@ class GatewayBootstrapAuthTest {
storedOperatorToken = "stored-token",
),
)
assertTrue(
assertFalse(
shouldConnectOperatorSession(
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "", password = null),
storedOperatorToken = null,
@@ -85,25 +84,17 @@ class GatewayBootstrapAuthTest {
}
@Test
fun resolveOperatorSessionConnectAuthIgnoresBootstrapWhenNoStoredOperatorTokenExists() {
fun resolveOperatorSessionConnectAuthUsesBootstrapWhenNoStoredOperatorTokenExists() {
val resolved =
resolveOperatorSessionConnectAuth(
auth = NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null),
storedOperatorToken = null,
)
assertNull(resolved)
}
@Test
fun resolveOperatorSessionConnectAuthUsesNoAuthWhenGatewayHasNoAuth() {
val resolved =
resolveOperatorSessionConnectAuth(
auth = NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = null, password = null),
storedOperatorToken = null,
)
assertEquals(NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = null, password = null), resolved)
assertEquals(
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null),
resolved,
)
}
@Test
@@ -165,7 +156,7 @@ class GatewayBootstrapAuthTest {
NodeRuntime(
app,
prefs,
tlsFingerprintProbe = { _, _ -> GatewayTlsProbeResult(fingerprintSha256 = "fp:1") },
tlsFingerprintProbe = { _, _ -> GatewayTlsProbeResult(fingerprintSha256 = "fp-1") },
)
val endpoint = GatewayEndpoint.manual(host = "gateway.example", port = 18789)
val explicitAuth =
@@ -181,91 +172,9 @@ class GatewayBootstrapAuthTest {
runtime.acceptGatewayTrustPrompt()
assertEquals("f1", prefs.loadGatewayTlsFingerprint(endpoint.stableId))
assertEquals("setup-bootstrap-token", waitForDesiredBootstrapToken(runtime, "nodeSession"))
assertNull(desiredBootstrapToken(runtime, "operatorSession"))
}
@Test
fun connect_promptsBeforeReplacingChangedTlsFingerprint() =
runBlocking {
val app = RuntimeEnvironment.getApplication()
val securePrefs =
app.getSharedPreferences(
"openclaw.node.secure.test.${UUID.randomUUID()}",
android.content.Context.MODE_PRIVATE,
)
val prefs = SecurePrefs(app, securePrefsOverride = securePrefs)
val endpoint = GatewayEndpoint.manual(host = "gateway.example", port = 18789)
prefs.saveGatewayTlsFingerprint(endpoint.stableId, "sha256:aa:aa:aa:aa")
val runtime =
NodeRuntime(
app,
prefs,
tlsFingerprintProbe = { _, _ -> GatewayTlsProbeResult(fingerprintSha256 = "sha256:bb:bb:bb:bb") },
)
runtime.connect(
endpoint,
NodeRuntime.GatewayConnectAuth(token = "shared-token", bootstrapToken = null, password = null),
)
val prompt = waitForGatewayTrustPrompt(runtime)
assertEquals("aaaaaaaa", prompt.previousFingerprintSha256)
assertEquals("bbbbbbbb", prompt.fingerprintSha256)
assertEquals("sha256:aa:aa:aa:aa", prefs.loadGatewayTlsFingerprint(endpoint.stableId))
runtime.declineGatewayTrustPrompt()
assertEquals("sha256:aa:aa:aa:aa", prefs.loadGatewayTlsFingerprint(endpoint.stableId))
runtime.connect(
endpoint,
NodeRuntime.GatewayConnectAuth(token = "shared-token", bootstrapToken = null, password = null),
)
waitForGatewayTrustPrompt(runtime)
runtime.acceptGatewayTrustPrompt()
assertEquals("bbbbbbbb", prefs.loadGatewayTlsFingerprint(endpoint.stableId))
}
@Test
fun connect_ignoresStaleTlsProbeAfterDisconnect() =
runBlocking {
val app = RuntimeEnvironment.getApplication()
val securePrefs =
app.getSharedPreferences(
"openclaw.node.secure.test.${UUID.randomUUID()}",
android.content.Context.MODE_PRIVATE,
)
val prefs = SecurePrefs(app, securePrefsOverride = securePrefs)
val endpoint = GatewayEndpoint.manual(host = "gateway.example", port = 18789)
prefs.saveGatewayTlsFingerprint(endpoint.stableId, "aaaaaaaa")
val probeStarted = CompletableDeferred<Unit>()
val probeResult = CompletableDeferred<GatewayTlsProbeResult>()
val runtime =
NodeRuntime(
app,
prefs,
tlsFingerprintProbe = { _, _ ->
probeStarted.complete(Unit)
probeResult.await()
},
)
runtime.connect(
endpoint,
NodeRuntime.GatewayConnectAuth(token = "shared-token", bootstrapToken = null, password = null),
)
probeStarted.await()
runtime.disconnect()
probeResult.complete(GatewayTlsProbeResult(fingerprintSha256 = "aaaaaaaa"))
Thread.sleep(100)
assertNull(runtime.pendingGatewayTrust.value)
assertNull(desiredBootstrapToken(runtime, "nodeSession"))
assertEquals("aaaaaaaa", prefs.loadGatewayTlsFingerprint(endpoint.stableId))
assertEquals("fp-1", prefs.loadGatewayTlsFingerprint(endpoint.stableId))
assertEquals("setup-bootstrap-token", desiredBootstrapToken(runtime, "nodeSession"))
assertEquals("setup-bootstrap-token", desiredBootstrapToken(runtime, "operatorSession"))
}
@Test
@@ -363,21 +272,6 @@ class GatewayBootstrapAuthTest {
return readField(desired, "bootstrapToken")
}
private fun waitForDesiredBootstrapToken(
runtime: NodeRuntime,
sessionFieldName: String,
): String {
var lastObserved: String? = null
repeat(50) {
desiredBootstrapToken(runtime, sessionFieldName)?.let { token ->
lastObserved = token
return token
}
Thread.sleep(10)
}
error("Expected desired bootstrap token for $sessionFieldName; last observed=$lastObserved")
}
private fun <T> readField(
target: Any,
name: String,

View File

@@ -1,129 +0,0 @@
package ai.openclaw.app
import android.Manifest
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.app.ActivityOptionsCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class PermissionRequesterTest {
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun timedOutRequestCallbackDoesNotCompleteNextRequest() =
runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val launchers = mutableListOf<FakePermissionLauncher>()
val requester =
PermissionRequester(activity()) { callback ->
FakePermissionLauncher(callback).also { launchers += it }
}
try {
val first = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 10) }
runCurrent()
advanceTimeBy(11)
runCurrent()
assertTrue(first.isCompleted)
assertTrue(first.getCompletionExceptionOrNull() is TimeoutCancellationException)
assertEquals(listOf(listOf(Manifest.permission.CAMERA)), launchers[0].launches)
val second = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 1_000) }
runCurrent()
assertEquals(listOf(listOf(Manifest.permission.CAMERA)), launchers[1].launches)
launchers[0].deliver(mapOf(Manifest.permission.CAMERA to false))
runCurrent()
assertFalse(second.isCompleted)
launchers[1].deliver(mapOf(Manifest.permission.CAMERA to true))
runCurrent()
assertEquals(mapOf(Manifest.permission.CAMERA to true), second.await())
} finally {
Dispatchers.resetMain()
}
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun timedOutRequestWithoutCallbackDoesNotBlockNextRequest() =
runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val launchers = mutableListOf<FakePermissionLauncher>()
val requester =
PermissionRequester(activity()) { callback ->
FakePermissionLauncher(callback).also { launchers += it }
}
try {
val first = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 10) }
runCurrent()
advanceTimeBy(11)
runCurrent()
assertTrue(first.isCompleted)
assertTrue(first.getCompletionExceptionOrNull() is TimeoutCancellationException)
val second = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 1_000) }
runCurrent()
assertEquals(listOf(listOf(Manifest.permission.CAMERA)), launchers[1].launches)
launchers[1].deliver(mapOf(Manifest.permission.CAMERA to true))
runCurrent()
assertEquals(mapOf(Manifest.permission.CAMERA to true), second.await())
} finally {
Dispatchers.resetMain()
}
}
private fun activity(): ComponentActivity =
Robolectric
.buildActivity(ComponentActivity::class.java)
.setup()
.get()
}
private class FakePermissionLauncher(
private val callback: (Map<String, Boolean>) -> Unit,
) : ActivityResultLauncher<Array<String>>() {
val launches = mutableListOf<List<String>>()
override val contract: ActivityResultContract<Array<String>, *> = ActivityResultContracts.RequestMultiplePermissions()
override fun launch(
input: Array<String>,
options: ActivityOptionsCompat?,
) {
launches += input.toList()
}
override fun unregister() {}
fun deliver(result: Map<String, Boolean>) {
callback(result)
}
}

View File

@@ -1,356 +0,0 @@
package ai.openclaw.app.gateway
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.util.concurrent.ConcurrentLinkedQueue
private const val LIFECYCLE_TEST_TIMEOUT_MS = 8_000L
private const val LIFECYCLE_CONNECT_CHALLENGE_FRAME =
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}"""
private class ReconnectDeviceAuthStore : DeviceAuthTokenStore {
override fun loadEntry(
deviceId: String,
role: String,
): DeviceAuthEntry? = null
override fun saveToken(
deviceId: String,
role: String,
token: String,
scopes: List<String>,
) = Unit
override fun clearToken(
deviceId: String,
role: String,
) = Unit
}
private data class ReconnectHarness(
val session: GatewaySession,
val sessionJob: Job,
)
private data class ReconnectServer(
val server: MockWebServer,
val sockets: ConcurrentLinkedQueue<WebSocket>,
) {
val port: Int
get() = server.port
val requestCount: Int
get() = server.requestCount
fun shutdown() {
sockets.forEach { runCatching { it.cancel() } }
runCatching { server.shutdown() }
.onFailure { err ->
if (err.message != "Gave up waiting for queue to shut down") throw err
}
}
}
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class GatewaySessionReconnectTest {
@Test
fun connectToNewGatewayClosesActiveConnectionAndStartsReplacement() =
runBlocking {
val json = Json { ignoreUnknownKeys = true }
val firstConnect = CompletableDeferred<Unit>()
val firstClosed = CompletableDeferred<Unit>()
val secondConnect = CompletableDeferred<Unit>()
val secondClosed = CompletableDeferred<Unit>()
val firstServer =
startGatewayServer(
json = json,
onClosed = { firstClosed.complete(Unit) },
) { webSocket, id, method ->
if (method == "connect") {
firstConnect.complete(Unit)
webSocket.send(connectResponseFrame(id))
}
}
val secondServer =
startGatewayServer(
json = json,
onClosed = { secondClosed.complete(Unit) },
) { webSocket, id, method ->
if (method == "connect") {
secondConnect.complete(Unit)
webSocket.send(connectResponseFrame(id))
}
}
val harness = createReconnectHarness()
try {
connectNodeSession(harness.session, firstServer.port)
withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { firstConnect.await() }
connectNodeSession(harness.session, secondServer.port)
withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { firstClosed.await() }
withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { secondConnect.await() }
assertEquals(1, secondServer.requestCount)
harness.session.disconnect()
withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { secondClosed.await() }
} finally {
shutdownReconnectHarness(harness, firstServer, secondServer)
}
}
@Test
fun bootstrapNodePairingRequiredKeepsReconnectActive() {
val error =
GatewaySession.ErrorShape(
code = "NOT_PAIRED",
message = "pairing required",
details =
GatewayConnectErrorDetails(
code = "PAIRING_REQUIRED",
canRetryWithDeviceToken = false,
recommendedNextStep = "wait_then_retry",
pauseReconnect = false,
reason = "not-paired",
),
)
assertFalse(
shouldPauseGatewayReconnectAfterAuthFailure(
error = error,
hasBootstrapToken = true,
role = "node",
scopes = emptyList(),
deviceTokenRetryBudgetUsed = false,
pendingDeviceTokenRetry = false,
),
)
}
@Test
fun bootstrapNodePairingRequiredWithoutRetryHintPausesReconnect() {
val error =
GatewaySession.ErrorShape(
code = "NOT_PAIRED",
message = "pairing required",
details =
GatewayConnectErrorDetails(
code = "PAIRING_REQUIRED",
canRetryWithDeviceToken = false,
recommendedNextStep = null,
reason = "not-paired",
),
)
assertTrue(
shouldPauseGatewayReconnectAfterAuthFailure(
error = error,
hasBootstrapToken = true,
role = "node",
scopes = emptyList(),
deviceTokenRetryBudgetUsed = false,
pendingDeviceTokenRetry = false,
),
)
}
@Test
fun nonBootstrapPairingRequiredStillPausesReconnect() {
val error =
GatewaySession.ErrorShape(
code = "NOT_PAIRED",
message = "pairing required",
details =
GatewayConnectErrorDetails(
code = "PAIRING_REQUIRED",
canRetryWithDeviceToken = false,
recommendedNextStep = "wait_then_retry",
reason = "not-paired",
),
)
assertTrue(
shouldPauseGatewayReconnectAfterAuthFailure(
error = error,
hasBootstrapToken = false,
role = "node",
scopes = emptyList(),
deviceTokenRetryBudgetUsed = false,
pendingDeviceTokenRetry = false,
),
)
}
@Test
fun bootstrapRoleUpgradeStillPausesReconnect() {
val error =
GatewaySession.ErrorShape(
code = "NOT_PAIRED",
message = "pairing required",
details =
GatewayConnectErrorDetails(
code = "PAIRING_REQUIRED",
canRetryWithDeviceToken = false,
recommendedNextStep = null,
reason = "role-upgrade",
),
)
assertTrue(
shouldPauseGatewayReconnectAfterAuthFailure(
error = error,
hasBootstrapToken = true,
role = "node",
scopes = emptyList(),
deviceTokenRetryBudgetUsed = false,
pendingDeviceTokenRetry = false,
),
)
}
private fun createReconnectHarness(): ReconnectHarness {
val app = RuntimeEnvironment.getApplication()
val sessionJob = SupervisorJob()
val session =
GatewaySession(
scope = CoroutineScope(sessionJob + Dispatchers.Default),
identityStore = DeviceIdentityStore(app),
deviceAuthStore = ReconnectDeviceAuthStore(),
onConnected = { _, _, _ -> },
onDisconnected = { _ -> },
onEvent = { _, _ -> },
onInvoke = { GatewaySession.InvokeResult.ok("""{"handled":true}""") },
)
return ReconnectHarness(session = session, sessionJob = sessionJob)
}
private suspend fun connectNodeSession(
session: GatewaySession,
port: Int,
) {
session.connect(
endpoint =
GatewayEndpoint(
stableId = "manual|127.0.0.1|$port",
name = "test",
host = "127.0.0.1",
port = port,
tlsEnabled = false,
),
token = "test-token",
bootstrapToken = null,
password = null,
options =
GatewayConnectOptions(
role = "node",
scopes = listOf("node:invoke"),
caps = emptyList(),
commands = emptyList(),
permissions = emptyMap(),
client =
GatewayClientInfo(
id = "openclaw-android-test",
displayName = "Android Test",
version = "1.0.0-test",
platform = "android",
mode = "node",
instanceId = "android-test-instance",
deviceFamily = "android",
modelIdentifier = "test",
),
),
tls = null,
)
}
private suspend fun shutdownReconnectHarness(
harness: ReconnectHarness,
vararg servers: ReconnectServer,
) {
harness.session.disconnect()
harness.sessionJob.cancelAndJoin()
servers.forEach { it.shutdown() }
}
private fun connectResponseFrame(id: String): String = """{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}"""
private fun startGatewayServer(
json: Json,
onClosed: () -> Unit = {},
onRequestFrame: (webSocket: WebSocket, id: String, method: String) -> Unit,
): ReconnectServer {
val sockets = ConcurrentLinkedQueue<WebSocket>()
val server =
MockWebServer().apply {
dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse =
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) {
sockets += webSocket
webSocket.send(LIFECYCLE_CONNECT_CHALLENGE_FRAME)
}
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val frame = json.parseToJsonElement(text).jsonObject
if (frame["type"]?.jsonPrimitive?.content != "req") return
val id = frame["id"]?.jsonPrimitive?.content ?: return
val method = frame["method"]?.jsonPrimitive?.content ?: return
onRequestFrame(webSocket, id, method)
}
override fun onClosing(
webSocket: WebSocket,
code: Int,
reason: String,
) {
onClosed()
}
override fun onClosed(
webSocket: WebSocket,
code: Int,
reason: String,
) {
onClosed()
}
},
)
}
start()
}
return ReconnectServer(server = server, sockets = sockets)
}
}

View File

@@ -44,27 +44,4 @@ class JpegSizeLimiterTest {
assertEquals(600, result.height)
assertEquals(90, result.quality)
}
@Test
fun triesFinalScaledImageBeforeFailing() {
val result =
JpegSizeLimiter.compressToLimit(
initialWidth = 1000,
initialHeight = 800,
startQuality = 90,
maxBytes = 100,
minSize = 1,
scaleStep = 0.5,
maxScaleAttempts = 1,
maxQualityAttempts = 1,
encode = { width, _, _ ->
if (width == 500) ByteArray(80) else ByteArray(120)
},
)
assertEquals(500, result.width)
assertEquals(400, result.height)
assertEquals(90, result.quality)
assertEquals(80, result.bytes.size)
}
}

View File

@@ -268,14 +268,6 @@ class GatewayConfigResolverTest {
assertEquals(GatewayEndpointValidationError.INSECURE_REMOTE_URL, parsed.error)
}
@Test
fun parseGatewayEndpointResultRejectsUnsupportedSchemes() {
val parsed = parseGatewayEndpointResult("ftp://gateway.example:21")
assertNull(parsed.config)
assertEquals(GatewayEndpointValidationError.INVALID_URL, parsed.error)
}
@Test
fun parseGatewayEndpointResultFlagsInsecureLanCleartextGateway() {
val parsed = parseGatewayEndpointResult("ws://192.168.1.20:18789")

View File

@@ -1,75 +0,0 @@
package ai.openclaw.app.ui.chat
import androidx.compose.ui.text.LinkAnnotation
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatMarkdownTest {
@Test
fun bareUrlsCarryClickableUrlAnnotations() {
val url = "https://www.amazon.it/GAZEBO-CANOPY-ACCIAIO-BIANCO-IMPERMEABILE/dp/B01G5R9FCK"
val annotated = buildChatInlineMarkdown("Open $url")
assertEquals("Open $url", annotated.text)
val links = annotated.getLinkAnnotations(0, annotated.length)
assertEquals(1, links.size)
assertEquals(5, links.single().start)
assertEquals(5 + url.length, links.single().end)
assertEquals(url, (links.single().item as LinkAnnotation.Url).url)
}
@Test
fun markdownLinksUseLabelTextAndDestinationUrl() {
val annotated = buildChatInlineMarkdown("Open [docs](https://docs.openclaw.ai/help/testing) now")
assertEquals("Open docs now", annotated.text)
val links = annotated.getLinkAnnotations(0, annotated.length)
assertEquals(1, links.size)
assertEquals(5, links.single().start)
assertEquals(9, links.single().end)
assertEquals("https://docs.openclaw.ai/help/testing", (links.single().item as LinkAnnotation.Url).url)
}
@Test
fun markdownLinksDropUnsafeDestinations() {
listOf(
"intent://example/#Intent;scheme=openclaw;end",
"file:///sdcard/Download/x",
"content://downloads/public_downloads/1",
"tel:+15551234567",
"javascript:alert(1)",
).forEach { destination ->
val annotated = buildChatInlineMarkdown("Open [settings]($destination)")
assertEquals("Open settings", annotated.text)
assertTrue(annotated.getLinkAnnotations(0, annotated.length).isEmpty())
}
}
@Test
fun plainTextDoesNotAddLinkAnnotations() {
val annotated = buildChatInlineMarkdown("No link here")
assertEquals("No link here", annotated.text)
assertTrue(annotated.getLinkAnnotations(0, annotated.length).isEmpty())
}
@Test
fun parseDataImageDestinationAcceptsBoundedPayloads() {
val parsed = parseDataImageDestination("data:image/png;base64,QUJD")
assertEquals(ParsedDataImage(mimeType = "image/png", base64 = "QUJD"), parsed)
}
@Test
fun parseDataImageDestinationRejectsOversizedPayloads() {
val oversized = "A".repeat(CHAT_IMAGE_MAX_BASE64_CHARS + 1)
val parsed = parseDataImageDestination("data:image/png;base64,$oversized")
assertNull(parsed)
}
}

View File

@@ -1,263 +0,0 @@
package ai.openclaw.app.voice
import android.Manifest
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class MicCaptureManagerTest {
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun transcriptionFinalQueuesGatewayMessage() =
runTest {
val sentMessages = mutableListOf<String>()
val manager =
createManager(
scope = this,
sendToGateway = { message, onRunIdKnown ->
sentMessages += message
onRunIdKnown("run-1")
null
},
)
setPrivateField(manager, "transcriptionSessionId", "transcription-1")
manager.onGatewayConnectionChanged(true)
manager.handleGatewayEvent(
"talk.event",
"""{"transcriptionSessionId":"transcription-1","type":"partial","text":"hello"}""",
)
manager.handleGatewayEvent(
"talk.event",
"""{"transcriptionSessionId":"transcription-1","type":"transcript","text":"hello world","final":true}""",
)
runCurrent()
manager.handleGatewayEvent("chat", chatFinalPayload(runId = "run-1", text = "reply"))
advanceUntilIdle()
assertNull(manager.liveTranscript.value)
assertEquals(listOf("hello world"), sentMessages)
val conversation = manager.conversation.value.first()
assertEquals(VoiceConversationRole.User, conversation.role)
assertEquals("hello world", conversation.text)
}
@Test
fun transcriptionErrorDisablesMic() {
val manager = createManager()
setPrivateField(manager, "transcriptionSessionId", "transcription-1")
manager.handleGatewayEvent(
"talk.event",
"""{"transcriptionSessionId":"transcription-1","type":"error","message":"provider unavailable"}""",
)
assertEquals(false, manager.micEnabled.value)
assertEquals("Transcription failed: provider unavailable", manager.statusText.value)
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun punctuationOnlyTranscriptDoesNotSendTurn() =
runTest {
val sentMessages = mutableListOf<String>()
val manager =
createManager(
scope = this,
sendToGateway = { message, onRunIdKnown ->
sentMessages += message
onRunIdKnown("run-1")
"run-1"
},
)
setPrivateField(manager, "transcriptionSessionId", "transcription-1")
manager.onGatewayConnectionChanged(true)
manager.handleGatewayEvent(
"talk.event",
"""{"transcriptionSessionId":"transcription-1","type":"transcript","text":".","final":true}""",
)
advanceUntilIdle()
assertEquals(emptyList<String>(), sentMessages)
assertEquals(emptyList<VoiceConversationEntry>(), manager.conversation.value)
}
@Test
fun pcm16FramesAreEncodedAsPcmuFrames() {
val manager = createManager()
val method = manager.javaClass.getDeclaredMethod("pcm16ToPcmu", ByteArray::class.java)
method.isAccessible = true
val encoded = method.invoke(manager, byteArrayOf(0, 0, 0, 0)) as ByteArray
assertEquals(2, encoded.size)
assertEquals(0xff.toByte(), encoded[0])
assertEquals(0xff.toByte(), encoded[1])
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun disablingMicDuringSessionCreateClosesReturnedSession() =
runTest {
val createdSession = CompletableDeferred<String>()
val closedSessions = mutableListOf<String>()
val manager =
createManager(
scope = this,
createTranscriptionSession = { createdSession.await() },
closeTranscriptionSession = { sessionId -> closedSessions += sessionId },
)
manager.onGatewayConnectionChanged(true)
manager.setMicEnabled(true)
manager.setMicEnabled(false)
createdSession.complete("transcription-1")
advanceUntilIdle()
assertEquals(listOf("transcription-1"), closedSessions)
assertEquals(false, manager.isListening.value)
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun disablingMicKeepsSessionOpenForFinalTranscript() =
runTest {
val manager = createManager(scope = this)
setPrivateMutableStateFlowValue(manager, "_micEnabled", true)
setPrivateField(manager, "transcriptionSessionId", "transcription-1")
manager.setMicEnabled(false)
manager.handleGatewayEvent(
"talk.event",
"""{"transcriptionSessionId":"transcription-1","type":"transcript","text":"testing testing 1 2 3","final":true}""",
)
runCurrent()
assertEquals(
"testing testing 1 2 3",
manager.conversation.value
.single()
.text,
)
assertEquals("transcription-1", privateField<String?>(manager, "transcriptionSessionId"))
privateField<Job?>(manager, "transcriptionDrainJob")?.cancel()
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun reconnectRestartsAfterPendingCreateCancellation() =
runTest {
val firstCreate = CompletableDeferred<String>()
val secondCreate = CompletableDeferred<String>()
var createCalls = 0
val manager =
createManager(
scope = this,
createTranscriptionSession = {
createCalls += 1
if (createCalls == 1) firstCreate.await() else secondCreate.await()
},
)
manager.onGatewayConnectionChanged(true)
manager.setMicEnabled(true)
runCurrent()
manager.onGatewayConnectionChanged(false)
manager.onGatewayConnectionChanged(true)
firstCreate.completeExceptionally(CancellationException("connection closed"))
runCurrent()
assertEquals(2, createCalls)
assertEquals(true, manager.micEnabled.value)
manager.setMicEnabled(false)
secondCreate.completeExceptionally(CancellationException("test complete"))
runCurrent()
}
private fun createManager(
scope: CoroutineScope = CoroutineScope(Dispatchers.Unconfined),
createTranscriptionSession: suspend () -> String = { "transcription-1" },
closeTranscriptionSession: suspend (String) -> Unit = { _ -> },
sendToGateway: suspend (String, (String) -> Unit) -> String? = { _, onRunIdKnown ->
onRunIdKnown("run-1")
"run-1"
},
): MicCaptureManager =
MicCaptureManager(
context =
RuntimeEnvironment.getApplication().also { app ->
shadowOf(app).grantPermissions(Manifest.permission.RECORD_AUDIO)
},
scope = scope,
createTranscriptionSession = createTranscriptionSession,
appendTranscriptionAudio = { _, _, _ -> },
closeTranscriptionSession = closeTranscriptionSession,
sendToGateway = sendToGateway,
)
private fun setPrivateField(
target: Any,
name: String,
value: Any?,
) {
val field = target.javaClass.getDeclaredField(name)
field.isAccessible = true
field.set(target, value)
}
@Suppress("UNCHECKED_CAST")
private fun setPrivateMutableStateFlowValue(
target: Any,
name: String,
value: Boolean,
) {
val field = target.javaClass.getDeclaredField(name)
field.isAccessible = true
(field.get(target) as MutableStateFlow<Boolean>).value = value
}
@Suppress("UNCHECKED_CAST")
private fun <T> privateField(
target: Any,
name: String,
): T {
val field = target.javaClass.getDeclaredField(name)
field.isAccessible = true
return field.get(target) as T
}
private fun chatFinalPayload(
runId: String,
text: String,
): String =
"""
{
"runId": "$runId",
"state": "final",
"message": {
"role": "assistant",
"content": [
{ "type": "text", "text": "$text" }
]
}
}
""".trimIndent()
}

View File

@@ -62,37 +62,4 @@ class TalkModeConfigParsingTest {
TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(talk),
)
}
@Test
fun defaultsToNativeTalkMode() {
val talk =
buildJsonObject {
put("realtime", buildJsonObject { put("transport", "webrtc") })
}
assertEquals(
TalkModeExecutionMode.Native,
TalkModeGatewayConfigParser.resolvedExecutionMode(talk),
)
}
@Test
fun usesRealtimeRelayWhenGatewayRelayIsConfigured() {
val talk =
buildJsonObject {
put(
"realtime",
buildJsonObject {
put("mode", "realtime")
put("transport", "gateway-relay")
put("brain", "agent-consult")
},
)
}
assertEquals(
TalkModeExecutionMode.RealtimeRelay,
TalkModeGatewayConfigParser.resolvedExecutionMode(talk),
)
}
}

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