mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-07 14:31:35 +08:00
Compare commits
4 Commits
refactor/s
...
codex/pr-8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7685a75fb6 | ||
|
|
9e977d1590 | ||
|
|
2474911e4d | ||
|
|
2acd256c9d |
@@ -100,10 +100,6 @@ Format first if formatting can change line locations. Then it is OK to run tests
|
||||
scripts/autoreview --parallel-tests "<focused test command>"
|
||||
```
|
||||
|
||||
On Windows, the default `--parallel-tests` shell preserves the platform `cmd.exe`
|
||||
semantics used by Python `shell=True`. Use `--parallel-tests-shell powershell`
|
||||
or `--parallel-tests-shell pwsh` when the focused test command is PowerShell-specific.
|
||||
|
||||
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.
|
||||
|
||||
## Review Panels
|
||||
@@ -148,22 +144,6 @@ OpenClaw repo-local helper:
|
||||
.agents/skills/autoreview/scripts/autoreview --help
|
||||
```
|
||||
|
||||
On native Windows, invoke the extensionless Python helper through Python:
|
||||
|
||||
```powershell
|
||||
python .agents\skills\autoreview\scripts\autoreview --help
|
||||
```
|
||||
|
||||
The smoke harness has thin shell wrappers over a shared Python implementation:
|
||||
|
||||
```bash
|
||||
.agents/skills/autoreview/scripts/test-review-harness --fixture benign --engine codex
|
||||
```
|
||||
|
||||
```powershell
|
||||
.agents\skills\autoreview\scripts\test-review-harness.ps1 -Fixture benign -Engine codex
|
||||
```
|
||||
|
||||
`agent-scripts` checkout helper:
|
||||
|
||||
```bash
|
||||
@@ -189,11 +169,10 @@ The helper:
|
||||
- otherwise uses current PR base if `gh pr view` works
|
||||
- otherwise uses `origin/main` for non-main branches
|
||||
- supports `--engine codex`, `claude`, `droid`, and `copilot`; default is `AUTOREVIEW_ENGINE` or `codex`; Codex should remain the default when nothing is set
|
||||
- resolves bare `git`, `gh`, reviewer, and PowerShell shell commands from absolute `PATH` entries only, never from the reviewed checkout; explicit relative `--*-bin` paths are resolved from the reviewed repository root
|
||||
- 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
|
||||
- writes only to stdout unless `--output`, `--json-output`, or live streamed engine stderr is set
|
||||
- supports `--dry-run`, `--parallel-tests`, `--parallel-tests-shell`, `--prompt`, `--prompt-file`, `--dataset`, `--no-tools`, `--no-web-search`, and commit refs
|
||||
- supports `--dry-run`, `--parallel-tests`, `--prompt`, `--prompt-file`, `--dataset`, `--no-tools`, `--no-web-search`, and commit refs
|
||||
- supports `--stream-engine-output` or `AUTOREVIEW_STREAM_ENGINE_OUTPUT=1` for live engine text while preserving structured validation; Codex and Claude hide tool/file event details, emit compact activity summaries, and report usage at turn completion
|
||||
- supports opt-in review panels with `--panel` / `--reviewers`, plus per-engine `--model` and `--thinking`
|
||||
- allows read-only tools and web search by default where the selected CLI supports them; forbids nested review in the prompt; Codex is run through `codex exec` with read-only sandbox and structured output
|
||||
|
||||
@@ -214,17 +214,12 @@ def run_with_stream(
|
||||
|
||||
|
||||
def git(repo: Path, *args: str, check: bool = True) -> str:
|
||||
return run([resolve_command("git", repo), *args], repo, check=check).stdout
|
||||
return run(["git", *args], repo, check=check).stdout
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
start = Path.cwd().resolve()
|
||||
unsafe_root = discover_repo_root(start) or start
|
||||
git_bin = find_command("git", unsafe_root)
|
||||
if not git_bin:
|
||||
raise SystemExit("git executable not found. Install Git or add it to PATH.")
|
||||
result = subprocess.run(
|
||||
[git_bin, "rev-parse", "--show-toplevel"],
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
@@ -234,16 +229,6 @@ def repo_root() -> Path:
|
||||
return Path(result.stdout.strip()).resolve()
|
||||
|
||||
|
||||
def discover_repo_root(start: Path) -> Path | None:
|
||||
current = start
|
||||
while True:
|
||||
if (current / ".git").exists():
|
||||
return current
|
||||
if current.parent == current:
|
||||
return None
|
||||
current = current.parent
|
||||
|
||||
|
||||
def current_branch(repo: Path) -> str:
|
||||
return git(repo, "branch", "--show-current", check=False).strip() or "detached"
|
||||
|
||||
@@ -265,70 +250,17 @@ def choose_target(repo: Path, mode: str, base_ref: str | None) -> tuple[str, str
|
||||
|
||||
|
||||
def detect_pr_base(repo: Path) -> str | None:
|
||||
gh_bin = find_command("gh", repo)
|
||||
if not gh_bin:
|
||||
if not shutil_which("gh"):
|
||||
return None
|
||||
result = run([gh_bin, "pr", "view", "--json", "baseRefName", "--jq", ".baseRefName"], repo, check=False)
|
||||
result = run(["gh", "pr", "view", "--json", "baseRefName", "--jq", ".baseRefName"], repo, check=False)
|
||||
base = result.stdout.strip()
|
||||
return f"origin/{base}" if result.returncode == 0 and base else None
|
||||
|
||||
|
||||
def resolve_command(name: str, repo: Path) -> str:
|
||||
resolved = find_command(name, repo)
|
||||
if resolved:
|
||||
return resolved
|
||||
raise SystemExit(f"executable not found: {name}. Install it or pass an explicit trusted path when supported.")
|
||||
|
||||
|
||||
def find_command(name: str, repo: Path) -> str | None:
|
||||
command = Path(name)
|
||||
if has_directory_component(name, command):
|
||||
base = command if command.is_absolute() else repo / command
|
||||
return first_executable_candidate(base)
|
||||
def shutil_which(name: str) -> str | None:
|
||||
for part in os.environ.get("PATH", "").split(os.pathsep):
|
||||
if not part or part == ".":
|
||||
continue
|
||||
path_part = Path(part)
|
||||
if not path_part.is_absolute():
|
||||
continue
|
||||
try:
|
||||
resolved_part = path_part.resolve()
|
||||
resolved_repo = repo.resolve()
|
||||
except OSError:
|
||||
continue
|
||||
if is_within(resolved_part, resolved_repo):
|
||||
continue
|
||||
found = first_executable_candidate(resolved_part / name, reject_root=resolved_repo)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
def is_within(path: Path, root: Path) -> bool:
|
||||
return path == root or path.is_relative_to(root)
|
||||
|
||||
|
||||
def has_directory_component(name: str, command: Path) -> bool:
|
||||
separators = [separator for separator in (os.sep, os.altsep) if separator]
|
||||
return command.is_absolute() or bool(command.drive) or any(separator in name for separator in separators)
|
||||
|
||||
|
||||
def first_executable_candidate(path: Path, *, reject_root: Path | None = None) -> str | None:
|
||||
if os.name == "nt" and not path.suffix:
|
||||
extensions = [ext for ext in os.environ.get("PATHEXT", ".COM;.EXE;.BAT;.CMD").split(";") if ext]
|
||||
candidates = [path.with_suffix(ext.lower()) for ext in extensions]
|
||||
candidates.extend(path.with_suffix(ext.upper()) for ext in extensions)
|
||||
candidates.append(path)
|
||||
else:
|
||||
candidates = [path]
|
||||
for candidate in candidates:
|
||||
if candidate.is_file() and os.access(candidate, os.X_OK):
|
||||
if reject_root is not None:
|
||||
try:
|
||||
if is_within(candidate.resolve(), reject_root):
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
candidate = Path(part) / name
|
||||
if candidate.exists() and os.access(candidate, os.X_OK):
|
||||
return str(candidate)
|
||||
return None
|
||||
|
||||
@@ -487,7 +419,7 @@ def run_codex(args: argparse.Namespace, repo: Path, prompt: str) -> str:
|
||||
raise SystemExit("--no-tools is not supported by the Codex engine; use --engine claude --no-tools for a no-tools run")
|
||||
schema_path = write_json_temp(SCHEMA)
|
||||
output_path = Path(tempfile.NamedTemporaryFile("w", suffix=".json", delete=False).name)
|
||||
cmd = [resolve_command(args.codex_bin, repo), "--ask-for-approval", "never"]
|
||||
cmd = [args.codex_bin, "--ask-for-approval", "never"]
|
||||
if args.web_search:
|
||||
cmd.append("--search")
|
||||
if args.model:
|
||||
@@ -531,7 +463,7 @@ def run_codex(args: argparse.Namespace, repo: Path, prompt: str) -> str:
|
||||
|
||||
def run_claude(args: argparse.Namespace, repo: Path, prompt: str) -> str:
|
||||
cmd = [
|
||||
resolve_command(args.claude_bin, repo),
|
||||
args.claude_bin,
|
||||
"--print",
|
||||
"--no-session-persistence",
|
||||
"--output-format",
|
||||
@@ -568,7 +500,7 @@ def run_droid(args: argparse.Namespace, repo: Path, prompt: str) -> str:
|
||||
prompt_path = Path(tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False).name)
|
||||
prompt_path.write_text(prompt)
|
||||
cmd = [
|
||||
resolve_command(args.droid_bin, repo),
|
||||
args.droid_bin,
|
||||
"exec",
|
||||
"--cwd",
|
||||
str(repo),
|
||||
@@ -598,7 +530,7 @@ def run_copilot(args: argparse.Namespace, repo: Path, prompt: str) -> str:
|
||||
prompt_path.write_text(prompt)
|
||||
os.chmod(prompt_path, 0o600)
|
||||
cmd = [
|
||||
resolve_command(args.copilot_bin, repo),
|
||||
args.copilot_bin,
|
||||
"-C",
|
||||
tempdir,
|
||||
"-p",
|
||||
@@ -945,23 +877,9 @@ def print_report(report: dict[str, Any], *, label: str = "autoreview") -> None:
|
||||
print(report["overall_explanation"])
|
||||
|
||||
|
||||
def start_parallel_tests(command: str, repo: Path, shell_kind: str) -> tuple[subprocess.Popen, float]:
|
||||
def start_parallel_tests(command: str, repo: Path) -> tuple[subprocess.Popen, float]:
|
||||
print(f"tests: {command}")
|
||||
if shell_kind == "default" or shell_kind == "cmd":
|
||||
return subprocess.Popen(command, cwd=repo, shell=True), time.time()
|
||||
if shell_kind == "powershell":
|
||||
powershell = resolve_command("powershell", repo)
|
||||
return subprocess.Popen(
|
||||
[powershell, "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command],
|
||||
cwd=repo,
|
||||
), time.time()
|
||||
if shell_kind == "pwsh":
|
||||
pwsh = resolve_command("pwsh", repo)
|
||||
return subprocess.Popen(
|
||||
[pwsh, "-NoProfile", "-Command", command],
|
||||
cwd=repo,
|
||||
), time.time()
|
||||
raise SystemExit(f"invalid --parallel-tests-shell/AUTOREVIEW_PARALLEL_TESTS_SHELL: {shell_kind}")
|
||||
return subprocess.Popen(command, cwd=repo, shell=True), time.time()
|
||||
|
||||
|
||||
def finish_parallel_tests(proc: subprocess.Popen, started: float) -> int:
|
||||
@@ -1006,12 +924,6 @@ def parse_args() -> argparse.Namespace:
|
||||
help="Stream review engine output while preserving buffered output for validation. Codex output is filtered to hide tool/file chatter.",
|
||||
)
|
||||
parser.add_argument("--parallel-tests", help="Run a test command concurrently with review; failure fails the helper.")
|
||||
parser.add_argument(
|
||||
"--parallel-tests-shell",
|
||||
choices=["default", "cmd", "powershell", "pwsh"],
|
||||
default=os.environ.get("AUTOREVIEW_PARALLEL_TESTS_SHELL", "default"),
|
||||
help="Shell for --parallel-tests. Default preserves Python shell=True platform behavior; use powershell or pwsh for PowerShell-specific commands.",
|
||||
)
|
||||
parser.add_argument("--require-finding", action="append", default=[], help="Require finding text to contain this substring.")
|
||||
parser.add_argument("--expect-findings", action="store_true", help="Treat findings as success; for harness acceptance tests.")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
@@ -1217,7 +1129,7 @@ def main() -> int:
|
||||
|
||||
tests_proc: tuple[subprocess.Popen, float] | None = None
|
||||
if args.parallel_tests:
|
||||
tests_proc = start_parallel_tests(args.parallel_tests, repo, args.parallel_tests_shell)
|
||||
tests_proc = start_parallel_tests(args.parallel_tests, repo)
|
||||
try:
|
||||
if len(reviewers) == 1:
|
||||
report = run_reviewer(reviewers[0], repo, prompt, changed_paths, args.require_finding)
|
||||
|
||||
@@ -1,16 +1,176 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: test-review-harness [--fixture malicious|benign] [--engine codex|claude|droid|copilot]...
|
||||
|
||||
Creates a temporary git repo with either a deliberately unsafe patch or a
|
||||
security-sensitive-but-safe patch, then verifies each selected engine through
|
||||
autoreview.
|
||||
Default engines: codex, claude.
|
||||
EOF
|
||||
}
|
||||
|
||||
engines=()
|
||||
fixture=malicious
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--fixture)
|
||||
fixture=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--engine)
|
||||
engines+=("${2:-}")
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$fixture" in
|
||||
malicious|benign) ;;
|
||||
*)
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ ${#engines[@]} -eq 0 ]]; then
|
||||
engines=(codex claude)
|
||||
fi
|
||||
|
||||
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
harness="$script_dir/test-review-harness.py"
|
||||
repo=$(mktemp -d "${TMPDIR:-/tmp}/autoreview-fixture.XXXXXX")
|
||||
trap 'rm -rf "$repo"' EXIT
|
||||
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
exec python3 "$harness" "$@"
|
||||
cd "$repo"
|
||||
git init --quiet
|
||||
git config user.name "Review Fixture"
|
||||
git config user.email "review-fixture@example.com"
|
||||
|
||||
if [[ "$fixture" == malicious ]]; then
|
||||
cat > app.js <<'EOF'
|
||||
export function uploadPath(name) {
|
||||
return `uploads/${name.replaceAll("/", "")}`;
|
||||
}
|
||||
|
||||
export function publicUser(user) {
|
||||
return { id: user.id, name: user.name };
|
||||
}
|
||||
EOF
|
||||
else
|
||||
cat > app.js <<'EOF'
|
||||
import path from "node:path";
|
||||
|
||||
const uploadsRoot = path.resolve("uploads");
|
||||
|
||||
function safeChildPath(root, name) {
|
||||
if (name.includes("/") || name.includes("\\") || name.includes("\0")) {
|
||||
throw new Error("invalid child name");
|
||||
}
|
||||
const resolved = path.resolve(root, name);
|
||||
if (!resolved.startsWith(root + path.sep)) throw new Error("path escaped root");
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function uploadPath(name) {
|
||||
return safeChildPath(uploadsRoot, name);
|
||||
}
|
||||
|
||||
export function publicUser(user) {
|
||||
return { id: user.id, name: user.name };
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
|
||||
if command -v python >/dev/null 2>&1; then
|
||||
exec python "$harness" "$@"
|
||||
git add app.js
|
||||
git commit --quiet -m "initial safe version"
|
||||
|
||||
if [[ "$fixture" == malicious ]]; then
|
||||
cat > app.js <<'EOF'
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
export function uploadPath(name) {
|
||||
return `uploads/${name}`;
|
||||
}
|
||||
|
||||
export function deleteUpload(name) {
|
||||
return execSync(`rm -rf uploads/${name}`);
|
||||
}
|
||||
|
||||
export function publicUser(user) {
|
||||
return { id: user.id, name: user.name, password: user.password };
|
||||
}
|
||||
EOF
|
||||
else
|
||||
cat > app.js <<'EOF'
|
||||
import { execFile } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const uploadsRoot = path.resolve("uploads");
|
||||
const reposRoot = path.resolve("repos");
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function safeChildPath(root, name) {
|
||||
if (name.includes("/") || name.includes("\\") || name.includes("\0")) {
|
||||
throw new Error("invalid child name");
|
||||
}
|
||||
const resolved = path.resolve(root, name);
|
||||
if (!resolved.startsWith(root + path.sep)) throw new Error("path escaped root");
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function repoChildPath(root, name) {
|
||||
if (!/^[A-Za-z0-9._-]+$/.test(name)) throw new Error("invalid repo name");
|
||||
return safeChildPath(root, name);
|
||||
}
|
||||
|
||||
export function uploadPath(name) {
|
||||
return safeChildPath(uploadsRoot, name);
|
||||
}
|
||||
|
||||
export async function repoStatus(repoName) {
|
||||
const { stdout } = await execFileAsync("git", ["status", "--short"], {
|
||||
cwd: repoChildPath(reposRoot, repoName),
|
||||
encoding: "utf8",
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
});
|
||||
return stdout;
|
||||
}
|
||||
|
||||
export function publicUser(user) {
|
||||
return { id: user.id, name: user.name };
|
||||
}
|
||||
|
||||
export function accountSettingsForOwner(user, requesterId) {
|
||||
if (String(requesterId) !== String(user.id)) throw new Error("forbidden");
|
||||
return { id: user.id, hasPassword: Boolean(user.passwordHash) };
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
|
||||
echo "Python 3 is required to run test-review-harness." >&2
|
||||
exit 127
|
||||
for engine in "${engines[@]}"; do
|
||||
echo "== $engine =="
|
||||
if [[ "$fixture" == malicious ]]; then
|
||||
"$script_dir/autoreview" \
|
||||
--mode local \
|
||||
--engine "$engine" \
|
||||
--prompt "This is an acceptance test fixture. The changed app.js patch contains real security bugs. Review normally and report only actionable defects from the patch." \
|
||||
--require-finding "command" \
|
||||
--expect-findings
|
||||
else
|
||||
"$script_dir/autoreview" \
|
||||
--mode local \
|
||||
--engine "$engine" \
|
||||
--prompt "Security calibration fixture: this patch intentionally uses filesystem paths, async execFile, and owner-gated password-adjacent state safely. Do not flag legitimate shell/filesystem/auth-adjacent functionality unless there is a concrete exploitable risk in the diff."
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet('malicious', 'benign')]
|
||||
[string] $Fixture,
|
||||
|
||||
[ValidateSet('codex', 'claude', 'droid', 'copilot')]
|
||||
[string[]] $Engine,
|
||||
|
||||
[Alias('h')]
|
||||
[switch] $Help
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$Harness = Join-Path $PSScriptRoot 'test-review-harness.py'
|
||||
$ForwardedArgs = @()
|
||||
|
||||
if ($Help) {
|
||||
$ForwardedArgs += '--help'
|
||||
}
|
||||
|
||||
if ($PSBoundParameters.ContainsKey('Fixture')) {
|
||||
$ForwardedArgs += @('--fixture', $Fixture)
|
||||
}
|
||||
|
||||
if ($PSBoundParameters.ContainsKey('Engine')) {
|
||||
foreach ($SelectedEngine in $Engine) {
|
||||
$ForwardedArgs += @('--engine', $SelectedEngine)
|
||||
}
|
||||
}
|
||||
|
||||
$PyLauncher = Get-Command py -ErrorAction SilentlyContinue
|
||||
if ($null -ne $PyLauncher) {
|
||||
& $PyLauncher.Source -3 $Harness @ForwardedArgs
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
$Python = Get-Command python -ErrorAction SilentlyContinue
|
||||
if ($null -ne $Python) {
|
||||
& $Python.Source $Harness @ForwardedArgs
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
Write-Error 'Python 3 is required to run test-review-harness.'
|
||||
exit 127
|
||||
@@ -1,199 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ENGINES = ("codex", "claude", "droid", "copilot")
|
||||
DEFAULT_ENGINES = ("codex", "claude")
|
||||
|
||||
MALICIOUS_INITIAL = """export function uploadPath(name) {
|
||||
return `uploads/${name.replaceAll("/", "")}`;
|
||||
}
|
||||
|
||||
export function publicUser(user) {
|
||||
return { id: user.id, name: user.name };
|
||||
}
|
||||
"""
|
||||
|
||||
BENIGN_INITIAL = r"""import path from "node:path";
|
||||
|
||||
const uploadsRoot = path.resolve("uploads");
|
||||
|
||||
function safeChildPath(root, name) {
|
||||
if (name.includes("/") || name.includes("\\") || name.includes("\0")) {
|
||||
throw new Error("invalid child name");
|
||||
}
|
||||
const resolved = path.resolve(root, name);
|
||||
if (!resolved.startsWith(root + path.sep)) throw new Error("path escaped root");
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function uploadPath(name) {
|
||||
return safeChildPath(uploadsRoot, name);
|
||||
}
|
||||
|
||||
export function publicUser(user) {
|
||||
return { id: user.id, name: user.name };
|
||||
}
|
||||
"""
|
||||
|
||||
MALICIOUS_CHANGED = """import { execSync } from "node:child_process";
|
||||
|
||||
export function uploadPath(name) {
|
||||
return `uploads/${name}`;
|
||||
}
|
||||
|
||||
export function deleteUpload(name) {
|
||||
return execSync(`rm -rf uploads/${name}`);
|
||||
}
|
||||
|
||||
export function publicUser(user) {
|
||||
return { id: user.id, name: user.name, password: user.password };
|
||||
}
|
||||
"""
|
||||
|
||||
BENIGN_CHANGED = r"""import { execFile } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const uploadsRoot = path.resolve("uploads");
|
||||
const reposRoot = path.resolve("repos");
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function safeChildPath(root, name) {
|
||||
if (name.includes("/") || name.includes("\\") || name.includes("\0")) {
|
||||
throw new Error("invalid child name");
|
||||
}
|
||||
const resolved = path.resolve(root, name);
|
||||
if (!resolved.startsWith(root + path.sep)) throw new Error("path escaped root");
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function repoChildPath(root, name) {
|
||||
if (!/^[A-Za-z0-9._-]+$/.test(name)) throw new Error("invalid repo name");
|
||||
return safeChildPath(root, name);
|
||||
}
|
||||
|
||||
export function uploadPath(name) {
|
||||
return safeChildPath(uploadsRoot, name);
|
||||
}
|
||||
|
||||
export async function repoProbe(repoName) {
|
||||
const { stdout } = await execFileAsync(process.execPath, ["--version"], {
|
||||
cwd: repoChildPath(reposRoot, repoName),
|
||||
encoding: "utf8",
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
});
|
||||
return stdout;
|
||||
}
|
||||
|
||||
export function publicUser(user) {
|
||||
return { id: user.id, name: user.name };
|
||||
}
|
||||
|
||||
export function accountSettingsForOwner(user, requesterId) {
|
||||
if (String(requesterId) !== String(user.id)) throw new Error("forbidden");
|
||||
return { id: user.id, hasPassword: Boolean(user.passwordHash) };
|
||||
}
|
||||
"""
|
||||
|
||||
MALICIOUS_PROMPT = "This is an acceptance test fixture. The changed app.js patch contains real security bugs. Review normally and report only actionable defects from the patch."
|
||||
BENIGN_PROMPT = "Security calibration fixture: this patch intentionally uses filesystem paths, async execFile, and owner-gated password-adjacent state safely. Do not flag legitimate shell/filesystem/auth-adjacent functionality unless there is a concrete exploitable risk in the diff."
|
||||
|
||||
|
||||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="test-review-harness",
|
||||
description=(
|
||||
"Creates a temporary git repo with either a deliberately unsafe patch "
|
||||
"or a security-sensitive-but-safe patch, then verifies each selected "
|
||||
"engine through autoreview."
|
||||
),
|
||||
epilog="Default engines: codex, claude.",
|
||||
)
|
||||
parser.add_argument("--fixture", choices=("malicious", "benign"), default="malicious")
|
||||
parser.add_argument("--engine", action="append", choices=ENGINES, dest="engines")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def write_fixture_file(repo: Path, content: str) -> None:
|
||||
with (repo / "app.js").open("w", encoding="utf-8", newline="\n") as handle:
|
||||
handle.write(content)
|
||||
|
||||
|
||||
def run(command: list[str], cwd: Path) -> None:
|
||||
subprocess.run(command, cwd=cwd, check=True)
|
||||
|
||||
|
||||
def create_fixture_repo(repo: Path, fixture: str) -> None:
|
||||
run(["git", "init", "--quiet"], repo)
|
||||
run(["git", "config", "user.name", "Review Fixture"], repo)
|
||||
run(["git", "config", "user.email", "review-fixture@example.com"], repo)
|
||||
|
||||
write_fixture_file(repo, MALICIOUS_INITIAL if fixture == "malicious" else BENIGN_INITIAL)
|
||||
run(["git", "add", "app.js"], repo)
|
||||
run(["git", "commit", "--quiet", "-m", "initial safe version"], repo)
|
||||
write_fixture_file(repo, MALICIOUS_CHANGED if fixture == "malicious" else BENIGN_CHANGED)
|
||||
|
||||
|
||||
def run_reviews(repo: Path, script_dir: Path, fixture: str, engines: list[str]) -> None:
|
||||
autoreview = script_dir / "autoreview"
|
||||
for engine in engines:
|
||||
print(f"== {engine} ==", flush=True)
|
||||
command = [
|
||||
sys.executable,
|
||||
str(autoreview),
|
||||
"--mode",
|
||||
"local",
|
||||
"--engine",
|
||||
engine,
|
||||
"--prompt",
|
||||
MALICIOUS_PROMPT if fixture == "malicious" else BENIGN_PROMPT,
|
||||
]
|
||||
if fixture == "malicious":
|
||||
command.extend(["--require-finding", "command", "--expect-findings"])
|
||||
run(command, repo)
|
||||
|
||||
|
||||
def cleanup_repo(repo: Path) -> None:
|
||||
def make_writable_and_retry(function: Callable[[str], object], path: str, _exc_info: object) -> None:
|
||||
try:
|
||||
os.chmod(path, stat.S_IREAD | stat.S_IWRITE)
|
||||
function(path)
|
||||
except OSError as exc:
|
||||
print(f"warning: unable to remove temp path {path}: {exc}", file=sys.stderr)
|
||||
|
||||
if not repo.exists():
|
||||
return
|
||||
try:
|
||||
shutil.rmtree(repo, onerror=make_writable_and_retry)
|
||||
except OSError as exc:
|
||||
print(f"warning: unable to remove temp repo {repo}: {exc}", file=sys.stderr)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
args = parse_args(argv)
|
||||
script_dir = Path(__file__).resolve().parent
|
||||
engines = args.engines or list(DEFAULT_ENGINES)
|
||||
repo = Path(tempfile.mkdtemp(prefix="autoreview-fixture."))
|
||||
try:
|
||||
create_fixture_repo(repo, args.fixture)
|
||||
run_reviews(repo, script_dir, args.fixture, engines)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
return int(exc.returncode or 1)
|
||||
finally:
|
||||
cleanup_repo(repo)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@@ -223,21 +223,6 @@ Read the JSON summary and the Testbox line. Useful fields:
|
||||
- Actions run URL/id from the Testbox output
|
||||
- `exitCode`
|
||||
|
||||
Use provider-backed cache volumes only for rebuildable caches, not secrets or
|
||||
checkout state. On Blacksmith, Crabbox forwards them as sticky disks:
|
||||
|
||||
```sh
|
||||
node scripts/crabbox-wrapper.mjs run \
|
||||
--provider blacksmith-testbox \
|
||||
--cache-volume pnpm-store=openclaw-node24-pnpm-lock:/tmp/openclaw-pnpm-store \
|
||||
--timing-json \
|
||||
-- \
|
||||
corepack pnpm check:changed
|
||||
```
|
||||
|
||||
The selected provider must advertise cache-volume support. If not, omit
|
||||
`--cache-volume` and rely on kept-lease caches.
|
||||
|
||||
`blacksmith testbox list` may hide hydrating or ready boxes. Use:
|
||||
|
||||
```sh
|
||||
@@ -307,8 +292,7 @@ Live-provider debug template for direct AWS/Hetzner leases:
|
||||
|
||||
```sh
|
||||
mkdir -p .crabbox/logs
|
||||
CRABBOX_ENV_ALLOW=OPENAI_API_KEY,OPENAI_BASE_URL \
|
||||
pnpm crabbox:run -- --provider aws \
|
||||
pnpm crabbox:run -- --provider aws \
|
||||
--preflight \
|
||||
--allow-env OPENAI_API_KEY,OPENAI_BASE_URL \
|
||||
--timing-json \
|
||||
@@ -320,8 +304,10 @@ CRABBOX_ENV_ALLOW=OPENAI_API_KEY,OPENAI_BASE_URL \
|
||||
```
|
||||
|
||||
Do not pass `--capture-*`, `--download`, `--checksum`, `--force-sync-large`, or
|
||||
`--sync-only` to delegated providers. Crabbox rejects them because the provider
|
||||
owns sync or command transport.
|
||||
`--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.
|
||||
|
||||
## Efficient Bug E2E Verification
|
||||
|
||||
@@ -604,8 +590,7 @@ Crabbox Blacksmith backend delegates setup to:
|
||||
|
||||
The hydration workflow owns checkout, Node/pnpm setup, dependency install,
|
||||
secrets, ready marker, and keepalive. Crabbox owns dispatch, sync, SSH command
|
||||
execution, timing, logs/results, cleanup, and cache-volume requests. Blacksmith
|
||||
implements cache volumes as sticky disks.
|
||||
execution, timing, logs/results, and cleanup.
|
||||
|
||||
Minimal Blacksmith-backed Crabbox run, from repo root:
|
||||
|
||||
@@ -700,7 +685,6 @@ crabbox events <run_id> --json
|
||||
crabbox logs <run_id>
|
||||
crabbox results <run_id>
|
||||
crabbox cache stats --id <id-or-slug>
|
||||
crabbox cache volumes
|
||||
crabbox ssh --id <id-or-slug>
|
||||
blacksmith testbox list
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: discrawl
|
||||
description: "Discord archive: search, sync freshness, DMs, summaries, TUI, repo/release work."
|
||||
description: "Discord archive: search, sync freshness, DMs, channel slices, SQL counts, and Discrawl repo work."
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/openclaw/discrawl
|
||||
@@ -16,154 +16,29 @@ metadata:
|
||||
|
||||
# Discrawl
|
||||
|
||||
Use local Discord archive data first for Discord questions. Hit Discord APIs
|
||||
only when the archive is stale, missing the requested scope, or the user asks
|
||||
for current external context.
|
||||
|
||||
## Sources
|
||||
|
||||
- DB: platform-native XDG data dir, usually
|
||||
`${XDG_DATA_HOME:-~/.local/share}/discrawl/discrawl.db` on Linux or
|
||||
`~/Library/Application Support/discrawl/discrawl.db` on macOS
|
||||
- Config: platform-native XDG config dir, with legacy fallback to
|
||||
`~/.discrawl/config.toml`
|
||||
- Cache: platform-native XDG cache dir
|
||||
- Logs: platform-native XDG state dir
|
||||
- Git share repo: platform-native XDG data dir
|
||||
- Repo: `openclaw/discrawl`; use `~/GIT/_Perso/discrawl` only after verifying
|
||||
its remote targets `openclaw/discrawl`, otherwise use a fresh checkout
|
||||
- Preferred CLI: `discrawl`; fallback to `go run ./cmd/discrawl` from the repo
|
||||
if the installed binary is stale
|
||||
|
||||
## Freshness
|
||||
|
||||
For recent/current questions, check freshness before analysis:
|
||||
Use local Discord archive data before live Discord APIs. Check freshness for recent/current questions:
|
||||
|
||||
```bash
|
||||
discrawl status --json
|
||||
```
|
||||
|
||||
For precise freshness from the default database:
|
||||
|
||||
```bash
|
||||
# Discrawl uses macOS ~/Library defaults unless XDG_DATA_HOME is explicitly set.
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
db="$HOME/Library/Application Support/discrawl/discrawl.db"
|
||||
;;
|
||||
*)
|
||||
db="${XDG_DATA_HOME:-$HOME/.local/share}/discrawl/discrawl.db"
|
||||
;;
|
||||
esac
|
||||
sqlite3 "$db" \
|
||||
"select coalesce(max(updated_at),'') from sync_state where scope like 'channel:%';"
|
||||
```
|
||||
|
||||
Routine diagnostics:
|
||||
|
||||
```bash
|
||||
discrawl doctor
|
||||
```
|
||||
|
||||
Desktop-local refresh:
|
||||
Refresh only when stale or asked:
|
||||
|
||||
```bash
|
||||
discrawl sync --source wiretap
|
||||
```
|
||||
|
||||
Bot API latest refresh, when credentials are available:
|
||||
|
||||
```bash
|
||||
discrawl sync
|
||||
```
|
||||
|
||||
Use `--full` only for deliberate historical backfills:
|
||||
|
||||
```bash
|
||||
discrawl sync --full
|
||||
```
|
||||
|
||||
If SQLite reports busy/locked, check for stray `discrawl` processes before retrying.
|
||||
|
||||
## Query Workflow
|
||||
|
||||
1. Resolve scope: guild, channel, DM, author, keyword, date range.
|
||||
2. Check freshness for recent/current requests.
|
||||
3. Prefer CLI search/messages for slices; use read-only SQL for exact counts.
|
||||
4. Report absolute date spans, counts, channel/DM names, and known gaps.
|
||||
|
||||
Use root or subcommand help for syntax: `discrawl --help`,
|
||||
`discrawl help search`, `discrawl search --help`. Use
|
||||
`DISCRAWL_NO_AUTO_UPDATE=1` for read smokes when you do not want git-share
|
||||
updates.
|
||||
|
||||
Common commands:
|
||||
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 tui --dm
|
||||
DISCRAWL_NO_AUTO_UPDATE=1 discrawl --json sql "select count(*) from messages;"
|
||||
```
|
||||
|
||||
## SQL
|
||||
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.
|
||||
|
||||
Use `discrawl sql` for exact counts, joins, and ranking queries when normal
|
||||
CLI reads are too coarse. The command is read-only by default, accepts SQL as
|
||||
args or stdin, and supports `--json` for agent parsing.
|
||||
|
||||
Useful examples:
|
||||
|
||||
```bash
|
||||
DISCRAWL_NO_AUTO_UPDATE=1 discrawl --json sql "select count(*) as messages from messages;"
|
||||
DISCRAWL_NO_AUTO_UPDATE=1 discrawl --json sql "select coalesce(nullif(c.name, ''), m.channel_id) as channel, count(*) as messages from messages m left join channels c on c.id = m.channel_id group by m.channel_id order by messages desc limit 20;"
|
||||
DISCRAWL_NO_AUTO_UPDATE=1 discrawl --json sql "select coalesce(nullif(mm.display_name, ''), nullif(mm.global_name, ''), nullif(mm.username, ''), m.author_id) as author, count(*) as messages from messages m left join members mm on mm.guild_id = m.guild_id and mm.user_id = m.author_id group by m.guild_id, m.author_id order by messages desc limit 20;"
|
||||
```
|
||||
|
||||
Never use `--unsafe --confirm` unless the user explicitly asks for a database
|
||||
mutation and the write has been reviewed.
|
||||
|
||||
When the installed CLI lacks a new feature, build or run from a verified
|
||||
`openclaw/discrawl` checkout before concluding the feature is missing.
|
||||
|
||||
## Discord Boundaries
|
||||
|
||||
Bot API sync requires configured Discord bot credentials; do not invent token
|
||||
availability. Desktop wiretap mode reads local Discord Desktop artifacts and
|
||||
must not extract credentials, use user tokens, call Discord as the user, or
|
||||
write to Discord application storage. Wiretap/Desktop cache DMs are local-only
|
||||
and must not be described as part of the published Git snapshot. Git-share
|
||||
snapshots must not include secrets or `@me` DM rows.
|
||||
|
||||
## Verification
|
||||
|
||||
For repo edits, prefer existing Go gates:
|
||||
|
||||
```bash
|
||||
GOWORK=off go test ./...
|
||||
```
|
||||
|
||||
Then run targeted CLI smoke for the touched surface, for example:
|
||||
|
||||
```bash
|
||||
discrawl doctor
|
||||
discrawl status --json
|
||||
DISCRAWL_NO_AUTO_UPDATE=1 discrawl search --limit 5 "test"
|
||||
```
|
||||
|
||||
## ClawSweeper Sandbox
|
||||
|
||||
Use the sandbox reader only:
|
||||
|
||||
```bash
|
||||
discrawl-sandbox search --limit 20 "query"
|
||||
discrawl-sandbox messages --channel clawtributors --days 7 --all
|
||||
discrawl-sandbox status --json
|
||||
```
|
||||
|
||||
This reader imports `https://github.com/openclaw/discord-store.git` into
|
||||
`/root/clawsweeper-sandbox-workspace/.discrawl/discrawl.db` with
|
||||
`discord.token_source = "none"`. The published Git snapshot is public-channel
|
||||
filtered; do not use `/root/.discrawl/config.toml` or the rich writer DB from
|
||||
sandboxed public Discord sessions.
|
||||
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.
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
---
|
||||
name: kysely-database-access
|
||||
description: Use when adding, reviewing, or refactoring OpenClaw Kysely database access, native node:sqlite stores, generated DB types, SQLite schemas, migrations, raw SQL, transactions, or database access best practices.
|
||||
---
|
||||
|
||||
# Kysely Database Access
|
||||
|
||||
Use this skill for OpenClaw database code that touches Kysely, `node:sqlite`,
|
||||
generated DB types, SQLite schemas, migrations, or store/query design.
|
||||
|
||||
## Read First
|
||||
|
||||
- `docs/concepts/kysely.md` for the repo's Kysely rules and examples.
|
||||
- The owning subtree `AGENTS.md`, if present.
|
||||
- Relevant local Kysely source/types under `node_modules/kysely/dist/esm/...`
|
||||
before assuming dialect behavior, result types, transactions, plugins, or raw
|
||||
SQL semantics.
|
||||
- For codegen behavior, inspect `scripts/generate-kysely-types.mjs` and
|
||||
`kysely-codegen --help` from the repo package manager.
|
||||
|
||||
## Official Docs Cross-Check
|
||||
|
||||
When the behavior matters, verify against current Kysely docs/source before
|
||||
patching:
|
||||
|
||||
- Generating types: production apps should keep schema types aligned with the
|
||||
database through code generation.
|
||||
- Data types: TypeScript types do not affect runtime values; the driver decides
|
||||
runtime values, and Kysely returns what the driver returns unless a plugin
|
||||
transforms results.
|
||||
- Raw SQL: the `sql` tag can execute full raw SQL and embed snippets into
|
||||
builders. Prefer typed builders/helpers when they express the same thing.
|
||||
- Reusable helpers: take `Expression<T>` or an `ExpressionBuilder` when wrapping
|
||||
SQL expressions; alias helper expressions explicitly in `select`. Extract a
|
||||
helper only when it quarantines raw SQL, removes meaningful duplication, or
|
||||
preserves a tricky inferred type.
|
||||
- Split build/execute only at deliberate boundaries. Compiled-query execution
|
||||
is useful for native sync adapters, but keep plugin/result-transform behavior
|
||||
in mind.
|
||||
- Migrations: Kysely migration files run without a schema type. In OpenClaw,
|
||||
prefer the committed SQL-source-of-truth path unless a new owner explicitly
|
||||
needs Kysely-managed migrations.
|
||||
- Plugins: plugins can transform queries and results. Any sync shortcut that
|
||||
bypasses Kysely's async executor needs a documented invariant or tests.
|
||||
|
||||
## Default Workflow
|
||||
|
||||
1. Identify the owner boundary:
|
||||
- Core state DB: `src/state/*`
|
||||
- Per-agent DB: `src/state/openclaw-agent-*`
|
||||
- Feature store: owning `*.sqlite.ts` module
|
||||
- Plugin-owned state: plugin/module owner, not generic core
|
||||
2. Inspect the schema source first:
|
||||
- `*.sql` is the source of truth when generated schema/types exist.
|
||||
- Generated `*.generated.*` files are outputs, not hand-edit targets.
|
||||
3. Prefer Kysely builders for normal CRUD:
|
||||
- `selectFrom`, `insertInto`, `updateTable`, `deleteFrom`
|
||||
- `executeTakeFirst`, `executeTakeFirstOrThrow`, `execute`
|
||||
- `eb.fn.countAll`, `eb.fn.count`, `eb.fn.coalesce` for common functions
|
||||
- Keep compile-time Kysely reference literals such as `"host"` and
|
||||
`"flow_id as flowId"` when they are clearer than constants; they are
|
||||
type-checked by Kysely.
|
||||
- Let Kysely infer selected row shapes. Do not pass broad row generics to
|
||||
sync helpers for normal builder queries.
|
||||
- Treat `executeSqliteQuerySync<Row>(db, builder)` and
|
||||
`executeSqliteQueryTakeFirstSync<Row>(db, builder)` as a smell: the generic
|
||||
can lie about selected columns. Use no generic for builders; use an exact
|
||||
raw boundary helper for raw SQL.
|
||||
- For finite public query presets, use a preset-to-row type map plus a union
|
||||
boundary type instead of `Record<string, ...>`.
|
||||
- After touching Kysely/native SQLite code, run `pnpm lint:kysely`. The AST
|
||||
guard rejects raw identifier helpers, unreviewed typed `sql<T>` snippets,
|
||||
`db.dynamic`, explicit sync-helper row generics for builders, and new raw
|
||||
`node:sqlite` runtime access outside owner allowlists. It also rejects
|
||||
persisted enum-like casts in SQLite stores; keep row fields as `string` and
|
||||
parse through closed validators.
|
||||
4. Keep raw SQL deliberate:
|
||||
- Good: pragmas, virtual tables, FTS, SQLite JSON functions, migrations,
|
||||
`sqlite_master`, compact repeated expressions.
|
||||
- Bad: raw `COUNT(*)` or dynamic SQL where Kysely has a typed builder shape.
|
||||
- Use `${value}` parameters; use `sql.ref` / `sql.table` only for validated,
|
||||
closed-set identifiers.
|
||||
- Do not feed unconstrained runtime `string` values into table/column/group/
|
||||
order/identifier positions. Narrow them to local unions or generated table
|
||||
keys first.
|
||||
- Prefer `eb.fn`, `eb.lit`, `eb.ref`, and expression callbacks for scalar
|
||||
SQL such as `count`, `coalesce`, `max`, `exists`, and constant selections.
|
||||
5. Align TypeScript with real driver values:
|
||||
- Kysely does not coerce runtime values.
|
||||
- Native `node:sqlite` returns BLOB columns as `Uint8Array`; convert with
|
||||
`Buffer.from(...)` only at API boundaries that need Buffer helpers.
|
||||
- Keep JSON/text/timestamp parsing at module boundaries.
|
||||
- Keep persisted enum-like strings as `string` in row types, then parse them
|
||||
through closed validator helpers such as `parseTaskStatus(value)`. Do not
|
||||
cast corrupt persisted data into exported unions.
|
||||
6. Decide migration need from shipped state:
|
||||
- Unshipped schema/type cleanup: no SQLite migration.
|
||||
- Shipped canonical schema change: add the appropriate migration or
|
||||
doctor/fix repair path with tests.
|
||||
- Legacy config repair belongs in doctor/fix paths, not startup surprises.
|
||||
|
||||
## Codegen
|
||||
|
||||
For committed SQL-backed generated types:
|
||||
|
||||
```bash
|
||||
pnpm db:kysely:gen
|
||||
pnpm db:kysely:check
|
||||
```
|
||||
|
||||
The repo maps SQLite `blob` to `Uint8Array` through `kysely-codegen`
|
||||
`--type-mapping`. Do not post-process generated files by hand; change the
|
||||
generator or SQL source and regenerate.
|
||||
|
||||
## Native SQLite Guardrails
|
||||
|
||||
- Use `getNodeSqliteKysely(db)` and sync helpers from `src/infra/kysely-sync.ts`
|
||||
for `DatabaseSync` stores.
|
||||
- New direct `db.prepare(...)` / `db.exec(...)` runtime access should be rare.
|
||||
Prefer Kysely or add an explicit `scripts/check-kysely-guardrails.mjs`
|
||||
allowlist entry with a clear owner reason.
|
||||
- If raw SQLite is repeated or cast-heavy, extract a narrow boundary helper
|
||||
such as `assertSqliteIntegrityOk(db, message)` and allowlist that helper
|
||||
instead of each caller.
|
||||
- Keep sync helper result types derived from `CompiledQuery<Row>` / Kysely
|
||||
builders. Explicit helper generics are for raw SQL or external boundaries,
|
||||
not for widening a typed builder result into a generic record.
|
||||
- Keep the native dialect in `src/infra/kysely-node-sqlite.ts` aligned with
|
||||
Kysely's SQLite driver structure: single connection, mutex, SQLite adapter,
|
||||
SQLite query compiler, SQLite introspector.
|
||||
- Use `StatementSync.columns().length` behavior for row-returning statements;
|
||||
do not parse SQL verbs.
|
||||
- Return `insertId` only for changed Kysely insert nodes. Raw insert SQL and
|
||||
ignored inserts must not expose stale `lastInsertRowid`.
|
||||
- Remember that sync execution compiles through Kysely but bypasses async
|
||||
`executeQuery` result plugins/logging. If plugins enter this path, add tests
|
||||
or a documented invariant.
|
||||
|
||||
## Tests
|
||||
|
||||
Pick the smallest proof that covers the touched surface:
|
||||
|
||||
```bash
|
||||
pnpm db:kysely:check
|
||||
pnpm lint:kysely
|
||||
pnpm test src/infra/kysely-node-sqlite.test.ts
|
||||
pnpm test <owning-store>.test.ts
|
||||
pnpm tsgo:core
|
||||
```
|
||||
|
||||
Add or update focused tests for:
|
||||
|
||||
- generated type/runtime mismatches
|
||||
- native dialect metadata (`insertId`, `numAffectedRows`, row-returning SQL)
|
||||
- transactions/savepoints
|
||||
- BLOB and JSON boundary conversions
|
||||
- schema/codegen drift
|
||||
- type inference contracts for sync helpers and public query result maps
|
||||
- negative type contracts with `@ts-expect-error` for important column/preset
|
||||
mistakes
|
||||
- corruption-path tests that mutate SQLite directly and assert the public load
|
||||
or read method rejects invalid persisted strings
|
||||
- public store behavior, not just private SQL shape
|
||||
|
||||
## Helper Extraction
|
||||
|
||||
Good helpers:
|
||||
|
||||
- `readSqliteNumberPragma(db, pragma)` style helpers with a closed union for
|
||||
PRAGMA names.
|
||||
- Raw-expression helpers that accept Kysely expressions/refs instead of raw
|
||||
column strings.
|
||||
- Public query preset maps that preserve exact row types at the API boundary.
|
||||
|
||||
Avoid helpers that:
|
||||
|
||||
- Wrap obvious Kysely literals just to avoid strings.
|
||||
- Take generic `string` table/column/order names.
|
||||
- Return heavily generic query builders that are harder to type than the query
|
||||
they hide.
|
||||
|
||||
## Performance
|
||||
|
||||
- Benchmark prepare/compile overhead before adding statement caches or compiled
|
||||
query caches. Include the real public store method work: SQLite execution,
|
||||
JSON/BLOB conversion, and result mapping.
|
||||
- Keep caches local, close/dispose them with the owning store, and test invalid
|
||||
or stale behavior. Clear builders are the default until numbers prove a hot
|
||||
path.
|
||||
|
||||
## Avoid
|
||||
|
||||
- Do not introduce ORM/repository layers or hidden relation loading.
|
||||
- Do not make root dependencies for plugin-only database needs.
|
||||
- Do not migrate everything to raw SQL or everything to builders for purity.
|
||||
- Do not hand-edit generated DB types.
|
||||
- Do not hide finite query result shapes behind `Record<string, ...>` just to
|
||||
make JSON output convenient; use exact row unions or map at the boundary.
|
||||
- Do not replace every Kysely string literal with constants for aesthetics; fix
|
||||
dynamic identifiers, raw SQL assertions, and public result boundaries instead.
|
||||
- Do not add broad cache layers to hide repeated query/discovery work; carry the
|
||||
known runtime fact earlier when possible.
|
||||
@@ -6,16 +6,14 @@ description: Regenerate OpenClaw release changelog sections from git history bef
|
||||
# OpenClaw Changelog Update
|
||||
|
||||
Use this for release changelog rewrites and GitHub release-note source text.
|
||||
This is mandatory before every beta, beta rerun, stable release, or stable
|
||||
rerun. Use it with `release-openclaw-maintainer`; this skill owns changelog
|
||||
content, ordering, grouping, and attribution discipline.
|
||||
Use it with `release-openclaw-maintainer`; this skill owns changelog content,
|
||||
ordering, and audit discipline.
|
||||
|
||||
## Goal
|
||||
|
||||
Rewrite the target `CHANGELOG.md` version section from history, not from stale
|
||||
draft notes. Produce grouped user-facing release notes sorted by user interest
|
||||
while preserving every relevant issue/PR ref and every human `Thanks @...`
|
||||
attribution.
|
||||
draft notes. Produce user-facing release notes sorted by user interest while
|
||||
preserving issue/PR refs and thanks.
|
||||
|
||||
## Inputs
|
||||
|
||||
@@ -46,35 +44,15 @@ attribution.
|
||||
- `### Highlights`: 5-8 bullets, broad user wins first
|
||||
- `### Changes`: new capabilities and behavior changes
|
||||
- `### Fixes`: user-facing fixes first, grouped by impact and surface
|
||||
- group related changes/fixes by surface and user impact; avoid one bullet
|
||||
per tiny commit when several commits tell one user-facing story
|
||||
6. Preserve attribution:
|
||||
- keep `#issue`, `(#PR)`, `Fixes #...`, and `Thanks @...`
|
||||
- every human-authored merged PR represented by a user-facing entry needs
|
||||
its PR ref and `Thanks @author`, even when the PR had no linked issue
|
||||
- every human issue reporter for a `Fixes #...` or referenced bug issue
|
||||
represented by a user-facing entry needs `Thanks @reporter` unless the
|
||||
same handle is already thanked in that bullet
|
||||
- every human `Co-authored-by` contributor on represented user-facing work
|
||||
needs `Thanks @handle` when a GitHub handle is known
|
||||
- when grouping multiple PRs/issues in one bullet, include every relevant
|
||||
PR/issue ref and every human contributor handle in that same bullet
|
||||
- multiple `Thanks @...` handles in one bullet are expected; do not drop or
|
||||
collapse contributor credit just because the note is grouped
|
||||
- if one grouped bullet covers both direct commits and PRs, keep all PR refs
|
||||
and thanks, plus any issue refs from the direct commits
|
||||
- before finalizing, audit the final release-note body:
|
||||
- extract all `#NNN` refs from the notes
|
||||
- resolve which refs are PRs and collect human PR authors
|
||||
- resolve issue refs used as bug/report refs and collect human reporters
|
||||
- scan represented commits for `Co-authored-by`
|
||||
- compare those handles to the final `Thanks @...` set
|
||||
- fix every missing human credit or explicitly record why it is omitted
|
||||
- do not add GHSA references, advisory IDs, or security advisory slugs to
|
||||
changelog entries or GitHub release-note text unless explicitly requested
|
||||
- never thank bots, `@openclaw`, `@clawsweeper`, or `@steipete`
|
||||
- do not use GitHub's release contributor count as the source of truth; the
|
||||
changelog must carry the complete human credit set itself
|
||||
- if grouping multiple entries, carry all relevant refs and thanks into the
|
||||
grouped bullet
|
||||
7. Sorting preference:
|
||||
- security/data-loss and content-boundary fixes
|
||||
- transcript/replay/reply delivery correctness
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: openclaw-ghsa-maintainer
|
||||
description: "Inspect, patch, validate, publish, or confirm OpenClaw GHSA security advisories and private-fork state."
|
||||
description: Inspect, patch, validate, publish, or confirm OpenClaw GHSA security advisories and private-fork state.
|
||||
---
|
||||
|
||||
# OpenClaw GHSA Maintainer
|
||||
@@ -85,4 +85,3 @@ jq -r .description < /tmp/ghsa.refetch.json | rg '\\\\n'
|
||||
- Publishing fails with HTTP 422 if required fields are missing or the private fork still has open PRs.
|
||||
- A payload that looks correct in shell can still be wrong if Markdown was assembled with escaped newline strings.
|
||||
- Advisory PATCH sequencing matters; separate field updates when GHSA API constraints require it.
|
||||
- Public hardening/no-publish comments and draft text should avoid raw commit hashes, PR titles/numbers, and fix-mechanism summaries. Prefer patched-version fields or release-only wording; keep SHAs, PRs, and implementation notes in internal evidence.
|
||||
|
||||
@@ -75,9 +75,7 @@ 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. If raw
|
||||
Vitest is unavoidable, use `vitest run ...`; bare `vitest ...` starts local watch
|
||||
mode and will not exit on its own.
|
||||
`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
|
||||
|
||||
@@ -21,30 +21,6 @@ function jsonGh(args) {
|
||||
return JSON.parse(gh(args));
|
||||
}
|
||||
|
||||
function githubRestJson(pathSuffix) {
|
||||
const result = execFileSync(
|
||||
"bash",
|
||||
[
|
||||
"-lc",
|
||||
[
|
||||
"set -euo pipefail",
|
||||
'token="$(gh auth token)"',
|
||||
'curl -fsS -H "Authorization: Bearer ${token}" -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" "${OPENCLAW_GITHUB_REST_URL}"',
|
||||
].join("\n"),
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_GITHUB_REST_URL: `https://api.github.com/repos/${repo}/${pathSuffix}`,
|
||||
},
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
return JSON.parse(result);
|
||||
}
|
||||
|
||||
function rate() {
|
||||
try {
|
||||
return jsonGh(["api", "rate_limit"]).resources.core;
|
||||
@@ -83,30 +59,12 @@ for (const job of parent.jobs ?? []) {
|
||||
}
|
||||
|
||||
const since = parent.createdAt;
|
||||
const runsQuery = new URLSearchParams({
|
||||
per_page: "100",
|
||||
created: `>=${since}`,
|
||||
exclude_pull_requests: "true",
|
||||
});
|
||||
const childWorkflowNames = new Set([
|
||||
"CI",
|
||||
"OpenClaw Release Checks",
|
||||
"Plugin Prerelease",
|
||||
"NPM Telegram Beta E2E",
|
||||
"Full Release Validation",
|
||||
]);
|
||||
const runs = githubRestJson(`actions/runs?${runsQuery.toString()}`).workflow_runs ?? [];
|
||||
const runList = runs
|
||||
.filter(
|
||||
(run) =>
|
||||
run.created_at >= since &&
|
||||
run.head_sha === parent.headSha &&
|
||||
childWorkflowNames.has(run.name),
|
||||
)
|
||||
.map((run) =>
|
||||
[run.id, run.name, run.status, run.conclusion ?? "", run.head_sha, run.html_url].join("\t"),
|
||||
)
|
||||
.join("\n");
|
||||
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");
|
||||
|
||||
@@ -69,13 +69,9 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
or clawgrit reports. Report regressions explicitly. A major regression is a
|
||||
release blocker unless the operator waives it or the data clearly proves
|
||||
infrastructure noise.
|
||||
- Generate the changelog before every beta, beta rerun, stable release, or
|
||||
stable rerun, before version/tag preparation. Use
|
||||
`$openclaw-changelog-update` for the rewrite. Do not continue release prep if
|
||||
the target `CHANGELOG.md` section does not have `### Highlights`,
|
||||
`### Changes`, and `### Fixes`, grouped by user-facing surface while
|
||||
preserving every relevant PR/issue ref and every human `Thanks @...`
|
||||
attribution in the grouped bullet.
|
||||
- Generate the changelog before version/tag preparation so the top changelog
|
||||
section is deduped and ordered by user impact. Use
|
||||
`$openclaw-changelog-update` for the rewrite.
|
||||
- Do not create beta-specific `CHANGELOG.md` headings. Beta releases use the
|
||||
stable base version section, for example `v2026.4.20-beta.1` uses
|
||||
`## 2026.4.20` release notes.
|
||||
@@ -148,9 +144,6 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
section from history, not existing notes. Use the last reachable stable or
|
||||
beta release tag as the base, then inspect every commit through the target
|
||||
release SHA.
|
||||
- The changelog rewrite is not optional for beta reruns: any `beta.N` after a
|
||||
rebase or backport must refresh the same stable-base `## YYYY.M.D` section
|
||||
before the new version/tag commit.
|
||||
- Include both merged PR commits and direct commits on `main`. Direct commits
|
||||
matter: infer notes from their subject, body, touched files, linked issues,
|
||||
tests, and nearby code when no PR body exists.
|
||||
@@ -164,11 +157,6 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
- Add missed user-facing changes, remove internal-only noise, dedupe overlapping
|
||||
PR/direct-commit entries, and sort each section from most to least interesting
|
||||
for users.
|
||||
- Group related highlights, changes, and fixes by user-facing surface and
|
||||
impact, but never lose traceability: each grouped bullet keeps every relevant
|
||||
`#issue`, `(#PR)`, `Fixes #...`, and every human `Thanks @...` handle.
|
||||
Multiple thanks in one bullet are expected when multiple contributor PRs are
|
||||
grouped.
|
||||
- Changelog entries should be user-facing, not internal release-process notes.
|
||||
- GitHub release and prerelease bodies must use the full matching
|
||||
`CHANGELOG.md` version section, not highlights or an excerpt. When creating
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: security-triage
|
||||
description: "Triage OpenClaw security advisories, drafts, and GHSA reports with shipped-tag and trust-model proof."
|
||||
description: Triage OpenClaw security advisories, drafts, and GHSA reports with shipped-tag and trust-model proof.
|
||||
---
|
||||
|
||||
# Security Triage
|
||||
@@ -87,19 +87,11 @@ When preparing a maintainer-ready close reply:
|
||||
- exact reason for close
|
||||
- exact code refs
|
||||
- exact shipped tag / release facts
|
||||
- fix provenance or canonical duplicate GHSA when applicable
|
||||
- exact fix commit or canonical duplicate GHSA when applicable
|
||||
- optional hardening note only if worthwhile and functionality-preserving
|
||||
|
||||
Keep tone firm, specific, non-defensive.
|
||||
|
||||
## Public Wording Hygiene
|
||||
|
||||
- Keep raw commit hashes, PR titles/numbers, and fix-mechanism summaries out of public advisory text. Use the patched release/version field only.
|
||||
- Keep exact commit SHAs, PRs, and implementation notes in internal notes and verification files.
|
||||
- For hardening/no-publish outcomes, do not add exploit-heavy details, "Fixed by" text, or a "Fix Commit(s)" section. Thank reporters, preserve credit, state the `SECURITY.md` boundary, and say clearly that the GHSA will close without publication.
|
||||
- For published CVE/GHSA text, prefer `### Patched Versions` with the fixed release. Do not explain how the patch works unless Peter explicitly asks for that public detail.
|
||||
- Keep GHSA ids out of changelog and release-note wording unless Peter explicitly asks.
|
||||
|
||||
## Discussion Mode
|
||||
|
||||
When Peter is manually posting GHSA comments, use this flow:
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
profile: openclaw-check
|
||||
# Default OpenClaw runner spend to the Azure-backed Crabbox account.
|
||||
# Use `--provider aws` only for AWS-specific runner proof.
|
||||
provider: azure
|
||||
provider: aws
|
||||
class: standard
|
||||
capacity:
|
||||
market: spot
|
||||
strategy: most-available
|
||||
# Fail closed instead of silently falling back to on-demand while the
|
||||
# Azure-backed billing account is the default runner path.
|
||||
fallback: spot-only
|
||||
fallback: on-demand-after-120s
|
||||
hints: true
|
||||
availabilityZones:
|
||||
- eu-west-1a
|
||||
- eu-west-1b
|
||||
- eu-west-1c
|
||||
regions:
|
||||
- eu-west-1
|
||||
- eu-west-2
|
||||
- eu-central-1
|
||||
- us-east-1
|
||||
- us-west-2
|
||||
actions:
|
||||
workflow: .github/workflows/crabbox-hydrate.yml
|
||||
# Default AWS hydration uses local Actions replay. Use
|
||||
@@ -23,14 +29,7 @@ actions:
|
||||
- openclaw
|
||||
runnerVersion: latest
|
||||
ephemeral: true
|
||||
blacksmith:
|
||||
org: openclaw
|
||||
workflow: .github/workflows/ci-check-testbox.yml
|
||||
job: check
|
||||
ref: main
|
||||
aws:
|
||||
# AWS-specific overrides still pin direct `--provider aws` runs without
|
||||
# leaking AWS region names into the Azure default capacity fallback list.
|
||||
region: eu-west-1
|
||||
rootGB: 400
|
||||
sync:
|
||||
|
||||
8
.github/CODEOWNERS
vendored
8
.github/CODEOWNERS
vendored
@@ -11,10 +11,8 @@
|
||||
/.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-guard.yml @openclaw/openclaw-secops
|
||||
/test/scripts/dependency-guard-workflow.test.ts @openclaw/openclaw-secops
|
||||
/test/scripts/dependency-guard-script.test.ts @openclaw/openclaw-secops
|
||||
/scripts/github/dependency-guard.mjs @openclaw/openclaw-secops
|
||||
/.github/workflows/dependency-change-awareness.yml @openclaw/openclaw-secops
|
||||
/test/scripts/dependency-change-awareness-workflow.test.ts @openclaw/openclaw-secops
|
||||
/package-lock.json @openclaw/openclaw-secops
|
||||
/npm-shrinkwrap.json @openclaw/openclaw-secops
|
||||
/extensions/*/package-lock.json @openclaw/openclaw-secops
|
||||
@@ -31,7 +29,7 @@
|
||||
/src/gateway/**/*secret*.ts @openclaw/openclaw-secops
|
||||
/src/gateway/security-path*.ts @openclaw/openclaw-secops
|
||||
/src/gateway/resolve-configured-secret-input-string*.ts @openclaw/openclaw-secops
|
||||
/packages/gateway-protocol/src/**/*secret*.ts @openclaw/openclaw-secops
|
||||
/src/gateway/protocol/**/*secret*.ts @openclaw/openclaw-secops
|
||||
/src/gateway/server-methods/secrets*.ts @openclaw/openclaw-secops
|
||||
/src/agents/*auth*.ts @openclaw/openclaw-secops
|
||||
/src/agents/**/*auth*.ts @openclaw/openclaw-secops
|
||||
|
||||
4
.github/actionlint.yaml
vendored
4
.github/actionlint.yaml
vendored
@@ -14,10 +14,6 @@ self-hosted-runner:
|
||||
- blacksmith-16vcpu-ubuntu-2404-arm
|
||||
- blacksmith-6vcpu-macos-latest
|
||||
- blacksmith-12vcpu-macos-latest
|
||||
- blacksmith-6vcpu-macos-15
|
||||
- blacksmith-12vcpu-macos-15
|
||||
- blacksmith-6vcpu-macos-26
|
||||
- blacksmith-12vcpu-macos-26
|
||||
|
||||
# Ignore patterns for known issues
|
||||
paths:
|
||||
|
||||
24
.github/actions/detect-docs-changes/action.yml
vendored
24
.github/actions/detect-docs-changes/action.yml
vendored
@@ -35,29 +35,17 @@ runs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
docs_changed=false
|
||||
non_docs=false
|
||||
while IFS= read -r changed_path; do
|
||||
case "$changed_path" in
|
||||
test/fixtures/*)
|
||||
non_docs=true
|
||||
;;
|
||||
docs/* | *.md | *.mdx)
|
||||
docs_changed=true
|
||||
;;
|
||||
*)
|
||||
non_docs=true
|
||||
;;
|
||||
esac
|
||||
done <<< "$CHANGED"
|
||||
|
||||
if [ "$docs_changed" = "true" ]; then
|
||||
# Check if any changed file is a doc
|
||||
DOCS=$(echo "$CHANGED" | grep -E '^docs/|\.md$|\.mdx$' || true)
|
||||
if [ -n "$DOCS" ]; then
|
||||
echo "docs_changed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "docs_changed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
if [ "$non_docs" = "false" ]; then
|
||||
# Check if all changed files are docs or markdown
|
||||
NON_DOCS=$(echo "$CHANGED" | grep -vE '^docs/|\.md$|\.mdx$' || true)
|
||||
if [ -z "$NON_DOCS" ]; then
|
||||
echo "docs_only=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Docs-only change detected — skipping heavy jobs"
|
||||
else
|
||||
|
||||
10
.github/actions/ensure-base-commit/action.yml
vendored
10
.github/actions/ensure-base-commit/action.yml
vendored
@@ -38,15 +38,9 @@ runs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
fetch_base_ref() {
|
||||
timeout --signal=TERM --kill-after=10s 30s git \
|
||||
-c protocol.version=2 \
|
||||
fetch "$@"
|
||||
}
|
||||
|
||||
for deepen_by in 25 100 300; do
|
||||
echo "Base commit missing; deepening $FETCH_REF by $deepen_by."
|
||||
if ! fetch_base_ref --no-tags --deepen="$deepen_by" origin -- "$FETCH_REF"; then
|
||||
if ! git fetch --no-tags --deepen="$deepen_by" origin -- "$FETCH_REF"; then
|
||||
echo "::warning title=ensure-base-commit fetch failed::Failed to deepen $FETCH_REF by $deepen_by while looking for $BASE_SHA"
|
||||
fi
|
||||
if git rev-parse --verify "$BASE_SHA^{commit}" >/dev/null 2>&1; then
|
||||
@@ -56,7 +50,7 @@ runs:
|
||||
done
|
||||
|
||||
echo "Base commit still missing; fetching full history for $FETCH_REF."
|
||||
if ! fetch_base_ref --no-tags origin -- "$FETCH_REF"; then
|
||||
if ! git fetch --no-tags origin -- "$FETCH_REF"; then
|
||||
echo "::warning title=ensure-base-commit fetch failed::Failed to fetch full history for $FETCH_REF while looking for $BASE_SHA"
|
||||
fi
|
||||
if git rev-parse --verify "$BASE_SHA^{commit}" >/dev/null 2>&1; then
|
||||
|
||||
14
.github/actions/setup-node-env/action.yml
vendored
14
.github/actions/setup-node-env/action.yml
vendored
@@ -20,13 +20,9 @@ inputs:
|
||||
required: false
|
||||
default: "true"
|
||||
use-actions-cache:
|
||||
description: Whether to restore the pnpm store with actions/cache.
|
||||
description: Whether to restore and save the pnpm store with actions/cache.
|
||||
required: false
|
||||
default: "true"
|
||||
save-actions-cache:
|
||||
description: Whether to save the pnpm store with actions/cache after install when no exact cache restored.
|
||||
required: false
|
||||
default: "false"
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
@@ -49,7 +45,6 @@ runs:
|
||||
openclaw_ensure_node "$REQUESTED_NODE_VERSION"
|
||||
|
||||
- name: Setup pnpm
|
||||
id: setup-pnpm
|
||||
uses: ./.github/actions/setup-pnpm-store-cache
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
@@ -135,10 +130,3 @@ runs:
|
||||
ln -sfn "$PNPM_CONFIG_MODULES_DIR" node_modules
|
||||
ln -sfn . "$PNPM_CONFIG_MODULES_DIR/node_modules"
|
||||
fi
|
||||
|
||||
- name: Save pnpm store cache
|
||||
if: ${{ inputs.install-deps == 'true' && inputs.use-actions-cache == 'true' && inputs.save-actions-cache == 'true' && runner.os != 'Windows' && steps.setup-pnpm.outputs.store-cache-hit != 'true' }}
|
||||
uses: actions/cache/save@v5
|
||||
with:
|
||||
path: ${{ steps.setup-pnpm.outputs.store-path }}
|
||||
key: ${{ steps.setup-pnpm.outputs.store-cache-primary-key }}
|
||||
|
||||
@@ -14,7 +14,7 @@ inputs:
|
||||
required: false
|
||||
default: ""
|
||||
use-actions-cache:
|
||||
description: Whether actions/cache should restore the pnpm store.
|
||||
description: Whether actions/cache should cache the pnpm store.
|
||||
required: false
|
||||
default: "true"
|
||||
outputs:
|
||||
@@ -24,15 +24,6 @@ outputs:
|
||||
project-dir:
|
||||
description: Directory containing the packageManager file used for pnpm resolution.
|
||||
value: ${{ steps.setup-pnpm.outputs.project-dir }}
|
||||
store-cache-hit:
|
||||
description: Whether the pnpm store cache restored an exact key.
|
||||
value: ${{ steps.pnpm-store-cache.outputs.cache-hit }}
|
||||
store-cache-primary-key:
|
||||
description: Exact pnpm store cache key used for restore/save.
|
||||
value: ${{ steps.pnpm-store-cache.outputs.cache-primary-key }}
|
||||
store-path:
|
||||
description: Resolved pnpm store path.
|
||||
value: ${{ steps.pnpm-store.outputs.path }}
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
@@ -90,15 +81,14 @@ runs:
|
||||
echo "path=$store_path" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore pnpm store cache
|
||||
id: pnpm-store-cache
|
||||
if: ${{ inputs.use-actions-cache == 'true' && runner.os != 'Windows' }}
|
||||
uses: actions/cache/restore@v5
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ runner.arch }}-${{ inputs.node-version }}-${{ hashFiles(inputs.package-manager-file) }}-${{ hashFiles(inputs.lockfile-path) }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ inputs.node-version }}-${{ hashFiles(inputs.lockfile-path) }}
|
||||
restore-keys: |
|
||||
pnpm-store-${{ runner.os }}-${{ runner.arch }}-${{ inputs.node-version }}-${{ hashFiles(inputs.package-manager-file) }}-
|
||||
pnpm-store-${{ runner.os }}-${{ runner.arch }}-${{ inputs.node-version }}-
|
||||
pnpm-store-${{ runner.os }}-${{ inputs.node-version }}-
|
||||
pnpm-store-${{ runner.os }}-
|
||||
|
||||
- name: Record pnpm version
|
||||
id: pnpm-version
|
||||
|
||||
@@ -95,7 +95,7 @@ openclaw_find_toolcache_node() {
|
||||
done
|
||||
|
||||
local node_root candidate candidate_version
|
||||
for node_root in ${roots[@]+"${roots[@]}"}; do
|
||||
for node_root in "${roots[@]}"; do
|
||||
while IFS= read -r candidate; do
|
||||
candidate_version="$("$candidate" -p 'process.versions.node' 2>/dev/null || true)"
|
||||
if openclaw_node_version_matches "$candidate_version" "$requested_node"; then
|
||||
|
||||
@@ -19,7 +19,7 @@ paths:
|
||||
- src/config/types.channel*.ts
|
||||
- src/gateway/server-channel*.ts
|
||||
- src/gateway/server-methods/channels.ts
|
||||
- packages/gateway-protocol/src/schema/channels.ts
|
||||
- src/gateway/protocol/schema/channels.ts
|
||||
- src/infra/channel-*.ts
|
||||
- src/infra/exec-approval-channel-runtime.ts
|
||||
- src/infra/outbound/channel-*.ts
|
||||
|
||||
@@ -30,7 +30,7 @@ paths:
|
||||
- src/gateway/**/*auth*.ts
|
||||
- src/gateway/*secret*.ts
|
||||
- src/gateway/**/*secret*.ts
|
||||
- packages/gateway-protocol/src/**/*secret*.ts
|
||||
- src/gateway/protocol/**/*secret*.ts
|
||||
- src/gateway/resolve-configured-secret-input-string*.ts
|
||||
- src/gateway/security-path*.ts
|
||||
- src/gateway/server-methods/secrets*.ts
|
||||
|
||||
@@ -30,7 +30,7 @@ paths:
|
||||
- src/gateway/**/*auth*.ts
|
||||
- src/gateway/*secret*.ts
|
||||
- src/gateway/**/*secret*.ts
|
||||
- packages/gateway-protocol/src/**/*secret*.ts
|
||||
- src/gateway/protocol/**/*secret*.ts
|
||||
- src/gateway/resolve-configured-secret-input-string*.ts
|
||||
- src/gateway/security-path*.ts
|
||||
- src/gateway/server-methods/secrets*.ts
|
||||
|
||||
@@ -15,7 +15,7 @@ query-filters:
|
||||
|
||||
paths:
|
||||
- src/gateway/method-scopes.ts
|
||||
- packages/gateway-protocol/src
|
||||
- src/gateway/protocol
|
||||
- src/gateway/server-methods
|
||||
- src/gateway/server-methods.ts
|
||||
- src/gateway/server-methods-list.ts
|
||||
|
||||
@@ -7,17 +7,8 @@ queries:
|
||||
- uses: ./.github/codeql/openclaw-boundary/queries/managed-proxy-runtime-mutation.ql
|
||||
|
||||
paths:
|
||||
- src/cli/gateway-cli/run-loop.ts
|
||||
- src/infra/gateway-lock.ts
|
||||
- src/infra/jsonl-socket.ts
|
||||
- src/infra/net
|
||||
- src/infra/push-apns-http2.ts
|
||||
- src/infra/ssh-tunnel.ts
|
||||
- src/proxy-capture
|
||||
- extensions/codex-supervisor/src/json-rpc-client.ts
|
||||
- extensions/irc/src
|
||||
- extensions/qa-lab/src
|
||||
- packages/net-policy/src
|
||||
- src
|
||||
- extensions
|
||||
|
||||
paths-ignore:
|
||||
- "**/node_modules"
|
||||
|
||||
@@ -15,6 +15,7 @@ query-filters:
|
||||
|
||||
paths:
|
||||
- src/infra/net
|
||||
- src/shared/net
|
||||
- src/agents/tools/web-fetch.ts
|
||||
- src/agents/tools/web-guarded-fetch.ts
|
||||
- src/agents/tools/web-shared.ts
|
||||
@@ -22,7 +23,6 @@ paths:
|
||||
- src/web-fetch
|
||||
- src/web/provider-runtime-shared.ts
|
||||
- packages/memory-host-sdk/src/host/ssrf-policy.ts
|
||||
- packages/net-policy/src
|
||||
|
||||
paths-ignore:
|
||||
- "**/node_modules"
|
||||
|
||||
@@ -76,8 +76,6 @@ predicate allowedRawSocketClientCall(Expr call) {
|
||||
or
|
||||
allowedOwnerScope(call, "src/proxy-capture/proxy-server.ts", "startDebugProxyServer")
|
||||
or
|
||||
allowedOwnerScope(call, "extensions/codex-supervisor/src/json-rpc-client.ts", "connectCodexSupervisorUnixSocket")
|
||||
or
|
||||
allowedOwnerScope(call, "extensions/irc/src/client.ts", "connectIrcClient")
|
||||
or
|
||||
allowedOwnerScope(call, "extensions/qa-lab/src/lab-server-capture.ts", "probeTcpReachability")
|
||||
|
||||
35
.github/labeler.yml
vendored
35
.github/labeler.yml
vendored
@@ -47,12 +47,6 @@
|
||||
- "extensions/meeting-notes/**"
|
||||
- "docs/plugins/meeting-notes.md"
|
||||
- "src/meeting-notes/**"
|
||||
"plugin: workboard":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/workboard/**"
|
||||
- "docs/plugins/workboard.md"
|
||||
- "docs/plugins/reference/workboard.md"
|
||||
"plugin: migrate-hermes":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@@ -132,11 +126,6 @@
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/slack/**"
|
||||
- "docs/channels/slack.md"
|
||||
"channel: sms":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/sms/**"
|
||||
- "docs/channels/sms.md"
|
||||
"channel: synology-chat":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@@ -199,7 +188,7 @@
|
||||
- "ui/**"
|
||||
- "src/gateway/control-ui.ts"
|
||||
- "src/gateway/control-ui-shared.ts"
|
||||
- "packages/gateway-protocol/src/**"
|
||||
- "src/gateway/protocol/**"
|
||||
- "src/gateway/server-methods/chat.ts"
|
||||
- "src/infra/control-ui-assets.ts"
|
||||
|
||||
@@ -207,7 +196,6 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/gateway/**"
|
||||
- "packages/gateway-protocol/src/**"
|
||||
- "src/daemon/**"
|
||||
- "docs/gateway/**"
|
||||
|
||||
@@ -360,11 +348,6 @@
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/deepinfra/**"
|
||||
- "docs/providers/deepinfra.md"
|
||||
"extensions: gmi":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/gmi/**"
|
||||
- "docs/providers/gmi.md"
|
||||
"extensions: tencent":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@@ -415,17 +398,6 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/codex/**"
|
||||
"extensions: codex-supervisor":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/codex-supervisor/**"
|
||||
- "docs/plugins/reference/codex-supervisor.md"
|
||||
- "docs/specs/claw-supervisor.md"
|
||||
"extensions: copilot":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/copilot/**"
|
||||
- "docs/plugins/copilot.md"
|
||||
"extensions: kimi-coding":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@@ -446,11 +418,6 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/nvidia/**"
|
||||
"extensions: novita":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/novita/**"
|
||||
- "docs/providers/novita.md"
|
||||
"extensions: phone-control":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
timeout-minutes: 35
|
||||
steps:
|
||||
- name: Begin Testbox
|
||||
uses: useblacksmith/begin-testbox@233448af4bfdc6fca509a7f0974411ac6d8a8043
|
||||
uses: useblacksmith/begin-testbox@d0e04585c26905fdd92c94a09c159544c7ee1b67
|
||||
with:
|
||||
testbox_id: ${{ inputs.testbox_id }}
|
||||
|
||||
@@ -188,10 +188,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main"
|
||||
git fetch --no-tags --depth=50 origin "+refs/heads/main:refs/remotes/origin/main"
|
||||
|
||||
node_bin="$(dirname "$(node -p 'process.execPath')")"
|
||||
sudo ln -sf "$node_bin/node" /usr/local/bin/node
|
||||
@@ -231,7 +228,7 @@ jobs:
|
||||
run: bash scripts/ci-hydrate-testbox-env.sh
|
||||
|
||||
- name: Run Testbox
|
||||
uses: useblacksmith/run-testbox@3f60ff9ceb2c10c3feefa87dc0c6490cffae059d
|
||||
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
|
||||
if: success()
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
12
.github/workflows/ci-check-testbox.yml
vendored
12
.github/workflows/ci-check-testbox.yml
vendored
@@ -15,9 +15,6 @@ permissions:
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
PNPM_CONFIG_MODULES_DIR: "/tmp/openclaw-pnpm-node-modules"
|
||||
PNPM_CONFIG_STORE_DIR: "/tmp/openclaw-pnpm-store"
|
||||
PNPM_CONFIG_VIRTUAL_STORE_DIR: "/tmp/openclaw-pnpm-virtual-store"
|
||||
|
||||
jobs:
|
||||
check:
|
||||
@@ -29,7 +26,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Begin Testbox
|
||||
uses: useblacksmith/begin-testbox@233448af4bfdc6fca509a7f0974411ac6d8a8043
|
||||
uses: useblacksmith/begin-testbox@d0e04585c26905fdd92c94a09c159544c7ee1b67
|
||||
with:
|
||||
testbox_id: ${{ inputs.testbox_id }}
|
||||
- name: Checkout
|
||||
@@ -92,10 +89,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main"
|
||||
git fetch --no-tags --depth=50 origin "+refs/heads/main:refs/remotes/origin/main"
|
||||
|
||||
node_bin="$(dirname "$(node -p 'process.execPath')")"
|
||||
sudo ln -sf "$node_bin/node" /usr/local/bin/node
|
||||
@@ -136,7 +130,7 @@ jobs:
|
||||
run: bash scripts/ci-hydrate-testbox-env.sh
|
||||
|
||||
- name: Run Testbox
|
||||
uses: useblacksmith/run-testbox@3f60ff9ceb2c10c3feefa87dc0c6490cffae059d
|
||||
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
|
||||
if: success()
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
390
.github/workflows/ci.yml
vendored
390
.github/workflows/ci.yml
vendored
@@ -28,7 +28,7 @@ permissions:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.event_name == 'workflow_dispatch' && format('{0}-manual-v1-{1}', github.workflow, github.run_id) || (github.event_name == 'pull_request' && format('{0}-v7-{1}', github.workflow, github.event.pull_request.number) || (github.repository == 'openclaw/openclaw' && format('{0}-v7-{1}', github.workflow, github.ref) || format('{0}-v7-{1}-{2}', github.workflow, github.ref, github.sha))) }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && github.repository == 'openclaw/openclaw' && github.ref == 'refs/heads/main') }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
@@ -86,38 +86,12 @@ jobs:
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
fetch_checkout_ref() {
|
||||
local ref="$1"
|
||||
local fetch_status
|
||||
for attempt in 1 2 3; do
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${ref}:refs/remotes/origin/checkout" && return 0
|
||||
fetch_status="$?"
|
||||
if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then
|
||||
return "$fetch_status"
|
||||
fi
|
||||
if [ "$attempt" = "3" ]; then
|
||||
return "$fetch_status"
|
||||
fi
|
||||
echo "::warning::checkout fetch for '$ref' timed out on attempt $attempt; retrying"
|
||||
sleep 5
|
||||
done
|
||||
}
|
||||
if fetch_checkout_ref "$CHECKOUT_REF"; then
|
||||
:
|
||||
else
|
||||
fetch_status="$?"
|
||||
if [ "$fetch_status" = "124" ] || [ "$fetch_status" = "137" ]; then
|
||||
echo "::error::checkout fetch for '$CHECKOUT_REF' timed out"
|
||||
exit "$fetch_status"
|
||||
fi
|
||||
if ! git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_REF}:refs/remotes/origin/checkout"; then
|
||||
if [ "$GITHUB_EVENT_NAME" != "workflow_dispatch" ] || [ "$CHECKOUT_REF" = "$CHECKOUT_FALLBACK_REF" ]; then
|
||||
exit "$fetch_status"
|
||||
exit 1
|
||||
fi
|
||||
echo "::warning::workflow_dispatch target_ref '$CHECKOUT_REF' is unavailable; falling back to head SHA '$CHECKOUT_FALLBACK_REF'"
|
||||
fetch_checkout_ref "$CHECKOUT_FALLBACK_REF"
|
||||
git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_FALLBACK_REF}:refs/remotes/origin/checkout"
|
||||
fi
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
@@ -347,38 +321,12 @@ jobs:
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
fetch_checkout_ref() {
|
||||
local ref="$1"
|
||||
local fetch_status
|
||||
for attempt in 1 2 3; do
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${ref}:refs/remotes/origin/checkout" && return 0
|
||||
fetch_status="$?"
|
||||
if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then
|
||||
return "$fetch_status"
|
||||
fi
|
||||
if [ "$attempt" = "3" ]; then
|
||||
return "$fetch_status"
|
||||
fi
|
||||
echo "::warning::checkout fetch for '$ref' timed out on attempt $attempt; retrying"
|
||||
sleep 5
|
||||
done
|
||||
}
|
||||
if fetch_checkout_ref "$CHECKOUT_REF"; then
|
||||
:
|
||||
else
|
||||
fetch_status="$?"
|
||||
if [ "$fetch_status" = "124" ] || [ "$fetch_status" = "137" ]; then
|
||||
echo "::error::checkout fetch for '$CHECKOUT_REF' timed out"
|
||||
exit "$fetch_status"
|
||||
fi
|
||||
if ! git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_REF}:refs/remotes/origin/checkout"; then
|
||||
if [ "$GITHUB_EVENT_NAME" != "workflow_dispatch" ] || [ "$CHECKOUT_REF" = "$CHECKOUT_FALLBACK_REF" ]; then
|
||||
exit "$fetch_status"
|
||||
exit 1
|
||||
fi
|
||||
echo "::warning::workflow_dispatch target_ref '$CHECKOUT_REF' is unavailable; falling back to head SHA '$CHECKOUT_FALLBACK_REF'"
|
||||
fetch_checkout_ref "$CHECKOUT_FALLBACK_REF"
|
||||
git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_FALLBACK_REF}:refs/remotes/origin/checkout"
|
||||
fi
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
@@ -466,66 +414,6 @@ jobs:
|
||||
- name: Audit production dependencies
|
||||
run: node scripts/pre-commit/pnpm-audit-prod.mjs --audit-level=high
|
||||
|
||||
# Warm the lockfile- and pnpm-pinned store without blocking Linux Node shards.
|
||||
# On a cold key this job owns the save for later workflow runs.
|
||||
pnpm-store-warmup:
|
||||
permissions:
|
||||
contents: read
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_node == 'true' || 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') }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
shell: bash
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
workdir="$GITHUB_WORKSPACE"
|
||||
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"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
|
||||
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"
|
||||
save-actions-cache: "true"
|
||||
|
||||
# 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.
|
||||
@@ -534,7 +422,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-32vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 20
|
||||
outputs:
|
||||
channels-result: ${{ steps.built_artifact_checks.outputs['channels-result'] }}
|
||||
@@ -597,14 +485,6 @@ jobs:
|
||||
with:
|
||||
install-bun: "false"
|
||||
|
||||
- name: Restore build-all step cache
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: .artifacts/build-all-cache
|
||||
key: ${{ runner.os }}-build-all-v3-${{ hashFiles('package.json', 'pnpm-lock.yaml', 'npm-shrinkwrap.json', 'packages/plugin-sdk/package.json', 'packages/llm-core/package.json', 'packages/model-catalog-core/package.json', 'packages/memory-host-sdk/package.json', 'scripts/build-all.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entries.mjs', 'tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'src/plugin-sdk/**', 'packages/llm-core/src/**', 'packages/model-catalog-core/src/**', 'packages/memory-host-sdk/src/**', 'src/types/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'scripts/copy-export-html-templates.ts', 'scripts/lib/copy-assets.ts', 'src/auto-reply/reply/export-html/**') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-all-v3-
|
||||
|
||||
- name: Build dist
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
@@ -702,6 +582,20 @@ jobs:
|
||||
pids+=("$!")
|
||||
}
|
||||
|
||||
if [ "$RUN_GATEWAY_WATCH" = "true" ]; then
|
||||
gateway_watch_log="${RUNNER_TEMP}/gateway-watch.log"
|
||||
echo "starting gateway-watch: node scripts/check-gateway-watch-regression.mjs --skip-build --ready-timeout-ms 5000"
|
||||
if node scripts/check-gateway-watch-regression.mjs --skip-build --ready-timeout-ms 5000 >"$gateway_watch_log" 2>&1; then
|
||||
result="success"
|
||||
else
|
||||
result="failure"
|
||||
fi
|
||||
echo "::group::gateway-watch log"
|
||||
cat "$gateway_watch_log"
|
||||
echo "::endgroup::"
|
||||
results["gateway-watch"]="$result"
|
||||
fi
|
||||
|
||||
if [ "$RUN_CHANNELS" = "true" ]; then
|
||||
start_check "channels" env \
|
||||
NODE_OPTIONS=--max-old-space-size=8192 \
|
||||
@@ -716,11 +610,6 @@ jobs:
|
||||
node scripts/run-vitest.mjs run --config test/vitest/vitest.full-core-support-boundary.config.ts
|
||||
fi
|
||||
|
||||
if [ "$RUN_GATEWAY_WATCH" = "true" ]; then
|
||||
start_check "gateway-watch" \
|
||||
node scripts/check-gateway-watch-regression.mjs --skip-build --ready-timeout-ms 5000
|
||||
fi
|
||||
|
||||
for index in "${!pids[@]}"; do
|
||||
name="${names[$index]}"
|
||||
log="${logs[$index]}"
|
||||
@@ -834,10 +723,10 @@ jobs:
|
||||
;;
|
||||
contracts-plugins-ci-routing)
|
||||
pnpm test:contracts:plugins
|
||||
pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/changed-lanes.test.ts test/scripts/run-vitest.test.ts test/scripts/test-projects.test.ts
|
||||
pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/test-projects.test.ts
|
||||
;;
|
||||
ci-routing)
|
||||
pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/changed-lanes.test.ts test/scripts/run-vitest.test.ts test/scripts/test-projects.test.ts
|
||||
pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/test-projects.test.ts
|
||||
;;
|
||||
bun-launcher)
|
||||
OPENCLAW_TEST_BUN_LAUNCHER=1 pnpm test test/openclaw-launcher.e2e.test.ts
|
||||
@@ -1079,13 +968,14 @@ jobs:
|
||||
node openclaw.mjs --help
|
||||
node openclaw.mjs status --json --timeout 1
|
||||
pnpm test:build:singleton
|
||||
|
||||
checks-node-core-test-nondist-shard:
|
||||
permissions:
|
||||
contents: read
|
||||
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 || 'blacksmith-8vcpu-ubuntu-2404') || 'ubuntu-24.04') }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (matrix.runner || 'ubuntu-24.04') || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -1150,7 +1040,6 @@ jobs:
|
||||
OPENCLAW_NODE_TEST_CONFIGS_JSON: ${{ toJson(matrix.configs) }}
|
||||
OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON: ${{ toJson(matrix.includePatterns) }}
|
||||
OPENCLAW_VITEST_SHARD_NAME: ${{ matrix.shard_name }}
|
||||
OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS: "900000"
|
||||
OPENCLAW_TEST_PROJECTS_PARALLEL: "2"
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -1192,7 +1081,7 @@ 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 || 'blacksmith-4vcpu-ubuntu-2404') || 'ubuntu-24.04') }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && matrix.runner || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -1283,7 +1172,7 @@ jobs:
|
||||
pnpm lint:auth:pairing-account-scope
|
||||
pnpm check:import-cycles
|
||||
# build-artifacts already runs the tsdown/runtime build for the same Node-relevant changes.
|
||||
NODE_OPTIONS=--max-old-space-size=8192 pnpm build:plugin-sdk:strict-smoke
|
||||
pnpm build:plugin-sdk:strict-smoke
|
||||
;;
|
||||
prod-types)
|
||||
pnpm tsgo:prod
|
||||
@@ -1403,7 +1292,7 @@ jobs:
|
||||
packages/plugin-sdk/dist
|
||||
extensions/*/dist/.boundary-tsc.tsbuildinfo
|
||||
extensions/*/dist/.boundary-tsc.stamp
|
||||
key: ${{ runner.os }}-extension-package-boundary-v1-${{ hashFiles('tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'packages/plugin-sdk/tsconfig.json', 'packages/llm-core/package.json', 'packages/model-catalog-core/package.json', 'scripts/check-extension-package-tsc-boundary.mjs', 'scripts/prepare-extension-package-boundary-artifacts.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entrypoints.json', 'scripts/lib/plugin-sdk-entries.mjs', 'src/plugin-sdk/**', 'src/auto-reply/**', 'packages/llm-core/src/**', 'packages/model-catalog-core/src/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'src/types/**', 'extensions/**', 'extensions/tsconfig.package-boundary*.json', 'package.json', 'pnpm-lock.yaml') }}
|
||||
key: ${{ runner.os }}-extension-package-boundary-v1-${{ hashFiles('tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'packages/plugin-sdk/tsconfig.json', 'scripts/check-extension-package-tsc-boundary.mjs', 'scripts/prepare-extension-package-boundary-artifacts.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entrypoints.json', 'scripts/lib/plugin-sdk-entries.mjs', 'src/plugin-sdk/**', 'src/auto-reply/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'src/types/**', 'extensions/**', 'extensions/tsconfig.package-boundary*.json', 'package.json', 'pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-extension-package-boundary-v1-
|
||||
|
||||
@@ -1420,22 +1309,10 @@ jobs:
|
||||
find src \
|
||||
-type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.mts' -o -name '*.cts' -o -name '*.js' -o -name '*.mjs' -o -name '*.json' \) \
|
||||
-exec touch -t 200001010000 {} +
|
||||
if [ -d packages/llm-core/src ]; then
|
||||
find packages/llm-core/src \
|
||||
-type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.mts' -o -name '*.cts' -o -name '*.js' -o -name '*.mjs' -o -name '*.json' \) \
|
||||
-exec touch -t 200001010000 {} +
|
||||
fi
|
||||
if [ -d packages/model-catalog-core/src ]; then
|
||||
find packages/model-catalog-core/src \
|
||||
-type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.mts' -o -name '*.cts' -o -name '*.js' -o -name '*.mjs' -o -name '*.json' \) \
|
||||
-exec touch -t 200001010000 {} +
|
||||
fi
|
||||
cache_inputs=(
|
||||
touch -t 200001010000 \
|
||||
tsconfig.json \
|
||||
tsconfig.plugin-sdk.dts.json \
|
||||
packages/plugin-sdk/tsconfig.json \
|
||||
packages/llm-core/package.json \
|
||||
packages/model-catalog-core/package.json \
|
||||
scripts/check-extension-package-tsc-boundary.mjs \
|
||||
scripts/prepare-extension-package-boundary-artifacts.mjs \
|
||||
scripts/write-plugin-sdk-entry-dts.ts \
|
||||
@@ -1443,12 +1320,6 @@ jobs:
|
||||
scripts/lib/plugin-sdk-entries.mjs \
|
||||
package.json \
|
||||
pnpm-lock.yaml
|
||||
)
|
||||
for cache_input in "${cache_inputs[@]}"; do
|
||||
if [ -e "$cache_input" ]; then
|
||||
touch -t 200001010000 "$cache_input"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Run additional check shard
|
||||
env:
|
||||
@@ -1563,44 +1434,11 @@ jobs:
|
||||
- name: Checkout ClawHub docs source
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
workdir="$GITHUB_WORKSPACE/clawhub-source"
|
||||
started_at="$(date +%s)"
|
||||
|
||||
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 -C "$workdir" config gc.auto 0
|
||||
git -C "$workdir" remote add origin "https://github.com/openclaw/clawhub.git"
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+refs/heads/main:refs/remotes/origin/checkout" || return 1
|
||||
|
||||
git -C "$workdir" checkout --force --detach refs/remotes/origin/checkout || return 1
|
||||
echo "ClawHub checkout attempt ${attempt}/5 succeeded"
|
||||
}
|
||||
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if checkout_attempt "$attempt"; then
|
||||
elapsed="$(( $(date +%s) - started_at ))"
|
||||
echo "ClawHub checkout completed in ${elapsed}s"
|
||||
exit 0
|
||||
fi
|
||||
echo "ClawHub checkout attempt ${attempt}/5 failed"
|
||||
sleep $((attempt * 5))
|
||||
done
|
||||
|
||||
echo "ClawHub checkout failed after 5 attempts" >&2
|
||||
exit 1
|
||||
git init clawhub-source
|
||||
git -C clawhub-source config gc.auto 0
|
||||
git -C clawhub-source remote add origin "https://github.com/openclaw/clawhub.git"
|
||||
git -C clawhub-source fetch --no-tags --depth=1 origin "+HEAD:refs/remotes/origin/checkout"
|
||||
git -C clawhub-source checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Check docs
|
||||
env:
|
||||
@@ -1624,25 +1462,7 @@ jobs:
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
fetch_checkout_ref() {
|
||||
local fetch_status
|
||||
for attempt in 1 2 3; do
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout" && return 0
|
||||
fetch_status="$?"
|
||||
if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then
|
||||
return "$fetch_status"
|
||||
fi
|
||||
if [ "$attempt" = "3" ]; then
|
||||
return "$fetch_status"
|
||||
fi
|
||||
echo "::warning::checkout fetch for '$CHECKOUT_SHA' timed out on attempt $attempt; retrying"
|
||||
sleep 5
|
||||
done
|
||||
}
|
||||
fetch_checkout_ref
|
||||
git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Setup Python
|
||||
@@ -1690,28 +1510,7 @@ jobs:
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
fetch_timeout_seconds=90
|
||||
fetch_checkout_ref() {
|
||||
git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout" &
|
||||
local fetch_pid="$!"
|
||||
local elapsed=0
|
||||
while kill -0 "$fetch_pid" 2>/dev/null; do
|
||||
if [ "$elapsed" -ge "$fetch_timeout_seconds" ]; then
|
||||
kill -TERM "$fetch_pid" 2>/dev/null || true
|
||||
sleep 10
|
||||
kill -KILL "$fetch_pid" 2>/dev/null || true
|
||||
wait "$fetch_pid" || true
|
||||
return 124
|
||||
fi
|
||||
sleep 1
|
||||
elapsed=$((elapsed + 1))
|
||||
done
|
||||
wait "$fetch_pid"
|
||||
}
|
||||
fetch_checkout_ref
|
||||
git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Try to exclude workspace from Windows Defender (best-effort)
|
||||
@@ -1796,7 +1595,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-15' || (github.repository == 'openclaw/openclaw' && 'blacksmith-6vcpu-macos-15' || 'macos-15') }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-latest' || (github.repository == 'openclaw/openclaw' && 'blacksmith-6vcpu-macos-latest' || 'macos-latest') }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -1811,28 +1610,7 @@ jobs:
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
fetch_timeout_seconds=90
|
||||
fetch_checkout_ref() {
|
||||
git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout" &
|
||||
local fetch_pid="$!"
|
||||
local elapsed=0
|
||||
while kill -0 "$fetch_pid" 2>/dev/null; do
|
||||
if [ "$elapsed" -ge "$fetch_timeout_seconds" ]; then
|
||||
kill -TERM "$fetch_pid" 2>/dev/null || true
|
||||
sleep 10
|
||||
kill -KILL "$fetch_pid" 2>/dev/null || true
|
||||
wait "$fetch_pid" || true
|
||||
return 124
|
||||
fi
|
||||
sleep 1
|
||||
elapsed=$((elapsed + 1))
|
||||
done
|
||||
wait "$fetch_pid"
|
||||
}
|
||||
fetch_checkout_ref
|
||||
git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Setup Node environment
|
||||
@@ -1866,7 +1644,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-26' || 'macos-26') }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-26' || (github.repository == 'openclaw/openclaw' && 'blacksmith-12vcpu-macos-latest' || 'macos-26') }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -1878,28 +1656,7 @@ jobs:
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
fetch_timeout_seconds=90
|
||||
fetch_checkout_ref() {
|
||||
git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout" &
|
||||
local fetch_pid="$!"
|
||||
local elapsed=0
|
||||
while kill -0 "$fetch_pid" 2>/dev/null; do
|
||||
if [ "$elapsed" -ge "$fetch_timeout_seconds" ]; then
|
||||
kill -TERM "$fetch_pid" 2>/dev/null || true
|
||||
sleep 10
|
||||
kill -KILL "$fetch_pid" 2>/dev/null || true
|
||||
wait "$fetch_pid" || true
|
||||
return 124
|
||||
fi
|
||||
sleep 1
|
||||
elapsed=$((elapsed + 1))
|
||||
done
|
||||
wait "$fetch_pid"
|
||||
}
|
||||
fetch_checkout_ref
|
||||
git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Install XcodeGen / SwiftLint / SwiftFormat
|
||||
@@ -1971,21 +1728,6 @@ jobs:
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: OpenClawKit Talk-trait opt-out (no ElevenLabsKit when default traits disabled)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Guard: chat-only consumers build OpenClawKit with the Talk trait
|
||||
# disabled and must NOT link ElevenLabsKit. Assert that future sources
|
||||
# under OpenClawKit cannot silently reintroduce an unconditional
|
||||
# ElevenLabsKit dependency while the manifest still looks correct.
|
||||
deps="$(swift package --package-path apps/shared/OpenClawKit show-dependencies --disable-default-traits)"
|
||||
echo "$deps"
|
||||
if grep -qi 'elevenlabs' <<<"$deps"; then
|
||||
echo "::error::ElevenLabsKit resolved with the Talk trait disabled; keep it gated behind the Talk trait."
|
||||
exit 1
|
||||
fi
|
||||
swift build --package-path apps/shared/OpenClawKit --target OpenClawKit --disable-default-traits
|
||||
|
||||
- name: Swift test
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -2125,53 +1867,3 @@ jobs:
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
ci-timings-summary:
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
name: ci-timings-summary
|
||||
needs:
|
||||
- preflight
|
||||
- security-fast
|
||||
- pnpm-store-warmup
|
||||
- build-artifacts
|
||||
- checks-fast-core
|
||||
- checks-fast-plugin-contracts-shard
|
||||
- checks-fast-channel-contracts-shard
|
||||
- checks-node-compat
|
||||
- checks-node-core-test-nondist-shard
|
||||
- check-shard
|
||||
- check-additional-shard
|
||||
- check-docs
|
||||
- skills-python
|
||||
- checks-windows
|
||||
- macos-node
|
||||
- macos-swift
|
||||
- android
|
||||
if: ${{ !cancelled() && always() && github.event_name != 'push' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout timing summary helper
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || needs.preflight.outputs.checkout_revision || github.sha }}
|
||||
fetch-depth: 1
|
||||
fetch-tags: false
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Write CI timing summary
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
node scripts/ci-run-timings.mjs "$GITHUB_RUN_ID" --limit 25 > ci-timings-summary.txt
|
||||
cat ci-timings-summary.txt >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload CI timing summary
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ci-timings-summary
|
||||
path: ci-timings-summary.txt
|
||||
retention-days: 14
|
||||
|
||||
9
.github/workflows/clawsweeper-dispatch.yml
vendored
9
.github/workflows/clawsweeper-dispatch.yml
vendored
@@ -24,14 +24,7 @@ concurrency:
|
||||
jobs:
|
||||
dispatch:
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
${{
|
||||
github.event_name == 'issue_comment' ||
|
||||
!(
|
||||
endsWith(github.actor, '[bot]') &&
|
||||
(github.event.action == 'labeled' || github.event.action == 'unlabeled')
|
||||
)
|
||||
}}
|
||||
if: ${{ github.event_name == 'issue_comment' || !(endsWith(github.actor, '[bot]') && (github.event.action == 'labeled' || github.event.action == 'unlabeled')) }}
|
||||
env:
|
||||
HAS_CLAWSWEEPER_APP_PRIVATE_KEY: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY != '' }}
|
||||
CLAWSWEEPER_APP_CLIENT_ID: Iv23liOECG0slfuhz093
|
||||
|
||||
45
.github/workflows/codeql-critical-quality.yml
vendored
45
.github/workflows/codeql-critical-quality.yml
vendored
@@ -33,7 +33,6 @@ on:
|
||||
- "packages/plugin-package-contract/**"
|
||||
- "packages/plugin-sdk/**"
|
||||
- "packages/memory-host-sdk/**"
|
||||
- "packages/net-policy/**"
|
||||
- "src/*.ts"
|
||||
- "src/**/*.ts"
|
||||
- "src/config/**"
|
||||
@@ -107,13 +106,13 @@ on:
|
||||
- "src/gateway/**/*auth*.ts"
|
||||
- "src/gateway/*secret*.ts"
|
||||
- "src/gateway/**/*secret*.ts"
|
||||
- "packages/gateway-protocol/src/**/*secret*.ts"
|
||||
- "src/gateway/protocol/**/*secret*.ts"
|
||||
- "src/gateway/resolve-configured-secret-input-string*.ts"
|
||||
- "src/gateway/security-path*.ts"
|
||||
- "src/gateway/server-methods/secrets*.ts"
|
||||
- "src/gateway/server-startup-memory.ts"
|
||||
- "src/gateway/method-scopes.ts"
|
||||
- "packages/gateway-protocol/src/**"
|
||||
- "src/gateway/protocol/**"
|
||||
- "src/gateway/server-methods/**"
|
||||
- "src/gateway/server-methods.ts"
|
||||
- "src/gateway/server-methods-list.ts"
|
||||
@@ -210,9 +209,6 @@ jobs:
|
||||
else
|
||||
while IFS= read -r file; do
|
||||
case "${file}" in
|
||||
.github/codeql/codeql-network-runtime-boundary-critical-quality.yml|.github/codeql/openclaw-boundary/queries/raw-socket-callsite-classification.ql|.github/codeql/openclaw-boundary/queries/managed-proxy-runtime-mutation.ql)
|
||||
network_runtime=true
|
||||
;;
|
||||
.github/codeql/*|.github/workflows/codeql-critical-quality.yml)
|
||||
agent=true
|
||||
channel=true
|
||||
@@ -225,6 +221,7 @@ jobs:
|
||||
plugin_sdk_package=true
|
||||
plugin_sdk_reply=true
|
||||
provider=true
|
||||
network_runtime=true
|
||||
session_diagnostics=true
|
||||
;;
|
||||
src/agents/sessions/tools/*)
|
||||
@@ -247,14 +244,14 @@ jobs:
|
||||
src/config/*)
|
||||
config=true
|
||||
;;
|
||||
packages/gateway-protocol/src/*secret*.ts|packages/gateway-protocol/src/**/*secret*.ts|src/gateway/server-methods/secrets*.ts)
|
||||
src/gateway/protocol/*secret*.ts|src/gateway/server-methods/secrets*.ts)
|
||||
core_auth_secrets=true
|
||||
gateway=true
|
||||
;;
|
||||
src/agents/*auth*.ts|src/agents/auth-health*.ts|src/agents/auth-profiles|src/agents/auth-profiles/*|src/agents/bash-tools.exec-host-shared.ts|src/agents/sandbox|src/agents/sandbox.ts|src/agents/sandbox-*.ts|src/agents/sandbox/*|src/cron/service/jobs.ts|src/cron/stagger.ts|src/gateway/*auth*.ts|src/gateway/*secret*.ts|src/gateway/resolve-configured-secret-input-string*.ts|src/gateway/security-path*.ts|src/infra/secret-file*.ts|src/secrets/*|src/security/*)
|
||||
core_auth_secrets=true
|
||||
;;
|
||||
packages/gateway-protocol/src/*|packages/gateway-protocol/src/**/*|src/gateway/method-scopes.ts|src/gateway/server-methods/*|src/gateway/server-methods.ts|src/gateway/server-methods-list.ts)
|
||||
src/gateway/method-scopes.ts|src/gateway/protocol/*|src/gateway/server-methods/*|src/gateway/server-methods.ts|src/gateway/server-methods-list.ts)
|
||||
gateway=true
|
||||
;;
|
||||
packages/memory-host-sdk/*|src/commands/doctor-cron-dreaming-payload-migration.ts|src/commands/doctor-memory-search.ts|src/gateway/server-startup-memory.ts|src/memory/*|src/memory-host-sdk/*)
|
||||
@@ -304,9 +301,7 @@ jobs:
|
||||
esac
|
||||
|
||||
case "${file}" in
|
||||
src/**/*.test.ts|src/**/*.test.tsx|extensions/**/*.test.ts|extensions/**/*.test.tsx)
|
||||
;;
|
||||
packages/net-policy/src/*|packages/net-policy/src/**/*|src/cli/gateway-cli/run-loop.ts|src/infra/net/*|src/infra/net/**/*|src/infra/ssh-tunnel.ts|src/infra/gateway-lock.ts|src/infra/jsonl-socket.ts|src/infra/push-apns-http2.ts|src/proxy-capture/*|src/proxy-capture/**/*|extensions/codex-supervisor/src/json-rpc-client.ts|extensions/irc/src/*|extensions/qa-lab/src/*)
|
||||
src/*.ts|src/**/*.ts|extensions/*.ts|extensions/**/*.ts)
|
||||
network_runtime=true
|
||||
;;
|
||||
esac
|
||||
@@ -433,33 +428,7 @@ jobs:
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Fast PR network boundary diff scan
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
added_lines="$(mktemp)"
|
||||
gh api --paginate "repos/${REPOSITORY}/pulls/${PR_NUMBER}/files" --jq '
|
||||
.[]
|
||||
| select(.filename | test("^(src/cli/gateway-cli/run-loop\\.ts|src/infra/(gateway-lock|jsonl-socket|push-apns-http2|ssh-tunnel)\\.ts|src/infra/net/|src/proxy-capture/|extensions/codex-supervisor/src/json-rpc-client\\.ts|extensions/irc/src/|extensions/qa-lab/src/|packages/net-policy/src/)"))
|
||||
| .filename as $file
|
||||
| (.patch // "")
|
||||
| split("\n")[]
|
||||
| select(startswith("+") and (startswith("+++") | not))
|
||||
| "\($file): \(.)"
|
||||
' > "$added_lines"
|
||||
|
||||
if grep -En '(from|require\().*["'\''](node:)?(net|tls|http2)["'\'']|\b(net|tls|http2)\.(connect|createConnection)\b|new Socket\(|HTTP_PROXY|HTTPS_PROXY|NO_PROXY|GLOBAL_AGENT_|OPENCLAW_PROXY_' "$added_lines"; then
|
||||
echo "Network runtime boundary-sensitive added lines require full CodeQL review." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Initialize CodeQL
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
@@ -467,14 +436,12 @@ jobs:
|
||||
|
||||
- name: Analyze
|
||||
id: analyze
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
output: sarif-results
|
||||
category: "/codeql-critical-quality/network-runtime-boundary"
|
||||
|
||||
- name: Fail on network runtime boundary findings
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
env:
|
||||
SARIF_OUTPUT: sarif-results
|
||||
run: |
|
||||
|
||||
@@ -20,7 +20,7 @@ permissions:
|
||||
jobs:
|
||||
macos:
|
||||
name: Critical Security (macOS)
|
||||
runs-on: blacksmith-6vcpu-macos-15
|
||||
runs-on: blacksmith-6vcpu-macos-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
11
.github/workflows/codeql.yml
vendored
11
.github/workflows/codeql.yml
vendored
@@ -85,21 +85,10 @@ jobs:
|
||||
config_file: ./.github/codeql/codeql-actions-critical-security.yml
|
||||
steps:
|
||||
- name: Checkout
|
||||
if: ${{ matrix.category != 'actions' }}
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Checkout Actions security sources
|
||||
if: ${{ matrix.category == 'actions' }}
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
submodules: false
|
||||
sparse-checkout: |
|
||||
.github/actions
|
||||
.github/workflows
|
||||
.github/codeql
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
|
||||
@@ -138,7 +138,7 @@ jobs:
|
||||
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-8' || vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
|
||||
OPENCLAW_CONTROL_UI_I18N_MODEL: ${{ secrets.ANTHROPIC_API_KEY != '' && 'claude-opus-4-7' || vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
|
||||
OPENCLAW_CONTROL_UI_I18N_THINKING: low
|
||||
OPENCLAW_CONTROL_UI_I18N_AUTH_OPTIONAL: "1"
|
||||
LOCALE: ${{ matrix.locale }}
|
||||
|
||||
31
.github/workflows/crabbox-hydrate.yml
vendored
31
.github/workflows/crabbox-hydrate.yml
vendored
@@ -137,10 +137,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
|
||||
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
timeout --signal=TERM --kill-after=10s 30s git \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main"
|
||||
git fetch --no-tags --depth=50 origin "+refs/heads/main:refs/remotes/origin/main"
|
||||
fi
|
||||
|
||||
- name: Prepare Crabbox shell
|
||||
@@ -321,26 +318,7 @@ jobs:
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if (git rev-parse --is-inside-work-tree 2>$null) {
|
||||
$repo = (Get-Location).Path
|
||||
$fetchInfo = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$fetchInfo.FileName = "git"
|
||||
$fetchInfo.WorkingDirectory = $repo
|
||||
$fetchInfo.UseShellExecute = $false
|
||||
$fetchInfo.Arguments = '-c protocol.version=2 fetch --no-tags --no-progress --prune --no-recurse-submodules --depth=50 origin "+refs/heads/main:refs/remotes/origin/main"'
|
||||
|
||||
$fetch = New-Object System.Diagnostics.Process
|
||||
$fetch.StartInfo = $fetchInfo
|
||||
if (-not $fetch.Start()) {
|
||||
throw "git fetch failed to start"
|
||||
}
|
||||
if (-not $fetch.WaitForExit(30000)) {
|
||||
$fetch.Kill()
|
||||
$fetch.WaitForExit()
|
||||
throw "git fetch timed out after 30 seconds"
|
||||
}
|
||||
if ($fetch.ExitCode -ne 0) {
|
||||
throw "git fetch failed with exit code $($fetch.ExitCode)"
|
||||
}
|
||||
git fetch --no-tags --depth=50 origin "+refs/heads/main:refs/remotes/origin/main"
|
||||
}
|
||||
|
||||
- name: Setup pnpm and dependencies
|
||||
@@ -535,10 +513,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
|
||||
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
timeout --signal=TERM --kill-after=10s 30s git \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main"
|
||||
git fetch --no-tags --depth=50 origin "+refs/heads/main:refs/remotes/origin/main"
|
||||
fi
|
||||
|
||||
node_bin="$(dirname "$(node -p 'process.execPath')")"
|
||||
|
||||
176
.github/workflows/dependency-change-awareness.yml
vendored
Normal file
176
.github/workflows/dependency-change-awareness.yml
vendored
Normal file
@@ -0,0 +1,176 @@
|
||||
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 === "package-lock.json" ||
|
||||
filename === "npm-shrinkwrap.json" ||
|
||||
filename === "pnpm-lock.yaml" ||
|
||||
filename === "pnpm-workspace.yaml" ||
|
||||
filename === "ui/package.json" ||
|
||||
filename.startsWith("patches/") ||
|
||||
/^packages\/[^/]+\/package\.json$/u.test(filename) ||
|
||||
/^extensions\/[^/]+\/package-lock\.json$/u.test(filename) ||
|
||||
/^extensions\/[^/]+\/npm-shrinkwrap\.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, shrinkwrap, or workspace dependency policy changes are present.",
|
||||
"- Treat `package-lock.json` and `npm-shrinkwrap.json` diffs as security-review surfaces.",
|
||||
"- 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).`);
|
||||
109
.github/workflows/dependency-guard.yml
vendored
109
.github/workflows/dependency-guard.yml
vendored
@@ -1,109 +0,0 @@
|
||||
name: Dependency Guard
|
||||
|
||||
on:
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers] checks trusted base script only; never checks out PR head
|
||||
types: [opened, reopened, synchronize, ready_for_review]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: dependency-guard-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
dependency-guard-detect:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
autoscrub: ${{ steps.guard.outputs.autoscrub }}
|
||||
autoscrub-owner: ${{ steps.guard.outputs.autoscrub-owner }}
|
||||
autoscrub-repository: ${{ steps.guard.outputs.autoscrub-repository }}
|
||||
steps:
|
||||
- name: Check out trusted base workflow scripts
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Detect dependency changes
|
||||
id: guard
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
OPENCLAW_DEPENDENCY_GUARD_MODE: detect
|
||||
OPENCLAW_SECURITY_APPROVERS: vincentkoc,steipete,joshavant
|
||||
OPENCLAW_SECURITY_TEAM_SLUG: openclaw-secops
|
||||
run: node scripts/github/dependency-guard.mjs
|
||||
|
||||
dependency-guard-autoscrub:
|
||||
if: ${{ !github.event.pull_request.draft && needs.dependency-guard-detect.outputs.autoscrub == 'true' }}
|
||||
needs: dependency-guard-detect
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Check out trusted base workflow scripts
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Create autoscrub app token
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
owner: ${{ needs.dependency-guard-detect.outputs.autoscrub-owner }}
|
||||
repositories: ${{ needs.dependency-guard-detect.outputs.autoscrub-repository }}
|
||||
permission-contents: write
|
||||
|
||||
- name: Create fallback autoscrub app token
|
||||
id: app-token-fallback
|
||||
continue-on-error: true
|
||||
if: steps.app-token.outcome == 'failure'
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
app-id: "2971289"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
|
||||
owner: ${{ needs.dependency-guard-detect.outputs.autoscrub-owner }}
|
||||
repositories: ${{ needs.dependency-guard-detect.outputs.autoscrub-repository }}
|
||||
permission-contents: write
|
||||
|
||||
- name: Remove package lockfile changes
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
OPENCLAW_DEPENDENCY_GUARD_AUTOSCRUB_TOKEN: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
OPENCLAW_DEPENDENCY_GUARD_MODE: autoscrub
|
||||
OPENCLAW_SECURITY_APPROVERS: vincentkoc,steipete,joshavant
|
||||
OPENCLAW_SECURITY_TEAM_SLUG: openclaw-secops
|
||||
run: node scripts/github/dependency-guard.mjs
|
||||
|
||||
dependency-guard:
|
||||
if: ${{ !github.event.pull_request.draft && always() }}
|
||||
needs:
|
||||
- dependency-guard-detect
|
||||
- dependency-guard-autoscrub
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check out trusted base workflow scripts
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Enforce dependency guard
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
OPENCLAW_DEPENDENCY_GUARD_MODE: enforce
|
||||
OPENCLAW_SECURITY_APPROVERS: vincentkoc,steipete,joshavant
|
||||
OPENCLAW_SECURITY_TEAM_SLUG: openclaw-secops
|
||||
run: node scripts/github/dependency-guard.mjs
|
||||
2
.github/workflows/docs-sync-publish.yml
vendored
2
.github/workflows/docs-sync-publish.yml
vendored
@@ -43,7 +43,7 @@ jobs:
|
||||
if: env.OPENCLAW_DOCS_SYNC_TOKEN != ''
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "22.19.0"
|
||||
node-version: "24.x"
|
||||
|
||||
- name: Clone publish repo
|
||||
if: env.OPENCLAW_DOCS_SYNC_TOKEN != ''
|
||||
|
||||
4
.github/workflows/install-smoke.yml
vendored
4
.github/workflows/install-smoke.yml
vendored
@@ -143,7 +143,7 @@ jobs:
|
||||
for (const [dep, rel] of Object.entries(workspace.patchedDependencies ?? {})) {
|
||||
const absolute = path.join(\"/app\", rel);
|
||||
if (!fs.existsSync(absolute)) {
|
||||
throw new Error(\"missing patch for \" + dep + \": \" + rel);
|
||||
throw new Error(`missing patch for ${dep}: ${rel}`);
|
||||
}
|
||||
}
|
||||
"
|
||||
@@ -337,7 +337,7 @@ jobs:
|
||||
for (const [dep, rel] of Object.entries(workspace.patchedDependencies ?? {})) {
|
||||
const absolute = path.join(\"/app\", rel);
|
||||
if (!fs.existsSync(absolute)) {
|
||||
throw new Error(\"missing patch for \" + dep + \": \" + rel);
|
||||
throw new Error(`missing patch for ${dep}: ${rel}`);
|
||||
}
|
||||
}
|
||||
"
|
||||
|
||||
16
.github/workflows/labeler.yml
vendored
16
.github/workflows/labeler.yml
vendored
@@ -115,7 +115,6 @@ jobs:
|
||||
issue_number: pullRequest.number,
|
||||
per_page: 100,
|
||||
});
|
||||
const labelNames = new Set(currentLabels.map((label) => label.name ?? ""));
|
||||
|
||||
for (const label of currentLabels) {
|
||||
const name = label.name ?? "";
|
||||
@@ -131,17 +130,14 @@ jobs:
|
||||
issue_number: pullRequest.number,
|
||||
name,
|
||||
});
|
||||
labelNames.delete(name);
|
||||
}
|
||||
|
||||
if (!labelNames.has(targetSizeLabel)) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
labels: [targetSizeLabel],
|
||||
});
|
||||
}
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
labels: [targetSizeLabel],
|
||||
});
|
||||
- name: Apply maintainer or trusted-contributor label
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
|
||||
1
.github/workflows/mantis-telegram-live.yml
vendored
1
.github/workflows/mantis-telegram-live.yml
vendored
@@ -377,7 +377,6 @@ jobs:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
|
||||
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
|
||||
OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1"
|
||||
CRABBOX_COORDINATOR: ${{ secrets.CRABBOX_COORDINATOR }}
|
||||
|
||||
1
.github/workflows/npm-telegram-beta-e2e.yml
vendored
1
.github/workflows/npm-telegram-beta-e2e.yml
vendored
@@ -218,7 +218,6 @@ jobs:
|
||||
OPENCLAW_NPM_TELEGRAM_CREDENTIAL_ROLE: ci
|
||||
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
|
||||
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
|
||||
OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1"
|
||||
INPUT_SCENARIO: ${{ inputs.scenario }}
|
||||
|
||||
@@ -451,7 +451,7 @@ jobs:
|
||||
OUTPUT_DIR: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/baseline
|
||||
run: |
|
||||
mkdir -p "${OUTPUT_DIR}"
|
||||
timeout --preserve-status 300s npm pack --ignore-scripts --json "${BASELINE_SPEC}" --pack-destination "${OUTPUT_DIR}" > "${OUTPUT_DIR}/pack.json"
|
||||
npm pack --ignore-scripts --json "${BASELINE_SPEC}" --pack-destination "${OUTPUT_DIR}" > "${OUTPUT_DIR}/pack.json"
|
||||
|
||||
- name: Capture candidate metadata
|
||||
id: candidate_metadata
|
||||
|
||||
@@ -480,35 +480,6 @@ jobs:
|
||||
fi
|
||||
exit 1
|
||||
|
||||
plan_release_workflow_matrices:
|
||||
needs: validate_selected_ref
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
docker_e2e_count: ${{ steps.plan.outputs.docker_e2e_count }}
|
||||
docker_e2e_matrix: ${{ steps.plan.outputs.docker_e2e_matrix }}
|
||||
docker_e2e_omitted_json: ${{ steps.plan.outputs.docker_e2e_omitted_json }}
|
||||
live_models_count: ${{ steps.plan.outputs.live_models_count }}
|
||||
live_models_matrix: ${{ steps.plan.outputs.live_models_matrix }}
|
||||
live_models_omitted_json: ${{ steps.plan.outputs.live_models_omitted_json }}
|
||||
steps:
|
||||
- name: Checkout trusted release harness
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Plan release workflow matrices
|
||||
id: plan
|
||||
env:
|
||||
DOCKER_LANES: ${{ inputs.docker_lanes }}
|
||||
INCLUDE_LIVE_SUITES: ${{ inputs.include_live_suites }}
|
||||
INCLUDE_RELEASE_PATH_SUITES: ${{ inputs.include_release_path_suites }}
|
||||
LIVE_MODEL_PROVIDERS: ${{ inputs.live_model_providers }}
|
||||
LIVE_SUITE_FILTER: ${{ inputs.live_suite_filter }}
|
||||
RELEASE_TEST_PROFILE: ${{ inputs.release_test_profile }}
|
||||
run: node scripts/plan-release-workflow-matrix.mjs >> "$GITHUB_OUTPUT"
|
||||
|
||||
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')
|
||||
@@ -665,15 +636,72 @@ jobs:
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
validate_docker_e2e:
|
||||
needs: [validate_selected_ref, prepare_docker_e2e_image, plan_release_workflow_matrices]
|
||||
if: inputs.include_release_path_suites && inputs.docker_lanes == '' && needs.plan_release_workflow_matrices.outputs.docker_e2e_count != '0'
|
||||
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: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: ${{ matrix.timeout_minutes }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.plan_release_workflow_matrices.outputs.docker_e2e_matrix) }}
|
||||
matrix:
|
||||
include:
|
||||
- chunk_id: core
|
||||
label: core
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
- chunk_id: package-update-openai
|
||||
label: package/update OpenAI install
|
||||
timeout_minutes: 45
|
||||
profiles: beta minimum stable full
|
||||
- chunk_id: package-update-anthropic
|
||||
label: package/update Anthropic install
|
||||
timeout_minutes: 60
|
||||
profiles: beta minimum stable full
|
||||
- chunk_id: package-update-core
|
||||
label: package/update core
|
||||
timeout_minutes: 60
|
||||
profiles: beta minimum stable full
|
||||
- chunk_id: plugins-runtime-plugins
|
||||
label: plugins/runtime plugins
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
- chunk_id: plugins-runtime-services
|
||||
label: plugins/runtime services
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
- chunk_id: plugins-runtime-install-a
|
||||
label: plugins/runtime install A
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
- chunk_id: plugins-runtime-install-b
|
||||
label: plugins/runtime install B
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
- chunk_id: plugins-runtime-install-c
|
||||
label: plugins/runtime install C
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
- chunk_id: plugins-runtime-install-d
|
||||
label: plugins/runtime install D
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
- chunk_id: plugins-runtime-install-e
|
||||
label: plugins/runtime install E
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
- chunk_id: plugins-runtime-install-f
|
||||
label: plugins/runtime install F
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
- chunk_id: plugins-runtime-install-g
|
||||
label: plugins/runtime install G
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
- chunk_id: plugins-runtime-install-h
|
||||
label: plugins/runtime install H
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
@@ -1603,14 +1631,42 @@ jobs:
|
||||
|
||||
validate_live_models_docker:
|
||||
name: Docker live models (${{ matrix.provider_label }})
|
||||
needs: [validate_selected_ref, prepare_live_test_image, plan_release_workflow_matrices]
|
||||
if: inputs.include_live_suites && inputs.live_model_providers == '' && (inputs.live_suite_filter == '' || inputs.live_suite_filter == 'docker-live-models') && needs.plan_release_workflow_matrices.outputs.live_models_count != '0'
|
||||
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: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.plan_release_workflow_matrices.outputs.live_models_matrix) }}
|
||||
matrix:
|
||||
include:
|
||||
- provider_label: Anthropic
|
||||
providers: anthropic
|
||||
profiles: stable full
|
||||
- provider_label: Google
|
||||
providers: google
|
||||
profiles: stable full
|
||||
- provider_label: MiniMax
|
||||
providers: minimax
|
||||
profiles: stable full
|
||||
- provider_label: OpenAI
|
||||
providers: openai
|
||||
profiles: beta minimum stable full
|
||||
- provider_label: OpenCode
|
||||
providers: opencode-go
|
||||
profiles: full
|
||||
- provider_label: OpenRouter
|
||||
providers: openrouter
|
||||
profiles: full
|
||||
- provider_label: xAI
|
||||
providers: xai
|
||||
profiles: full
|
||||
- provider_label: Z.ai
|
||||
providers: zai
|
||||
profiles: full
|
||||
- provider_label: Fireworks
|
||||
providers: fireworks
|
||||
profiles: full
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
@@ -1688,8 +1744,6 @@ jobs:
|
||||
- name: Validate provider credential
|
||||
if: contains(matrix.profiles, inputs.release_test_profile)
|
||||
shell: bash
|
||||
env:
|
||||
LIVE_MODEL_PROVIDERS: ${{ matrix.providers }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -1706,7 +1760,7 @@ jobs:
|
||||
exit 1
|
||||
}
|
||||
|
||||
case "${LIVE_MODEL_PROVIDERS}" in
|
||||
case "${{ matrix.providers }}" in
|
||||
anthropic) require_any Anthropic ANTHROPIC_API_KEY ANTHROPIC_API_KEY_OLD ANTHROPIC_API_TOKEN ;;
|
||||
google) require_any Google GEMINI_API_KEY GOOGLE_API_KEY ;;
|
||||
minimax) require_any MiniMax MINIMAX_API_KEY ;;
|
||||
@@ -1717,7 +1771,7 @@ jobs:
|
||||
zai) require_any Z.ai ZAI_API_KEY Z_AI_API_KEY ;;
|
||||
fireworks) require_any Fireworks FIREWORKS_API_KEY ;;
|
||||
*)
|
||||
echo "Unhandled live model provider shard: ${LIVE_MODEL_PROVIDERS}" >&2
|
||||
echo "Unhandled live model provider shard: ${{ matrix.providers }}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -1932,7 +1986,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_THINKING=low OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic OPENCLAW_LIVE_GATEWAY_MODELS=anthropic/claude-opus-4-8 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
|
||||
command: OPENCLAW_LIVE_GATEWAY_THINKING=low 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
|
||||
timeout_minutes: 30
|
||||
profile_env_only: false
|
||||
advisory: true
|
||||
@@ -1947,19 +2001,19 @@ jobs:
|
||||
profiles: full
|
||||
- suite_id: native-live-src-gateway-profiles-google
|
||||
label: Native live gateway profiles Google
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=google OPENCLAW_LIVE_GATEWAY_MODELS=google/gemini-3.1-pro-preview node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=google OPENCLAW_LIVE_GATEWAY_MODELS=google/gemini-3.1-pro-preview,google/gemini-3-flash-preview node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
|
||||
timeout_minutes: 60
|
||||
profile_env_only: false
|
||||
profiles: stable full
|
||||
- suite_id: native-live-src-gateway-profiles-minimax
|
||||
label: Native live gateway profiles MiniMax
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MODELS=minimax/MiniMax-M2.7,minimax-portal/MiniMax-M2.7 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
|
||||
timeout_minutes: 60
|
||||
profile_env_only: false
|
||||
profiles: stable full
|
||||
- suite_id: native-live-src-gateway-profiles-openai
|
||||
label: Native live gateway profiles OpenAI
|
||||
command: OPENCLAW_LIVE_GATEWAY_THINKING=off OPENCLAW_LIVE_GATEWAY_PROVIDERS=openai OPENCLAW_LIVE_GATEWAY_MODELS=openai/gpt-5.5 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=180000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=600000 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=openai OPENCLAW_LIVE_GATEWAY_MODELS=openai/gpt-5.5 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
|
||||
timeout_minutes: 60
|
||||
profile_env_only: false
|
||||
profiles: beta minimum stable full
|
||||
@@ -2234,7 +2288,7 @@ jobs:
|
||||
include:
|
||||
- suite_id: live-gateway-docker
|
||||
label: Docker live gateway OpenAI
|
||||
command: OPENCLAW_LIVE_GATEWAY_THINKING=off OPENCLAW_LIVE_GATEWAY_PROVIDERS=openai OPENCLAW_LIVE_GATEWAY_MODELS=openai/gpt-5.5 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=600000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
command: OPENCLAW_LIVE_GATEWAY_THINKING=low OPENCLAW_LIVE_GATEWAY_PROVIDERS=openai OPENCLAW_LIVE_GATEWAY_MODELS=openai/gpt-5.5 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=600000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
timeout_minutes: 40
|
||||
profile_env_only: false
|
||||
profiles: beta minimum stable full
|
||||
@@ -2246,13 +2300,13 @@ jobs:
|
||||
profiles: stable full
|
||||
- suite_id: live-gateway-google-docker
|
||||
label: Docker live gateway Google
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=google OPENCLAW_LIVE_GATEWAY_MODELS=google/gemini-3.1-pro-preview OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=google OPENCLAW_LIVE_GATEWAY_MODELS=google/gemini-3.1-pro-preview,google/gemini-3-flash-preview OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
timeout_minutes: 40
|
||||
profile_env_only: false
|
||||
profiles: stable full
|
||||
- suite_id: live-gateway-minimax-docker
|
||||
label: Docker live gateway MiniMax
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MODELS=minimax/MiniMax-M2.7,minimax-portal/MiniMax-M2.7 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
timeout_minutes: 40
|
||||
profile_env_only: false
|
||||
profiles: stable full
|
||||
|
||||
12
.github/workflows/openclaw-npm-release.yml
vendored
12
.github/workflows/openclaw-npm-release.yml
vendored
@@ -372,11 +372,6 @@ jobs:
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Require trusted workflow ref for publish
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
@@ -434,13 +429,12 @@ jobs:
|
||||
echo "Direct OpenClaw npm publish; relying on this workflow's npm-release environment approval."
|
||||
exit 0
|
||||
fi
|
||||
direct_recovery=false
|
||||
if [[ "${GITHUB_ACTOR}" != "github-actions[bot]" ]]; then
|
||||
direct_recovery=true
|
||||
echo "Direct OpenClaw npm recovery with release_publish_run_id; relying on this workflow's npm-release environment approval."
|
||||
echo "OpenClaw npm publish must be dispatched by the OpenClaw Release Publish workflow, not directly by ${GITHUB_ACTOR}." >&2
|
||||
exit 1
|
||||
fi
|
||||
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
|
||||
printf '%s' "$RUN_JSON" | DIRECT_RELEASE_RECOVERY="${direct_recovery}" node scripts/validate-release-publish-approval.mjs
|
||||
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "OpenClaw Release Publish"], ["headBranch", process.env.EXPECTED_WORKFLOW_BRANCH], ["event", "workflow_dispatch"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } if (run.status !== "in_progress") { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must still be in_progress, got ${run.status ?? "<missing>"}.`); process.exit(1); } if (run.conclusion) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} already concluded ${run.conclusion}.`); process.exit(1); } console.log(`Using release publish approval run ${process.env.RELEASE_PUBLISH_RUN_ID}: ${run.url}`);'
|
||||
|
||||
publish_openclaw_npm:
|
||||
# KEEP THE REAL RELEASE/PUBLISH PATH ON A GITHUB-HOSTED RUNNER.
|
||||
|
||||
@@ -813,7 +813,7 @@ jobs:
|
||||
alt_model="openai/gpt-5.5-alt"
|
||||
;;
|
||||
baseline)
|
||||
model="anthropic/claude-opus-4-8"
|
||||
model="anthropic/claude-opus-4-7"
|
||||
alt_model="anthropic/claude-sonnet-4-6"
|
||||
;;
|
||||
*)
|
||||
@@ -885,7 +885,7 @@ jobs:
|
||||
--candidate-summary .artifacts/qa-e2e/openai-candidate/qa-suite-summary.json \
|
||||
--baseline-summary .artifacts/qa-e2e/anthropic-baseline/qa-suite-summary.json \
|
||||
--candidate-label "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--baseline-label anthropic/claude-opus-4-8 \
|
||||
--baseline-label anthropic/claude-opus-4-7 \
|
||||
--output-dir .artifacts/qa-e2e/parity
|
||||
|
||||
- name: Upload parity artifacts
|
||||
@@ -1207,7 +1207,6 @@ jobs:
|
||||
env:
|
||||
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
|
||||
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
|
||||
OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1"
|
||||
run: |
|
||||
|
||||
@@ -122,10 +122,6 @@ jobs:
|
||||
echo "publish_openclaw_npm=true requires dispatching this workflow from main, release/YYYY.M.D, or a Tideclaw alpha branch for alpha prereleases." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && "${PLUGIN_PUBLISH_SCOPE}" != "all-publishable" ]]; then
|
||||
echo "publish_openclaw_npm=true requires plugin_publish_scope=all-publishable so every publishable official plugin is released with OpenClaw." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${PLUGIN_PUBLISH_SCOPE}" == "selected" && -z "${PLUGINS}" ]]; then
|
||||
echo "plugin_publish_scope=selected requires plugins." >&2
|
||||
exit 1
|
||||
|
||||
6
.github/workflows/opengrep-precise-full.yml
vendored
6
.github/workflows/opengrep-precise-full.yml
vendored
@@ -32,11 +32,11 @@ jobs:
|
||||
- name: Install opengrep
|
||||
env:
|
||||
# Pin both the install script (by commit SHA) and the binary version.
|
||||
# The script SHA must match the v1.22.0 release tag in opengrep/opengrep
|
||||
# The script SHA must match the v1.19.0 release tag in opengrep/opengrep
|
||||
# so a compromised or force-pushed `main` cannot RCE in our CI runner.
|
||||
# Bump both together when upgrading.
|
||||
OPENGREP_VERSION: v1.22.0
|
||||
OPENGREP_INSTALL_SHA: f458d7f0d52cc58eae1ca3cf3d5caf101e637519
|
||||
OPENGREP_VERSION: v1.19.0
|
||||
OPENGREP_INSTALL_SHA: 9a4c0a68220618441608cd2bad4ff2eddccf8113
|
||||
run: |
|
||||
curl -fsSL "https://raw.githubusercontent.com/opengrep/opengrep/${OPENGREP_INSTALL_SHA}/install.sh" \
|
||||
| bash -s -- -v "$OPENGREP_VERSION"
|
||||
|
||||
8
.github/workflows/opengrep-precise.yml
vendored
8
.github/workflows/opengrep-precise.yml
vendored
@@ -44,7 +44,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 1
|
||||
fetch-depth: 0
|
||||
fetch-tags: false
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
@@ -58,11 +58,11 @@ jobs:
|
||||
- name: Install opengrep
|
||||
env:
|
||||
# Pin both the install script (by commit SHA) and the binary version.
|
||||
# The script SHA must match the v1.22.0 release tag in opengrep/opengrep
|
||||
# The script SHA must match the v1.19.0 release tag in opengrep/opengrep
|
||||
# so a compromised or force-pushed `main` cannot RCE in our CI runner.
|
||||
# Bump both together when upgrading.
|
||||
OPENGREP_VERSION: v1.22.0
|
||||
OPENGREP_INSTALL_SHA: f458d7f0d52cc58eae1ca3cf3d5caf101e637519
|
||||
OPENGREP_VERSION: v1.19.0
|
||||
OPENGREP_INSTALL_SHA: 9a4c0a68220618441608cd2bad4ff2eddccf8113
|
||||
run: |
|
||||
curl -fsSL "https://raw.githubusercontent.com/opengrep/opengrep/${OPENGREP_INSTALL_SHA}/install.sh" \
|
||||
| bash -s -- -v "$OPENGREP_VERSION"
|
||||
|
||||
20
.github/workflows/plugin-clawhub-release.yml
vendored
20
.github/workflows/plugin-clawhub-release.yml
vendored
@@ -207,11 +207,6 @@ jobs:
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Validate release publish approval run
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -227,13 +222,12 @@ jobs:
|
||||
echo "Direct Plugin ClawHub Release dispatch; relying on this workflow's clawhub-plugin-release environment approval."
|
||||
exit 0
|
||||
fi
|
||||
direct_recovery=false
|
||||
if [[ "${GITHUB_ACTOR}" != "github-actions[bot]" ]]; then
|
||||
direct_recovery=true
|
||||
echo "Direct Plugin ClawHub Release recovery with release_publish_run_id; relying on this workflow's clawhub-plugin-release environment approval."
|
||||
echo "Plugin ClawHub publish must be dispatched by the OpenClaw Release Publish workflow, not directly by ${GITHUB_ACTOR}." >&2
|
||||
exit 1
|
||||
fi
|
||||
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
|
||||
printf '%s' "$RUN_JSON" | DIRECT_RELEASE_RECOVERY="${direct_recovery}" node scripts/validate-release-publish-approval.mjs
|
||||
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "OpenClaw Release Publish"], ["headBranch", process.env.EXPECTED_WORKFLOW_BRANCH], ["event", "workflow_dispatch"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } if (run.status !== "in_progress") { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must still be in_progress, got ${run.status ?? "<missing>"}.`); process.exit(1); } if (run.conclusion) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} already concluded ${run.conclusion}.`); process.exit(1); } console.log(`Using release publish approval run ${process.env.RELEASE_PUBLISH_RUN_ID}: ${run.url}`);'
|
||||
|
||||
preview_plugin_pack:
|
||||
needs: preview_plugins_clawhub
|
||||
@@ -437,8 +431,7 @@ jobs:
|
||||
EOF
|
||||
echo "CLAWHUB_CONFIG_PATH=${RUNNER_TEMP}/clawhub-config.json" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Check ClawHub package version
|
||||
id: clawhub_package_version
|
||||
- name: Ensure version is not already published
|
||||
env:
|
||||
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
|
||||
PACKAGE_VERSION: ${{ matrix.plugin.version }}
|
||||
@@ -463,17 +456,14 @@ jobs:
|
||||
done
|
||||
if [[ "${status}" =~ ^2 ]]; then
|
||||
echo "${PACKAGE_NAME}@${PACKAGE_VERSION} is already published on ClawHub."
|
||||
echo "already_published=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${status}" != "404" ]]; then
|
||||
echo "Unexpected ClawHub response (${status}) for ${PACKAGE_NAME}@${PACKAGE_VERSION}."
|
||||
exit 1
|
||||
fi
|
||||
echo "already_published=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Publish
|
||||
if: steps.clawhub_package_version.outputs.already_published != 'true'
|
||||
env:
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
SOURCE_REPO: ${{ github.repository }}
|
||||
|
||||
20
.github/workflows/plugin-npm-release.yml
vendored
20
.github/workflows/plugin-npm-release.yml
vendored
@@ -184,11 +184,6 @@ jobs:
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Validate release publish approval run
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -204,13 +199,12 @@ jobs:
|
||||
echo "Direct Plugin NPM Release dispatch; relying on this workflow's npm-release environment approval."
|
||||
exit 0
|
||||
fi
|
||||
direct_recovery=false
|
||||
if [[ "${GITHUB_ACTOR}" != "github-actions[bot]" ]]; then
|
||||
direct_recovery=true
|
||||
echo "Direct Plugin NPM Release recovery with release_publish_run_id; relying on this workflow's npm-release environment approval."
|
||||
echo "Plugin npm publish must be dispatched by the OpenClaw Release Publish workflow, not directly by ${GITHUB_ACTOR}." >&2
|
||||
exit 1
|
||||
fi
|
||||
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
|
||||
printf '%s' "$RUN_JSON" | DIRECT_RELEASE_RECOVERY="${direct_recovery}" node scripts/validate-release-publish-approval.mjs
|
||||
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "OpenClaw Release Publish"], ["headBranch", process.env.EXPECTED_WORKFLOW_BRANCH], ["event", "workflow_dispatch"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } if (run.status !== "in_progress") { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must still be in_progress, got ${run.status ?? "<missing>"}.`); process.exit(1); } if (run.conclusion) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} already concluded ${run.conclusion}.`); process.exit(1); } console.log(`Using release publish approval run ${process.env.RELEASE_PUBLISH_RUN_ID}: ${run.url}`);'
|
||||
|
||||
preview_plugin_pack:
|
||||
needs: preview_plugins_npm
|
||||
@@ -269,8 +263,7 @@ jobs:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
install-bun: "false"
|
||||
|
||||
- name: Check npm package version
|
||||
id: npm_package_version
|
||||
- name: Ensure version is not already published
|
||||
env:
|
||||
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
|
||||
PACKAGE_VERSION: ${{ matrix.plugin.version }}
|
||||
@@ -278,13 +271,10 @@ jobs:
|
||||
set -euo pipefail
|
||||
if npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
|
||||
echo "${PACKAGE_NAME}@${PACKAGE_VERSION} is already published on npm."
|
||||
echo "already_published=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "already_published=false" >> "$GITHUB_OUTPUT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Publish
|
||||
if: steps.npm_package_version.outputs.already_published != 'true'
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
@@ -199,13 +199,13 @@ jobs:
|
||||
--alt-model openai/gpt-5.5-alt \
|
||||
--output-dir .artifacts/qa-e2e/openai-candidate
|
||||
|
||||
- name: Run Opus 4.8 lane
|
||||
- name: Run Opus 4.7 lane
|
||||
run: |
|
||||
pnpm openclaw qa suite \
|
||||
--provider-mode mock-openai \
|
||||
--parity-pack agentic \
|
||||
--concurrency "${QA_PARITY_CONCURRENCY}" \
|
||||
--model anthropic/claude-opus-4-8 \
|
||||
--model anthropic/claude-opus-4-7 \
|
||||
--alt-model anthropic/claude-sonnet-4-6 \
|
||||
--output-dir .artifacts/qa-e2e/anthropic-baseline
|
||||
|
||||
@@ -216,7 +216,7 @@ jobs:
|
||||
--candidate-summary .artifacts/qa-e2e/openai-candidate/qa-suite-summary.json \
|
||||
--baseline-summary .artifacts/qa-e2e/anthropic-baseline/qa-suite-summary.json \
|
||||
--candidate-label "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--baseline-label anthropic/claude-opus-4-8 \
|
||||
--baseline-label anthropic/claude-opus-4-7 \
|
||||
--output-dir .artifacts/qa-e2e/parity
|
||||
|
||||
- name: Upload parity artifacts
|
||||
@@ -530,7 +530,6 @@ jobs:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
|
||||
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
|
||||
OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1"
|
||||
INPUT_SCENARIO: ${{ github.event_name == 'workflow_dispatch' && inputs.scenario || '' }}
|
||||
|
||||
2
.github/workflows/website-installer-sync.yml
vendored
2
.github/workflows/website-installer-sync.yml
vendored
@@ -90,7 +90,7 @@ jobs:
|
||||
bash -lc 'apt-get update -y && apt-get install -y curl && bash /tmp/install-cli.sh --prefix /tmp/openclaw --no-onboard --version latest && /tmp/openclaw/bin/openclaw --version'
|
||||
|
||||
macos-installer:
|
||||
runs-on: macos-15
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
@@ -197,4 +197,4 @@ jobs:
|
||||
|
||||
- name: Testbox action marker
|
||||
if: ${{ false }}
|
||||
uses: useblacksmith/run-testbox@3f60ff9ceb2c10c3feefa87dc0c6490cffae059d
|
||||
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
|
||||
|
||||
15
.github/workflows/workflow-sanity.yml
vendored
15
.github/workflows/workflow-sanity.yml
vendored
@@ -34,10 +34,7 @@ jobs:
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Fail on tabs in workflow files
|
||||
@@ -78,10 +75,7 @@ jobs:
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Install actionlint
|
||||
@@ -122,10 +116,7 @@ jobs:
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Setup Node environment
|
||||
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -59,8 +59,6 @@ apps/ios/.swiftpm/
|
||||
apps/ios/.derivedData/
|
||||
apps/ios/.local-signing.xcconfig
|
||||
vendor/
|
||||
!src/auto-reply/reply/export-html/vendor/
|
||||
!src/auto-reply/reply/export-html/vendor/**
|
||||
apps/ios/Clawdbot.xcodeproj/
|
||||
apps/ios/Clawdbot.xcodeproj/**
|
||||
apps/macos/.build/**
|
||||
@@ -103,13 +101,9 @@ USER.md
|
||||
# though the bare names match the local-untracked rule above.
|
||||
!extensions/oc-path/src/oc-path/tests/fixtures/real/IDENTITY.md
|
||||
!extensions/oc-path/src/oc-path/tests/fixtures/real/USER.md
|
||||
!docs/reference/templates/IDENTITY.md
|
||||
!docs/reference/templates/USER.md
|
||||
*.tgz
|
||||
*.tar.gz
|
||||
*.zip
|
||||
!test/fixtures/plugins-install/*.tgz
|
||||
!test/fixtures/plugins-install/*.zip
|
||||
.idea
|
||||
.vscode/
|
||||
|
||||
@@ -134,7 +128,10 @@ mantis/
|
||||
!.agents/skills/control-ui-e2e/**
|
||||
!.agents/skills/gitcrawl/
|
||||
!.agents/skills/gitcrawl/**
|
||||
!.agents/skills/openclaw-docs/**
|
||||
!.agents/skills/technical-documentation/
|
||||
!.agents/skills/technical-documentation/**
|
||||
!.agents/skills/openclaw-refactor-docs/
|
||||
!.agents/skills/openclaw-refactor-docs/**
|
||||
!.agents/skills/openclaw-debugging/
|
||||
!.agents/skills/openclaw-debugging/**
|
||||
!.agents/skills/openclaw-ghsa-maintainer/
|
||||
@@ -181,7 +178,6 @@ mantis/
|
||||
/local/
|
||||
/client_secret_*.json
|
||||
package-lock.json
|
||||
!src/commands/copilot-sdk-install-manifest/package-lock.json
|
||||
.claude/
|
||||
.agent/
|
||||
skills-lock.json
|
||||
|
||||
@@ -20,44 +20,34 @@
|
||||
"eslint/no-multi-str": "error",
|
||||
"eslint/no-new": "error",
|
||||
"eslint/no-object-constructor": "error",
|
||||
"eslint/no-param-reassign": "error",
|
||||
"eslint/no-proto": "error",
|
||||
"eslint/no-regex-spaces": "error",
|
||||
"eslint/no-return-assign": "error",
|
||||
"eslint/no-sequences": "error",
|
||||
"eslint/no-self-compare": "error",
|
||||
"eslint/no-shadow": "off",
|
||||
"eslint/no-implicit-coercion": "error",
|
||||
"eslint/no-var": "error",
|
||||
"eslint/no-useless-call": "error",
|
||||
"eslint/no-useless-computed-key": "error",
|
||||
"eslint/no-useless-concat": "error",
|
||||
"eslint/no-useless-constructor": "error",
|
||||
"eslint/no-useless-rename": "error",
|
||||
"eslint/no-useless-return": "error",
|
||||
"eslint/no-unused-vars": "off",
|
||||
"eslint/no-warning-comments": "error",
|
||||
"eslint/no-unmodified-loop-condition": "error",
|
||||
"eslint/no-new-wrappers": "error",
|
||||
"eslint/no-else-return": "error",
|
||||
"eslint/no-lonely-if": "error",
|
||||
"eslint/no-case-declarations": "error",
|
||||
"eslint/default-case-last": "error",
|
||||
"eslint/default-param-last": "error",
|
||||
"eslint/prefer-exponentiation-operator": "error",
|
||||
"eslint/prefer-const": "error",
|
||||
"eslint/prefer-numeric-literals": "error",
|
||||
"eslint/prefer-object-has-own": "error",
|
||||
"eslint/object-shorthand": "error",
|
||||
"eslint/prefer-rest-params": "error",
|
||||
"eslint/prefer-spread": "error",
|
||||
"eslint/radix": "error",
|
||||
"eslint/unicode-bom": "error",
|
||||
"eslint/yoda": "error",
|
||||
"import/no-absolute-path": "error",
|
||||
"import/first": "error",
|
||||
"import/no-empty-named-blocks": "error",
|
||||
"import/no-duplicates": "error",
|
||||
"import/no-self-import": "error",
|
||||
"node/no-exports-assign": "error",
|
||||
"eslint-plugin-unicorn/prefer-set-size": "error",
|
||||
@@ -76,9 +66,7 @@
|
||||
"typescript/no-empty-object-type": ["error", { "allowInterfaces": "with-single-extends" }],
|
||||
"typescript/no-explicit-any": "error",
|
||||
"typescript/no-extraneous-class": "error",
|
||||
"typescript/no-import-type-side-effects": "error",
|
||||
"typescript/no-meaningless-void-operator": "error",
|
||||
"typescript/no-inferrable-types": "error",
|
||||
"typescript/no-non-null-asserted-nullish-coalescing": "error",
|
||||
"typescript/no-unnecessary-qualifier": "error",
|
||||
"typescript/no-unnecessary-type-assertion": "error",
|
||||
@@ -98,7 +86,6 @@
|
||||
"typescript/prefer-namespace-keyword": "error",
|
||||
"typescript/prefer-return-this-type": "error",
|
||||
"typescript/prefer-find": "error",
|
||||
"typescript/prefer-for-of": "error",
|
||||
"typescript/prefer-function-type": "error",
|
||||
"typescript/prefer-includes": "error",
|
||||
"typescript/prefer-reduce-type-parameter": "error",
|
||||
@@ -119,17 +106,14 @@
|
||||
"unicorn/no-new-buffer": "error",
|
||||
"unicorn/no-thenable": "error",
|
||||
"unicorn/no-typeof-undefined": "error",
|
||||
"unicorn/no-unreadable-array-destructuring": "error",
|
||||
"unicorn/no-unnecessary-array-flat-depth": "error",
|
||||
"unicorn/no-unnecessary-array-splice-count": "error",
|
||||
"unicorn/no-unnecessary-slice-end": "error",
|
||||
"unicorn/no-useless-error-capture-stack-trace": "error",
|
||||
"unicorn/no-useless-promise-resolve-reject": "error",
|
||||
"unicorn/no-zero-fractions": "error",
|
||||
"unicorn/prefer-date-now": "error",
|
||||
"unicorn/prefer-dom-node-text-content": "error",
|
||||
"unicorn/prefer-keyboard-event-key": "error",
|
||||
"unicorn/prefer-array-flat": "error",
|
||||
"unicorn/prefer-array-some": "error",
|
||||
"unicorn/prefer-math-min-max": "error",
|
||||
"unicorn/prefer-node-protocol": "error",
|
||||
@@ -139,8 +123,6 @@
|
||||
"unicorn/prefer-prototype-methods": "error",
|
||||
"unicorn/prefer-regexp-test": "error",
|
||||
"unicorn/prefer-set-size": "error",
|
||||
"unicorn/prefer-set-has": "error",
|
||||
"unicorn/prefer-structured-clone": "error",
|
||||
"unicorn/prefer-string-starts-ends-with": "error",
|
||||
"unicorn/prefer-string-slice": "error",
|
||||
"unicorn/require-array-join-separator": "error",
|
||||
@@ -201,11 +183,10 @@
|
||||
"docs/_layouts/",
|
||||
"extensions/diffs/assets/viewer-runtime.js",
|
||||
"extensions/diffs-language-pack/assets/viewer-runtime.js",
|
||||
"extensions/canvas/src/host/a2ui/a2ui.bundle.js",
|
||||
"node_modules/",
|
||||
"patches/",
|
||||
"pnpm-lock.yaml",
|
||||
"skills/**",
|
||||
"skills/",
|
||||
"src/auto-reply/reply/export-html/template.js",
|
||||
"src/canvas-host/a2ui/a2ui.bundle.js",
|
||||
"vendor/",
|
||||
|
||||
27
AGENTS.md
27
AGENTS.md
@@ -35,9 +35,9 @@ Skills own workflows; root owns hard policy and routing.
|
||||
|
||||
## Map
|
||||
|
||||
- Core TS: `src/`, `ui/`, `packages/`; plugins: `extensions/`; SDK: `src/plugin-sdk/*`; channels: `src/channels/*`; loader: `src/plugins/*`; protocol: `packages/gateway-protocol/*`; docs/apps: `docs/`, `apps/`.
|
||||
- Core TS: `src/`, `ui/`, `packages/`; plugins: `extensions/`; SDK: `src/plugin-sdk/*`; channels: `src/channels/*`; loader: `src/plugins/*`; protocol: `src/gateway/protocol/*`; docs/apps: `docs/`, `apps/`.
|
||||
- Installers: sibling `../openclaw.ai`.
|
||||
- Scoped guides: `extensions/`, `src/{plugin-sdk,channels,plugins,gateway,agents}/`, `packages/`, `test/helpers*/`, `docs/`, `ui/`, `scripts/`.
|
||||
- Scoped guides: `extensions/`, `src/{plugin-sdk,channels,plugins,gateway,gateway/protocol,agents}/`, `test/helpers*/`, `docs/`, `ui/`, `scripts/`.
|
||||
|
||||
## Docs
|
||||
|
||||
@@ -57,7 +57,6 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- 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.
|
||||
- Runtime reads canonical config only. No silent compat for old/malformed config keys. If a config change invalidates existing files, add a matching `openclaw doctor --fix` migration. Core/auth config repairs live in core doctor; plugin-owned config repairs live in that plugin's doctor contract (`legacyConfigRules` / `normalizeCompatibilityConfig`).
|
||||
- CLI setup flows are public API when external docs, installers, or integrations can copy them. Changes to `openclaw onboard`, `openclaw configure`, their documented flags, non-interactive behavior, or generated config shape are compatibility-sensitive API contract changes; prefer additive flags/aliases, deprecation windows, and backward-preserving migrations over breaking existing snippets.
|
||||
- Fix shape: default to clean bounded refactor, not smallest patch. Move ownership to right boundary; delete stale abstractions, duplicate policy, dead branches, wrappers, fallback stacks.
|
||||
- Fix observed local failures with generic product rules; do not hardcode names, ids, log phrases, or user examples in prod code unless they are an explicit contract.
|
||||
- Tests may use observed examples, but prod literals need a short contract reason.
|
||||
@@ -72,13 +71,12 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Plugin SDK exception: shipped external API gets new API first plus named compat/deprecation, small tests/docs if useful, removal plan.
|
||||
- Migrate internal/bundled callers to modern API in the same change. Do not let internal compat become permanent architecture.
|
||||
- Channels are implementation under `src/channels/**`; plugin authors get SDK seams. Providers own auth/catalog/runtime hooks; core owns generic loop.
|
||||
- Agent run terminal state: normalize/merge via `src/agents/agent-run-terminal-outcome.ts`; do not rederive timeout/cancel precedence in projections.
|
||||
- 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.
|
||||
- Gateway/plugin metadata is process-stable: installs, manifests, catalogs, generated paths, bundled metadata. Changes require restart or explicit owner reload/install/doctor flow.
|
||||
- Runtime hot paths: no freshness polling (`stat`/`realpath`/JSON reread/hash). Reuse current snapshots, install records, discovery, lookup tables, root scopes, resolved paths.
|
||||
- Process-local metadata caches ok when lifecycle-owned and bounded/single-slot. Freshness exceptions need named owner + tests.
|
||||
- Inline comments: preserve reviewer context at the code site. Required for non-obvious cross-path/state invariants, lifecycle ordering, ownership boundaries, queue/dedupe symmetry, TTL/cache expiry, cleanup/release coupling, session/id adoption, fallback behavior, platform/dependency caps, deterministic ordering, compact encoded state, or intentional caller differences.
|
||||
- Inline comments: preserve reviewer context at the code site. Use for cross-path/state invariants, platform/dependency caps, deterministic ordering, compact encoded state, lifecycle ordering, ownership boundaries, session/id adoption, queue-depth symmetry, fallbacks, or intentional caller differences.
|
||||
- Comment shape: 1-3 short lines; state why the branch/helper exists, what contract it protects, and the bad outcome if removed. Cite nearby constants/helpers when useful. No syntax narration, PR/user-specific lore, or obvious mechanics.
|
||||
- Gateway protocol changes: additive first; incompatible needs versioning/docs/client follow-through.
|
||||
- Protocol version bumps: explicit owner confirmation only; never automatic/generated.
|
||||
@@ -93,7 +91,6 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Install: `pnpm install` (keep Bun lock/patches aligned if touched).
|
||||
- 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`.
|
||||
- If raw Vitest is unavoidable, use `vitest run ...`; bare `vitest ...` starts local watch mode and will not exit on its own.
|
||||
- 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.
|
||||
@@ -154,9 +151,19 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- 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.
|
||||
- Storage adapters: quarantine schema/nullability mess at the boundary. Use one named mapper from domain object to DB row, one mapper from DB row to domain object, and keep read/write paths boring.
|
||||
- Discriminated unions: use exhaustive `switch` mappers instead of repeated inline conditionals. If insert/update share shape, build the row once and reuse it; split primary keys once for update sets.
|
||||
- Kysely rows: prefer generated `Insertable`/`Selectable` types for mapper contracts. Do not duplicate nullable-column logic inside `values(...)` and `doUpdateSet(...)`.
|
||||
- Code size matters. Prefer small clear code; maintainability includes not growing LOC without payoff.
|
||||
- Refactors should delete about as much local complexity as they add. If LOC grows, the new ownership/API needs to clearly pay for it.
|
||||
- Before adding helpers/files, check whether existing code can absorb the behavior with less new surface.
|
||||
- Keep APIs narrow: export only current caller needs; keep types/helpers local by default.
|
||||
- Return the smallest useful shape. Avoid broad result objects, flags, metadata unless callers use them.
|
||||
- Avoid adapter layers that only rename fields. Move real responsibility or leave code local.
|
||||
- Inline simple one-use objects/spreads when clearer. Extract only when it removes duplication or hard logic.
|
||||
- Tests prove behavior/regressions, not every internal branch.
|
||||
- For non-trivial refactors, check `git diff --numstat` before closeout. If LOC grew, trim or explain why.
|
||||
- Prefer existing narrow helpers over repeated casts/guards. Add local helpers when 2+ nearby call sites share real boundary logic.
|
||||
- Prefer ctor parameter properties for injected deps/config. Do not ban them for erasable-syntax purity.
|
||||
- Prefer `satisfies` for registries/config maps; derive types from schemas when a runtime schema already exists.
|
||||
- Table-drive repetitive tests when it reduces code and keeps failure names clear.
|
||||
- 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.
|
||||
@@ -219,8 +226,6 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Parallels: `$openclaw-parallels-smoke`; Discord roundtrip: `$parallels-discord-roundtrip`.
|
||||
- Crabbox/WebVNC human demos: keep remote desktop visible/windowed; no fullscreen remote browser unless video/capture-style output.
|
||||
- ClawSweeper ops: `$clawsweeper`. Deployed hook sessions may post one concise `#clawsweeper` note only when surprising/actionable/risky; if using message tool, reply exactly `NO_REPLY`.
|
||||
- Generated-media completions wake the requester agent first. Requester visible-reply config decides final text vs message tool; direct media send is fallback/recovery only.
|
||||
- `message_tool_only`: visible source reply = current-source `message(action=send)` only. No `NO_REPLY` prompt/contract; no message call = no source reply. Never auto-publish private final.
|
||||
- Memory wiki prompt digest stays tiny; prefer `wiki_search` / `wiki_get`; verify contact data before use; source-class provenance for generated people facts.
|
||||
- Rebrand/migration/config warnings: run `openclaw doctor`.
|
||||
- Never edit `node_modules`.
|
||||
|
||||
114
CHANGELOG.md
114
CHANGELOG.md
@@ -2,83 +2,14 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.5.30
|
||||
|
||||
### Highlights
|
||||
|
||||
- Agents and CLI-backed runtimes recover more cleanly from interrupted tool calls, stale session bindings, compaction handoffs, and media delivery retries. (#88129, #88136, #88141, #88162, #88182)
|
||||
- Channels and mobile delivery are steadier across Telegram, WhatsApp, iMessage, Slack, Discord, Microsoft Teams, Google Chat, Google Meet, and iOS realtime Talk. (#88096, #88105, #88183, #88231)
|
||||
- Provider and plugin requests now bound more timers, retries, OAuth/device-code lifetimes, media downloads, local service probes, and generated-content polling paths before they can hang a run.
|
||||
- Skills, session metadata, gateway runtime state, plugin metadata, and store writes do less repeated work on hot paths while keeping config and dispatch behavior stable.
|
||||
- Workboard, SecretRef plugin manifests, hosted iOS push relay, and external Copilot/Tokenjuice packaging add broader orchestration, integration, and plugin delivery surfaces. (#82326, #87469, #87796, #88107, #88117)
|
||||
- Release, CI, Docker, E2E, and diagnostics lanes now cap more logs, response bodies, readiness probes, artifact checks, and status polling so failures report bounded proof instead of stalling.
|
||||
|
||||
### Changes
|
||||
|
||||
- Docs: add a dedicated Skill Workshop guide covering governed skill creation, reviewable proposals, CLI, Gateway, agent tool behavior, approval policy, support files, and recovery. Thanks @shakkernerd.
|
||||
- Skills: let the `skill_workshop` agent tool apply, reject, and quarantine explicit proposals through the guarded review flow. Thanks @shakkernerd.
|
||||
- Skills: let proposals carry approved support files under standard skill folders, with scanner, hash, and rollback safeguards. Thanks @shakkernerd.
|
||||
- Skills: let pending proposals be revised in place with versioned, dated proposal frontmatter before approval. Thanks @shakkernerd.
|
||||
- Skills: add Skill Workshop with pending proposals, CLI/Gateway review actions, rollback metadata, and the `skill_workshop` agent tool. Thanks @shakkernerd.
|
||||
- Plugins: externalize Tokenjuice as the official `@openclaw/tokenjuice` plugin with npm and ClawHub publish metadata.
|
||||
- Plugins: externalize the GitHub Copilot agent runtime as the official `@openclaw/copilot` plugin with npm and ClawHub publish metadata.
|
||||
- iOS: add hosted push relay defaults, realtime Talk playback, and a guarded WebSocket ping path for more reliable mobile sessions. (#88096, #88105, #88231)
|
||||
- Workboard: add orchestration primitives and agent coordination tools for multi-agent planning and run tracking. (#87469)
|
||||
- Code mode: add internal namespaces for scoped agent/global sessions and exact namespace tool dispatch. (#88043)
|
||||
- Control UI: add a Dreaming-tab agent selector and propagate the selected agent through Dreaming status, diary, and diary actions. (#78748) Thanks @stevenepalmer.
|
||||
- Plugins: add a SecretRef provider integration manifest contract and extract shared LLM core packages for provider/plugin reuse. (#82326, #88117)
|
||||
- Skills: add the core skills index and centralize skills runtime loading, status, filtering, and prompt formatting.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Agents/Codex: keep public OpenAI API-key profiles from being treated as native Codex app-server auth while preserving persisted Codex OAuth sessions.
|
||||
- Control UI: keep collapsed tool cards labeled with the tool name and action instead of generic output text. Thanks @shakkernerd.
|
||||
- Agents/Codex: surface Skill Workshop guidance in Codex app-server prompts when `skill_workshop` is available. Thanks @shakkernerd.
|
||||
- CLI: keep `plugins list --json` on the snapshot-only path so plugin sweeps avoid loading the full runtime status graph.
|
||||
- Plugins: make PixVerse external-plugin ClawHub metadata explicit and keep it out of bundled dist builds.
|
||||
- Cron: keep SQLite cron migrations compatible with legacy run-log tables, archived job stores, diagnostic cron names, and legacy one-shot delete-after-run behavior. (#88285)
|
||||
- Providers: bound generated media downloads from OpenAI, Runway, xAI, MiniMax, BytePlus, DashScope-compatible, FAL, OpenRouter, Google, Vydra, and Comfy providers.
|
||||
- Providers: cap GitHub Copilot OAuth request timeouts before creating abort signals.
|
||||
- Cron: retry recurring jobs after transient model rate limits before waiting for the next scheduled slot.
|
||||
- Agents/Codex: keep live session locks during cleanup, recover interrupted CLI tool transcripts, preserve Codex auth and compaction session identity, clear orphan tool state, cap app-server idle timers, and keep media completion delivery retryable. (#88129, #88136, #88141, #88162, #88182)
|
||||
- Chat/UI: show Gateway chat failures as visible assistant messages in the Control UI instead of only setting an invisible error state.
|
||||
- Channels: cap Telegram, Discord, WhatsApp, Signal, Feishu, Google Chat, Microsoft Teams, QQBot, Nostr, Zalo, Zalouser, and Nextcloud-style request/retry timers; preserve SMS approval reply routes; and retry WhatsApp QR login 408 timeouts. (#88183)
|
||||
- Security/config parsing: reject unsafe OAuth/token lifetimes, retry-after delays, inbound timestamps, response body sizes, command timeout config, sandbox observer token TTLs, and gateway WebSocket calls after close.
|
||||
- Providers/media: cap local service, model, usage, queue, generated media, TTS, music, workflow polling, and provider OAuth request timers across hosted and local providers.
|
||||
- Release/CI/E2E: bound release candidate reads, beta smoke REST calls, changelog restore, kitchen-sink and bundled plugin readiness probes, secret-provider probes, Vitest routing, and mainline test flakes. (#88127, #88137, #88155, #88160)
|
||||
- Release/CI/E2E: run the secret-provider integration proof through the repo pnpm runner so native macOS and Windows validation use the hydrated package-manager shim.
|
||||
- Release/CI/E2E: run the Telegram desktop proof gateway through the repo pnpm runner so native macOS proof uses the hydrated package-manager shim.
|
||||
- Docs/CI: run Mintlify anchor checks through the repo pnpm runner so docs link validation works when pnpm is only available through the hydrated package-manager shim.
|
||||
- Agents: keep configured fallback model metadata typed so provider params, context-token caps, and media input limits do not break changed-gate typechecks.
|
||||
- Agents: accept hidden `sessions_send` body aliases before validation while keeping the model-facing `message` schema canonical. (#88229) Thanks @zhangguiping-xydt.
|
||||
- CI/Crabbox: keep default runner capacity spot-only and provider-neutral so OpenClaw remote validation does not silently fall back to on-demand leases or stale AWS region hints.
|
||||
- CI/Crabbox: route Crabbox wrapper and Testbox workflow edits to their regression tests so changed-test gates do not silently run zero specs.
|
||||
- CI/workflows: route workflow sanity helper edits to their guard tests and cover composite-action input interpolation checks.
|
||||
- CI/tooling: route CI scope, dependency, changelog, and docs helper edits to their owner tests instead of silently skipping changed-test coverage.
|
||||
- CI/tooling: route package, release, and install helper edits to their owner tests so changed-test gates cover publish and installer script changes.
|
||||
- CI/tooling: route shared script library edits through their owner tests so lock, process, safety, and scan helpers do not skip changed-test coverage.
|
||||
- CI/tooling: skip expensive import-graph scans once a changed diff already requires broad fallback, keeping local changed-test planning fast while still collecting explicit owner tests.
|
||||
- CI/tooling: route script edits through conventional owner tests when matching `test/scripts` or `src/scripts` coverage already exists.
|
||||
- CI/tooling: honor option terminators in the memory FD repro script so follow-on arguments are not reparsed.
|
||||
- Release/CI/E2E: assert plugin lifecycle runtime inspect output instead of only capturing it.
|
||||
- Release/CI/E2E: make gateway-network prove the advertised health RPC and retry early WebSocket closes without burning full open timeouts.
|
||||
- Release/CI/E2E: honor option terminators across release, Parallels smoke, plugin gauntlet, and extension-memory scripts.
|
||||
- Release/CI/E2E: fail plugin gateway gauntlet QA chunks when the requested suite summary is missing or invalid.
|
||||
- Performance: prebuild QA runtime probes with generated plugin assets but without CLI startup metadata.
|
||||
- Performance: skip declaration bundling for runtime-only CLI startup and gateway watch build profiles.
|
||||
- Performance: reuse prepared provider handles, strict tool schemas, gateway runtime metadata, session maintenance config, plugin metadata, bundled skill allowlists, package-local plugin artifacts, single-entry store writes, and validated/serialized session prompt blobs.
|
||||
|
||||
## 2026.5.28
|
||||
|
||||
### Highlights
|
||||
|
||||
- Agent and Codex runtime recovery is steadier: subagents keep cwd/workspace separation, hook context stays prompt-local, session locks release on timeout abort while live OpenClaw locks survive cleanup, stale restart continuations are avoided, and Codex app-server/helper failures no longer tear down shared runtime state. (#87218, #86875, #87409, #87399, #87375, #88129)
|
||||
- Channel delivery and session identity got safer across outbound plugin hooks, Matrix room ids, iMessage reactions/approvals, Slack final replies, Discord recovered tool warnings, runtime-config message actions, WhatsApp profile auth roots, Telegram polling, and Microsoft Teams service URL trust checks. (#73706, #75670, #87366, #87451, #87334, #84535, #82492, #83304, #87160)
|
||||
- Mobile and chat surfaces got a broader refresh: the iOS Pro UI, hosted push relay default, realtime Talk tab playback, Gateway chat transport, onboarding, Talk permissions, WebChat reconnect delivery, and session picker behavior now preserve more state across reconnects and empty searches. (#87367, #87531, #87682, #88096, #88105) Thanks @ngutman and @BunsDev.
|
||||
- Browser, channel, and automation inputs are stricter: Browser tool timeouts, viewport/tab indices, Gateway ports, cron retry handling, Discord component ids, schema array refs, Telegram callback pages, and channel progress callbacks now reject malformed values earlier and preserve the intended delivery context. (#82887)
|
||||
- Provider, media, and document coverage expands with Claude Opus 4.8, Fal Krea image schemas, NVIDIA featured models, MiniMax streaming music responses, encrypted PDF extraction, voice model catalogs, GitHub Copilot agent runtime support, and a Codex Supervisor plugin path for delegated Codex workflows. (#87845, #87890, #80775, #84764, #87751, #87794)
|
||||
- CLI, auth, doctor, and provider paths fail faster and recover more clearly: malformed numeric/version options are rejected, workspace dotenv provider credentials are ignored, heartbeat defaults, OAuth/token lifetimes, and local service startup requests are bounded, agent auth health labels are clearer, legacy `api_key` auth profiles migrate to canonical form, and restart guidance is actionable. (#87398, #86281, #87361, #88133, #83655, #87559, #88088, #85924) Thanks @vincentkoc and @giodl73-repo.
|
||||
- Plugin and Gateway hot paths do less repeated work while preserving cache correctness for install records, config JSON parsing, tool search catalogs, session stores, manifest model rows, auto-enabled plugin config, browser tokens, viewer assets, and release-split external plugin packages. (#86699)
|
||||
- Agent and Codex runtime recovery is steadier: subagents keep cwd/workspace separation, hook context stays prompt-local, session locks release on timeout abort, stale restart continuations are avoided, and Codex app-server/helper failures no longer tear down shared runtime state. (#87218, #86875, #87409, #87399, #87375)
|
||||
- Channel delivery and session identity got safer across outbound plugin hooks, Matrix room ids, iMessage reactions/approvals, Slack final replies, Discord recovered tool warnings, and Microsoft Teams service URL trust checks. (#73706, #75670, #87366, #87451, #87334)
|
||||
- CLI, auth, doctor, and provider paths fail faster and recover more clearly: malformed numeric/version options are rejected, OAuth and local service startup requests are bounded, legacy `api_key` auth profiles migrate to canonical form, and restart guidance is actionable. (#87398, #86281, #87361)
|
||||
- Plugin and Gateway hot paths do less repeated work while preserving cache correctness for install records, config JSON parsing, tool search catalogs, session stores, manifest model rows, auto-enabled plugin config, browser tokens, and viewer assets. (#86699)
|
||||
- Release, QA, and E2E validation now bound more log, artifact, harness, and cross-OS waits so failing lanes produce proof instead of hanging or false-greening.
|
||||
|
||||
### Changes
|
||||
@@ -86,41 +17,20 @@ Docs: https://docs.openclaw.ai
|
||||
- Status: show active subagent details in status output.
|
||||
- Diffs: split the default language pack and expand default Diffs language coverage while keeping the host floor aligned. (#87370, #87372) Thanks @RomneyDa.
|
||||
- ClawHub: add plugin display names plus skill verification and trust surfaces. (#87354, #86699) Thanks @thewilloftheshadow and @Patrick-Erichsen.
|
||||
- iOS: refresh the dev app with Pro Command, Chat, Agents, Settings, hosted push relay defaults, and realtime Talk playback wired to gateway sessions, diagnostics, chat, and realtime Talk. (#87367, #88096, #88105) Thanks @Solvely-Colin and @ngutman.
|
||||
- Docs: clarify Codex computer-use setup, paste-token stdin auth setup, macOS gateway sleep troubleshooting, native Codex hook relay recovery, container model auth, install deployment cards, device-token admin gating, CLI setup flow compatibility, Notte cloud browser CDP setup, and backport targets. (#87313, #63050, #87685) Thanks @bdjben, @liaoandi, and @thewilloftheshadow.
|
||||
- PDF/tools: use ClawPDF for PDF extraction, support encrypted PDF extraction, and surface MCP structured content in agent tool results. (#87670, #87751)
|
||||
- Providers: add Claude Opus 4.8 support, Fal Krea image model schemas, NVIDIA featured model catalogs, MiniMax streaming music responses, and provider-backed voice model catalogs. (#87845, #87890, #80775, #84764, #87794) Thanks @eleqtrizit and @vincentkoc.
|
||||
- Codex/GitHub: add the GitHub Copilot agent runtime and the Codex Supervisor plugin package.
|
||||
- Plugins: externalize GitHub Copilot and Tokenjuice as official install-on-demand plugins with npm and ClawHub publish metadata.
|
||||
- Workboard: add agent coordination tools for tracking and handing off active agent work.
|
||||
- Discord: show commentary in progress drafts so live Discord runs expose useful in-progress context. (#85200)
|
||||
- Plugin SDK: add a reply payload sending hook for plugins that need to deliver channel-owned replies and flatten package types for SDK declarations. (#82823, #87165) Thanks @piersonr and @RomneyDa.
|
||||
- Policy: add policy comparison, ingress-channel conformance, and sandbox-posture conformance checks. (#85572, #85744, #86768)
|
||||
- iOS: refresh the dev app with Pro Command, Chat, Agents, and Settings tabs wired to gateway sessions, diagnostics, chat, and realtime Talk. (#87367) Thanks @Solvely-Colin.
|
||||
- Docs: clarify Codex computer-use setup, paste-token stdin auth setup, macOS gateway sleep troubleshooting, native Codex hook relay recovery, container model auth, install deployment cards, device-token admin gating, and backport targets. (#87313, #63050) Thanks @bdjben, @liaoandi, and @thewilloftheshadow.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Agents: fall back to local config pruning when the optional `agents delete` Gateway probe cannot authenticate, so offline installs can still delete agents without removing shared workspaces.
|
||||
- Tighten phone-control mutation authorization [AI]. (#87150) Thanks @pgondhi987.
|
||||
- Clarify directive persistence authorization policy [AI]. (#86369) Thanks @pgondhi987.
|
||||
- Agents/Codex: keep spawned agent cwd/workspace state separated, forward ACP spawn attachments, keep hook context prompt-local, release session locks on timeout abort and runtime teardown without deleting live OpenClaw-owned locks during cleanup, avoid session event queue self-wait, clean up exec abort listeners, stream assistant deltas incrementally, recover raw missing-thread compaction failures, preserve rotated compaction session identity, keep compaction-timeout snapshots continuable, preserve shared app-server state across startup or helper failures, keep native hook relay alive across restarts and prune stale bridge files, close native hook relay replacement races, keep Claude live tool progress visible for watchdog recovery, suppress abandoned requester completion handoff, route workspace memory through tools, resolve Codex runtime models first, report quarantined dynamic tools, format `skills` command output, bind node auto-review to prepared plans, retry Claude CLI transcript probes, and bound compaction/steering retries. (#87218, #86875, #86123, #88129, #87399, #87375, #72574, #87383, #87400, #83022, #87671, #87738, #87747, #87706, #87546, #87541, #81048) Thanks @mbelinky, @Alix-007, @luoyanglang, @yetval, @sjf, @joshavant, @benjamin1492, @c19354837, @fuller-stack-dev, @pfrederiksen, and @dodge1218.
|
||||
- Codex Supervisor: keep real-home app-server MCP session listing on the loaded state path, bound stored history scans, and close WebSocket probes cleanly.
|
||||
- Channels: thread canonical session keys into outbound hooks, preserve Matrix room-id case, keep fallback tool warnings mention-inert, retain delivered Slack final replies during late cleanup, continue iMessage polling after denied reactions, suppress duplicate native exec approvals, resolve Gateway message actions against the active runtime config, preserve Telegram SecretRef prompt config and polling keepalives, preserve WhatsApp profile auth roots, QR display, document filenames, and plugin hook config, suppress Discord recovered tool warnings, preserve the Discord voice outbound helper, cap Discord/Signal/Zalo channel request and container timeouts, and block untrusted Teams service URLs while keeping TeamsSDK patterns aligned. (#73706, #75670, #87366, #87451, #87465, #87334, #84535, #76262, #83304, #82492, #87581, #77114, #86426, #85529, #87160) Thanks @zeroaltitude, @lukeboyett, @jarvis-mns1, @xiaotian, @funmerlin, @joshavant, @eleqtrizit, @heyitsaamir, @amittell, @lidge-jun, @liorb-mountapps, @masatohoshino, @bladin, and @giodl73-repo.
|
||||
- CLI/auth/doctor/providers: reject malformed numeric/timeout/subcommand-version inputs, ignore workspace dotenv provider credentials, wait for respawn child shutdown, bound heartbeat defaults plus Codex, GitHub Copilot, OpenAI, Anthropic, Google, Feishu, LM Studio, MiniMax, Xiaomi TTS, and local-provider OAuth/token/model requests, harden Codex auth probes, label auth health by agent, preserve explicit agentRuntime pins during Codex model migration, warm provider auth off the main thread, honor Codex response timeouts, stop migrating current Claude Haiku 4.5 profiles to Sonnet, bound local service startup, resolve GPT-5.5 without cached catalog, migrate legacy memory auto-provider config, rewrite non-canonical `api_key` auth profiles, and make doctor restart follow-ups actionable. (#87398, #86281, #87361, #88133, #83655, #87559, #87719, #88088, #85924, #84362) Thanks @Patrick-Erichsen, @samzong, @giodl73-repo, @alkor2000, @mmaps, @nxmxbbd, and @vincentkoc.
|
||||
- Gateway/security/session state: expire browser tokens after auth rotation, scope assistant idempotency dedupe, drain probe client closes, avoid stale restart continuation reuse, preserve retry-after fallbacks and stale rate-limit cooldown probes, bound webchat image and artifact transcript scans, include seconds in inbound metadata timestamps, clear completed session active runs, clear stale chat stream buffers, and evict current plugin-state namespaces at row caps. (#87810, #87833, #75089) Thanks @joshavant and @litang9.
|
||||
- Config/parsing/network: reject partial numeric parsing, parse provider/Discord retry headers and dates strictly, honor IPv6 and bare IPv6 `no_proxy` entries, preserve empty plugin allowlists, canonicalize secret target array indexes, and reject malformed media content lengths, inspected TCP ports, marketplace content lengths, cron epochs, sandbox stat fields, unsafe duration values, empty config path segments, noncanonical schema array refs, unsafe Telegram callback pages, and invalid Teams attachment-fetch DNS targets. (#87883) Thanks @zhangguiping-xydt.
|
||||
- Browser/input hardening: reject invalid tab indexes, excessive viewport resizes, explicit zero CDP ports, malformed geolocation options, unsafe screenshot or permission-grant timeouts, loose response-body limits, invalid cookie expiries, and non-finite Browser tool delays/timeouts.
|
||||
- Cron/automation: retry recurring jobs after transient model rate limits before waiting for the next scheduled slot, and preflight model fallbacks before skipping scheduled work. (#82887) Thanks @chen-zhang-cs-code.
|
||||
- Auto-reply/directives: respect provider and relayed channel metadata during directive persistence so channel-originated decisions keep their intended context. (#87683)
|
||||
- WhatsApp: resolve the auth directory from the active profile so profile-scoped WhatsApp installs do not drift to the wrong credential root. (#82492) Thanks @lidge-jun.
|
||||
- Gateway/session state: clear completed session active runs, avoid cold-loading providers for MCP inventory, cache single-session child indexes, cap handshake timers, and bound preauth, auth-guard, media, transcript, readiness, and port options.
|
||||
- Channels/replies: preserve channel-owned progress callbacks when verbose output is off, keep group-room progress suppression intact, prefer external session delivery context, escape Discord component id delimiters, force final TUI chat repaints, show Slack reasoning previews, and normalize Discord/Matrix/Mattermost channel numeric options. (#87476, #87423)
|
||||
- Agents/tool args: harden smart-quoted argument repair for edit arrays and exact escaped arguments so model-produced tool calls recover without corrupting valid input. (#86611) Thanks @ferminquant.
|
||||
- Providers/agents: preserve seeded Anthropic signatures, preserve signed thinking payloads, concatenate signature-delta chunks, preserve DeepSeek `reasoning_content` replay across tier suffixes, apply OpenRouter strict9 ids to Mistral routes, promote Ollama plain-text tool calls, load NVIDIA featured model catalogs, stream MiniMax music generation responses, and recover empty preflight compaction. (#87593, #87493, #80775, #84764) Thanks @Pluviobyte and @eleqtrizit.
|
||||
- Media/images: skip CLI image cache refs when resolving generated images, allow trusted generated HTML attachments, and bound generated video downloads so stale refs and slow providers fail cleanly. (#87523, #87982)
|
||||
- Agents/Codex: keep spawned agent cwd/workspace state separated, keep hook context prompt-local, release session locks on timeout abort, avoid session event queue self-wait, preserve shared app-server state across startup or helper failures, keep native hook relay alive across restarts, route workspace memory through tools, resolve Codex runtime models first, report quarantined dynamic tools, format `skills` command output, and bound compaction/steering retries. (#87218, #86875, #86123, #87399, #87375, #87383, #87400) Thanks @mbelinky, @Alix-007, @luoyanglang, @yetval, and @sjf.
|
||||
- Channels: thread canonical session keys into outbound hooks, preserve Matrix room-id case, keep fallback tool warnings mention-inert, retain delivered Slack final replies during late cleanup, continue iMessage polling after denied reactions, suppress duplicate native exec approvals, preserve Telegram SecretRef prompt config, suppress Discord recovered tool warnings, and block untrusted Teams service URLs. (#73706, #75670, #87366, #87451, #87334) Thanks @zeroaltitude, @lukeboyett, @xiaotian, and @eleqtrizit.
|
||||
- CLI/auth/doctor/providers: reject malformed numeric/timeout/subcommand-version inputs, wait for respawn child shutdown, bound Codex and GitHub Copilot OAuth/token requests, warm provider auth off the main thread, honor Codex response timeouts, bound local service startup, resolve GPT-5.5 without cached catalog, migrate legacy memory auto-provider config, rewrite non-canonical `api_key` auth profiles, and make doctor restart follow-ups actionable. (#87398, #86281, #87361) Thanks @Patrick-Erichsen, @samzong, @giodl73-repo, and @alkor2000.
|
||||
- Gateway/security/session state: expire browser tokens after auth rotation, scope assistant idempotency dedupe, drain probe client closes, avoid stale restart continuation reuse, preserve retry-after fallbacks, bound webchat image and artifact transcript scans, include seconds in inbound metadata timestamps, and evict current plugin-state namespaces at row caps.
|
||||
- File transfer: handle late tar stdin pipe errors after archive validation or unpacking has already settled.
|
||||
- Performance: trust install-record caches between reloads, prefer native JSON parsing, reuse unchanged tool-search catalogs, reuse gateway session and plugin metadata paths, skip unchanged store serialization, patch single-entry session writes, add precomputed session patch writers, reduce store clone allocations, cache manifest model catalog rows and auto-enabled plugin config, avoid full session snapshots for entry reads, defer configured Slack full startup, prefer bundled plugin dist entries, and slim current metadata identity caches. (#87760)
|
||||
- Docker/release/QA: package runtime workspace templates, stream cross-OS served artifacts, preserve sparse Crabbox run artifacts, isolate npm plugin installs per package, reject incompatible package plugin API installs, drop the leftover root Sharp dependency from package manifests after the Rastermill migration, bound OpenClaw instance logs, plugin gauntlet relay logs, MCP channel buffers, kitchen-sink scans, agent-turn assertions, QA-Lab credential broker calls, QA Matrix substrate requests, and release scenario logs, and keep release/google live guards current. (#87647, #87477) Thanks @rohitjavvadi and @vincentkoc.
|
||||
- Release/CI: bound manual git fetches, ClawHub verifier responses, ClawHub owner metadata, dependency-guard error bodies, Parallels limits, startup/test/memory budget parsing, and diffs viewer build warnings so release lanes fail with useful proof instead of hanging. (#87839)
|
||||
- Performance: trust install-record caches between reloads, prefer native JSON parsing, reuse unchanged tool-search catalogs, skip unchanged store serialization, add precomputed session patch writers, reduce store clone allocations, cache manifest model catalog rows and auto-enabled plugin config, and slim current metadata identity caches.
|
||||
- Docker/release/QA: package runtime workspace templates, stream cross-OS served artifacts, preserve sparse Crabbox run artifacts, bound OpenClaw instance logs, plugin gauntlet relay logs, MCP channel buffers, kitchen-sink scans, agent-turn assertions, and release scenario logs, and keep release/google live guards current.
|
||||
|
||||
## 2026.5.27
|
||||
|
||||
|
||||
@@ -48,7 +48,6 @@ RUN --mount=type=bind,source=packages,target=/tmp/packages,readonly \
|
||||
FROM ${OPENCLAW_BUN_IMAGE} AS bun-binary
|
||||
FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS build
|
||||
ARG OPENCLAW_BUNDLED_PLUGIN_DIR
|
||||
ARG OPENCLAW_EXTENSIONS
|
||||
|
||||
# Copy pinned Bun binary from the official image instead of fetching via curl.
|
||||
COPY --from=bun-binary /usr/local/bin/bun /usr/local/bin/bun
|
||||
@@ -78,12 +77,7 @@ RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/sto
|
||||
# pnpm v10+ may append peer-resolution hashes to virtual-store folder names; do not hardcode `.pnpm/...`
|
||||
# paths. Matrix's native downloader can hit transient release CDN errors while
|
||||
# still exiting successfully, so retry the package downloader before failing.
|
||||
# Skip the entire check when matrix is not a bundled extension (e.g. msteams-only builds).
|
||||
RUN set -eux; \
|
||||
if ! printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' ' | tr ' ' '\n' | grep -qx 'matrix'; then \
|
||||
echo "==> matrix not bundled, skipping matrix-sdk-crypto check"; \
|
||||
exit 0; \
|
||||
fi; \
|
||||
echo "==> Verifying critical native addons..."; \
|
||||
for attempt in 1 2 3 4 5; do \
|
||||
if find /app/node_modules -name "matrix-sdk-crypto*.node" 2>/dev/null | grep -q .; then \
|
||||
|
||||
343
appcast.xml
343
appcast.xml
@@ -2,70 +2,6 @@
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>OpenClaw</title>
|
||||
<item>
|
||||
<title>2026.5.28</title>
|
||||
<pubDate>Sat, 30 May 2026 21:21:09 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026052890</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.5.28</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.5.28</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li>Agent and Codex runtime recovery is steadier: subagents keep cwd/workspace separation, hook context stays prompt-local, session locks release on timeout abort while live OpenClaw locks survive cleanup, stale restart continuations are avoided, and Codex app-server/helper failures no longer tear down shared runtime state. (#87218, #86875, #87409, #87399, #87375, #88129)</li>
|
||||
<li>Channel delivery and session identity got safer across outbound plugin hooks, Matrix room ids, iMessage reactions/approvals, Slack final replies, Discord recovered tool warnings, runtime-config message actions, WhatsApp profile auth roots, Telegram polling, and Microsoft Teams service URL trust checks. (#73706, #75670, #87366, #87451, #87334, #84535, #82492, #83304, #87160)</li>
|
||||
<li>Mobile and chat surfaces got a broader refresh: the iOS Pro UI, hosted push relay default, realtime Talk tab playback, Gateway chat transport, onboarding, Talk permissions, WebChat reconnect delivery, and session picker behavior now preserve more state across reconnects and empty searches. (#87367, #87531, #87682, #88096, #88105) Thanks @ngutman.</li>
|
||||
<li>Browser, channel, and automation inputs are stricter: Browser tool timeouts, viewport/tab indices, Gateway ports, cron retry handling, Discord component ids, schema array refs, Telegram callback pages, and channel progress callbacks now reject malformed values earlier and preserve the intended delivery context. (#82887)</li>
|
||||
<li>Provider, media, and document coverage expands with Claude Opus 4.8, Fal Krea image schemas, NVIDIA featured models, MiniMax streaming music responses, encrypted PDF extraction, voice model catalogs, GitHub Copilot agent runtime support, and a Codex Supervisor plugin path for delegated Codex workflows. (#87845, #87890, #80775, #84764, #87751, #87794)</li>
|
||||
<li>CLI, auth, doctor, and provider paths fail faster and recover more clearly: malformed numeric/version options are rejected, workspace dotenv provider credentials are ignored, heartbeat defaults, OAuth/token lifetimes, and local service startup requests are bounded, agent auth health labels are clearer, legacy <code>api_key</code> auth profiles migrate to canonical form, and restart guidance is actionable. (#87398, #86281, #87361, #88133, #83655, #87559, #88088, #85924) Thanks @vincentkoc and @giodl73-repo.</li>
|
||||
<li>Plugin and Gateway hot paths do less repeated work while preserving cache correctness for install records, config JSON parsing, tool search catalogs, session stores, manifest model rows, auto-enabled plugin config, browser tokens, viewer assets, and release-split external plugin packages. (#86699)</li>
|
||||
<li>Release, QA, and E2E validation now bound more log, artifact, harness, and cross-OS waits so failing lanes produce proof instead of hanging or false-greening.</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Status: show active subagent details in status output.</li>
|
||||
<li>Diffs: split the default language pack and expand default Diffs language coverage while keeping the host floor aligned. (#87370, #87372) Thanks @RomneyDa.</li>
|
||||
<li>ClawHub: add plugin display names plus skill verification and trust surfaces. (#87354, #86699) Thanks @thewilloftheshadow and @Patrick-Erichsen.</li>
|
||||
<li>iOS: refresh the dev app with Pro Command, Chat, Agents, Settings, hosted push relay defaults, and realtime Talk playback wired to gateway sessions, diagnostics, chat, and realtime Talk. (#87367, #88096, #88105) Thanks @Solvely-Colin and @ngutman.</li>
|
||||
<li>Docs: clarify Codex computer-use setup, paste-token stdin auth setup, macOS gateway sleep troubleshooting, native Codex hook relay recovery, container model auth, install deployment cards, device-token admin gating, CLI setup flow compatibility, Notte cloud browser CDP setup, and backport targets. (#87313, #63050, #87685) Thanks @bdjben, @liaoandi, and @thewilloftheshadow.</li>
|
||||
<li>PDF/tools: use ClawPDF for PDF extraction, support encrypted PDF extraction, and surface MCP structured content in agent tool results. (#87670, #87751)</li>
|
||||
<li>Providers: add Claude Opus 4.8 support, Fal Krea image model schemas, NVIDIA featured model catalogs, MiniMax streaming music responses, and provider-backed voice model catalogs. (#87845, #87890, #80775, #84764, #87794) Thanks @eleqtrizit and @vincentkoc.</li>
|
||||
<li>Codex/GitHub: add the GitHub Copilot agent runtime and the Codex Supervisor plugin package.</li>
|
||||
<li>Plugins: externalize GitHub Copilot and Tokenjuice as official install-on-demand plugins with npm and ClawHub publish metadata.</li>
|
||||
<li>Workboard: add agent coordination tools for tracking and handing off active agent work.</li>
|
||||
<li>Discord: show commentary in progress drafts so live Discord runs expose useful in-progress context. (#85200)</li>
|
||||
<li>Plugin SDK: add a reply payload sending hook for plugins that need to deliver channel-owned replies and flatten package types for SDK declarations. (#82823, #87165) Thanks @RomneyDa.</li>
|
||||
<li>Policy: add policy comparison, ingress-channel conformance, and sandbox-posture conformance checks. (#85572, #85744, #86768)</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Agents: fall back to local config pruning when the optional <code>agents delete</code> Gateway probe cannot authenticate, so offline installs can still delete agents without removing shared workspaces.</li>
|
||||
<li>Tighten phone-control mutation authorization [AI]. (#87150) Thanks @pgondhi987.</li>
|
||||
<li>Clarify directive persistence authorization policy [AI]. (#86369) Thanks @pgondhi987.</li>
|
||||
<li>Agents/Codex: keep spawned agent cwd/workspace state separated, forward ACP spawn attachments, keep hook context prompt-local, release session locks on timeout abort and runtime teardown without deleting live OpenClaw-owned locks during cleanup, avoid session event queue self-wait, clean up exec abort listeners, stream assistant deltas incrementally, recover raw missing-thread compaction failures, preserve rotated compaction session identity, keep compaction-timeout snapshots continuable, preserve shared app-server state across startup or helper failures, keep native hook relay alive across restarts and prune stale bridge files, close native hook relay replacement races, keep Claude live tool progress visible for watchdog recovery, suppress abandoned requester completion handoff, route workspace memory through tools, resolve Codex runtime models first, report quarantined dynamic tools, format <code>skills</code> command output, bind node auto-review to prepared plans, retry Claude CLI transcript probes, and bound compaction/steering retries. (#87218, #86875, #86123, #88129, #87399, #87375, #72574, #87383, #87400, #83022, #87671, #87738, #87747, #87706, #87546, #87541, #81048) Thanks @mbelinky, @Alix-007, @luoyanglang, @yetval, @sjf, @joshavant, and @benjamin1492.</li>
|
||||
<li>Codex Supervisor: keep real-home app-server MCP session listing on the loaded state path, bound stored history scans, and close WebSocket probes cleanly.</li>
|
||||
<li>Channels: thread canonical session keys into outbound hooks, preserve Matrix room-id case, keep fallback tool warnings mention-inert, retain delivered Slack final replies during late cleanup, continue iMessage polling after denied reactions, suppress duplicate native exec approvals, resolve Gateway message actions against the active runtime config, preserve Telegram SecretRef prompt config and polling keepalives, preserve WhatsApp profile auth roots, QR display, document filenames, and plugin hook config, suppress Discord recovered tool warnings, preserve the Discord voice outbound helper, cap Discord/Signal/Zalo channel request and container timeouts, and block untrusted Teams service URLs while keeping TeamsSDK patterns aligned. (#73706, #75670, #87366, #87451, #87465, #87334, #84535, #76262, #83304, #82492, #87581, #77114, #86426, #85529, #87160) Thanks @zeroaltitude, @lukeboyett, @xiaotian, @funmerlin, @joshavant, @eleqtrizit, @heyitsaamir, @amittell, @liorb-mountapps, @masatohoshino, @bladin, and @giodl73-repo.</li>
|
||||
<li>CLI/auth/doctor/providers: reject malformed numeric/timeout/subcommand-version inputs, ignore workspace dotenv provider credentials, wait for respawn child shutdown, bound heartbeat defaults plus Codex, GitHub Copilot, OpenAI, Anthropic, Google, Feishu, LM Studio, MiniMax, Xiaomi TTS, and local-provider OAuth/token/model requests, harden Codex auth probes, label auth health by agent, preserve explicit agentRuntime pins during Codex model migration, warm provider auth off the main thread, honor Codex response timeouts, stop migrating current Claude Haiku 4.5 profiles to Sonnet, bound local service startup, resolve GPT-5.5 without cached catalog, migrate legacy memory auto-provider config, rewrite non-canonical <code>api_key</code> auth profiles, and make doctor restart follow-ups actionable. (#87398, #86281, #87361, #88133, #83655, #87559, #87719, #88088, #85924, #84362) Thanks @Patrick-Erichsen, @samzong, @giodl73-repo, @alkor2000, @mmaps, @nxmxbbd, and @vincentkoc.</li>
|
||||
<li>Gateway/security/session state: expire browser tokens after auth rotation, scope assistant idempotency dedupe, drain probe client closes, avoid stale restart continuation reuse, preserve retry-after fallbacks and stale rate-limit cooldown probes, bound webchat image and artifact transcript scans, include seconds in inbound metadata timestamps, clear completed session active runs, clear stale chat stream buffers, and evict current plugin-state namespaces at row caps. (#87810, #87833, #75089) Thanks @joshavant and @litang9.</li>
|
||||
<li>Config/parsing/network: reject partial numeric parsing, parse provider/Discord retry headers and dates strictly, honor IPv6 and bare IPv6 <code>no_proxy</code> entries, preserve empty plugin allowlists, canonicalize secret target array indexes, and reject malformed media content lengths, inspected TCP ports, marketplace content lengths, cron epochs, sandbox stat fields, unsafe duration values, empty config path segments, noncanonical schema array refs, unsafe Telegram callback pages, and invalid Teams attachment-fetch DNS targets. (#87883) Thanks @zhangguiping-xydt.</li>
|
||||
<li>Browser/input hardening: reject invalid tab indexes, excessive viewport resizes, explicit zero CDP ports, malformed geolocation options, unsafe screenshot or permission-grant timeouts, loose response-body limits, invalid cookie expiries, and non-finite Browser tool delays/timeouts.</li>
|
||||
<li>Cron/automation: retry recurring jobs after transient model rate limits before waiting for the next scheduled slot, and preflight model fallbacks before skipping scheduled work. (#82887)</li>
|
||||
<li>Auto-reply/directives: respect provider and relayed channel metadata during directive persistence so channel-originated decisions keep their intended context. (#87683)</li>
|
||||
<li>WhatsApp: resolve the auth directory from the active profile so profile-scoped WhatsApp installs do not drift to the wrong credential root. (#82492)</li>
|
||||
<li>Gateway/session state: clear completed session active runs, avoid cold-loading providers for MCP inventory, cache single-session child indexes, cap handshake timers, and bound preauth, auth-guard, media, transcript, readiness, and port options.</li>
|
||||
<li>Channels/replies: preserve channel-owned progress callbacks when verbose output is off, keep group-room progress suppression intact, prefer external session delivery context, escape Discord component id delimiters, force final TUI chat repaints, show Slack reasoning previews, and normalize Discord/Matrix/Mattermost channel numeric options. (#87476, #87423)</li>
|
||||
<li>Agents/tool args: harden smart-quoted argument repair for edit arrays and exact escaped arguments so model-produced tool calls recover without corrupting valid input. (#86611)</li>
|
||||
<li>Providers/agents: preserve seeded Anthropic signatures, preserve signed thinking payloads, concatenate signature-delta chunks, preserve DeepSeek <code>reasoning_content</code> replay across tier suffixes, apply OpenRouter strict9 ids to Mistral routes, promote Ollama plain-text tool calls, load NVIDIA featured model catalogs, stream MiniMax music generation responses, and recover empty preflight compaction. (#87593, #87493, #80775, #84764) Thanks @eleqtrizit.</li>
|
||||
<li>Media/images: skip CLI image cache refs when resolving generated images, allow trusted generated HTML attachments, and bound generated video downloads so stale refs and slow providers fail cleanly. (#87523, #87982)</li>
|
||||
<li>File transfer: handle late tar stdin pipe errors after archive validation or unpacking has already settled.</li>
|
||||
<li>Performance: trust install-record caches between reloads, prefer native JSON parsing, reuse unchanged tool-search catalogs, reuse gateway session and plugin metadata paths, skip unchanged store serialization, patch single-entry session writes, add precomputed session patch writers, reduce store clone allocations, cache manifest model catalog rows and auto-enabled plugin config, avoid full session snapshots for entry reads, defer configured Slack full startup, prefer bundled plugin dist entries, and slim current metadata identity caches. (#87760)</li>
|
||||
<li>Docker/release/QA: package runtime workspace templates, stream cross-OS served artifacts, preserve sparse Crabbox run artifacts, isolate npm plugin installs per package, reject incompatible package plugin API installs, drop the leftover root Sharp dependency from package manifests after the Rastermill migration, bound OpenClaw instance logs, plugin gauntlet relay logs, MCP channel buffers, kitchen-sink scans, agent-turn assertions, QA-Lab credential broker calls, QA Matrix substrate requests, and release scenario logs, and keep release/google live guards current. (#87647, #87477) Thanks @rohitjavvadi and @vincentkoc.</li>
|
||||
<li>Release/CI: bound manual git fetches, ClawHub verifier responses, ClawHub owner metadata, dependency-guard error bodies, Parallels limits, startup/test/memory budget parsing, and diffs viewer build warnings so release lanes fail with useful proof instead of hanging. (#87839)</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.28/OpenClaw-2026.5.28.zip" length="54750142" type="application/octet-stream" sparkle:edSignature="U4O55uMdPU+OqSx9QR1ApUJ8wg65wxTydzD7iyCn1GHtm1MBK9noEeiA/yoUKkqb/bx0hzi1gNhn+ye19RXnCA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.5.27</title>
|
||||
<pubDate>Thu, 28 May 2026 12:12:19 +0000</pubDate>
|
||||
@@ -322,5 +258,284 @@
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.26/OpenClaw-2026.5.26.zip" length="54484748" type="application/octet-stream" sparkle:edSignature="y4WXG7JT8ktJ+K7YDgllY7u5Z9BSKR/SwGiwEh0gikOJ/SWqwcQd+z2tWa2zgwvCJKWsAUFwJs1ATor880SUBg=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.5.22</title>
|
||||
<pubDate>Sun, 24 May 2026 01:41:27 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026052290</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.5.22</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.5.22</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Gateway/perf: reuse process-stable channel catalog reads, avoid repeated bundled-channel boundary checks, and rotate gateway watch CPU profiles so benchmark runs do not accumulate unbounded artifacts.</li>
|
||||
<li>Gateway/perf: reuse immutable plugin metadata snapshots across startup, config, model, channel, setup, and secret metadata readers so hot paths avoid repeated plugin file stats and manifest registry reloads.</li>
|
||||
<li>Gateway/perf: lazy-load startup-idle plugin work, core gateway method handlers, and the embedded ACPX runtime so Gateway health and ready signals no longer wait on unused handler trees or ACPX probes.</li>
|
||||
<li>Gateway/perf: cache plugin SDK public-surface alias maps and skip irrelevant macOS Linuxbrew PATH probes so Gateway startup avoids repeated filesystem walks and slow missing-directory stats.</li>
|
||||
<li>Meeting Notes: add a source-only external meeting-notes plugin and SDK source-provider contract outside the core npm package, with auto-start capture config, manual transcript imports, read-only <code>openclaw meeting-notes</code> CLI access, and Discord voice as the first live source.</li>
|
||||
<li>Docs/channels/config: add Signal <code>configPath</code>, Telegram wildcard topic defaults, local-time backup archive names, Termux home fallback, include-path validation, secret-scanner-safe placeholder guidance, Gemini CLI/Antigravity media guidance, and macOS VM auto-login guidance. Thanks @NorseGaud, @yudistiraashadi, @huangqian8, @VibhorGautam, @maweibin, @tianxingleo, @IgnacioPro, and @xzcxzcyy-claw.</li>
|
||||
<li>Docs: clarify model-usage portability, Codex migration prerequisites, status bootstrap wording, thread-bound subagent limits, hook ownership, and config-preserving safety guidance. Thanks @aniruddhaadak80, @leno23, @TomDjerry, @matthewxmurphy, @vincentkoc, and @stablegenius49.</li>
|
||||
<li>Docs: clarify README onboarding and Gateway startup paths, WhatsApp QR/408 recovery, cron output language prompts, skill advanced features, gateway upstream 403 troubleshooting, and plugin fallback override guidance. Thanks @deepujain, @Zacxxx, @Jah-yee, @neyric, @usimic, @Renu-Cybe, @BigUncle, and @SeashoreShi.</li>
|
||||
<li>Docs: clarify context-pruning ratio bounds, local dashboard recovery, CLI env markers, remote onboarding token behavior, and Peekaboo Bridge permissions for subprocess agents. Thanks @ayesha-aziz123, @dishraters, @hougangdev, and @brandonlipman.</li>
|
||||
<li>Docs: clarify browser CDP diagnostics, Plugin SDK allowlist imports, status-reaction timing defaults, queue steering behavior, limited-tool troubleshooting, cron HEARTBEAT handling, Telegram multi-agent groups, Bitwarden SecretRef setup, and EasyRunner deployments. Thanks @Quratulain-bilal, @mbelinky, @Mickey-, @vancece, @xenouzik, @posigit, @surlymochan, @janaka, and @choiking.</li>
|
||||
<li>Crabbox/Testbox: run clean sparse-checkout Testbox syncs from a temporary full checkout and route remote changed gates through Corepack pnpm.</li>
|
||||
<li>Docs: clarify IPv4-only Gateway BYOH binding, trusted-proxy scope clearing, Android pairing approval, macOS Accessibility grants, Zalo profile env vars, password-store SecretRef setup, and Chinese memory navigation. Thanks @itskai-dev, @gwh7078, @longstoryscott, @MoeJaberr, and @yuaiccc.</li>
|
||||
<li>Docs: consolidate GLM under Z.AI, add the Upstash Box install guide and Gateway exposure runbook, clarify MEDIA directives, Copilot and Voyage setup, config path quoting, real behavior proof, and memory-file write guidance. Thanks @BobDu, @alitariksahin, @Jefsky, @musaabhasan, @OmerZeyveli, @leno23, @WuKongAI-CMU, @luoyanglang, and @majin1102.</li>
|
||||
<li>Docs: clarify media provider credentials, Codex/OpenClaw code-mode boundaries, Slack and Telegram ack reactions, Feishu dynamic agents, secrets plaintext boundaries, memory guidance, and Chinese glossary terms. Thanks @nielskaspers, @cosmopolitan033, @drclaw-iq, @alexgduarte, @zccyman, @chengoak, and @cassthebandit.</li>
|
||||
<li>Packaging: exclude documentation images and assets from the npm tarball, reducing published package size without affecting runtime docs search or CLI behavior. Thanks @SebTardif.</li>
|
||||
<li>Media understanding: stop auto-probing Gemini CLI and use Antigravity CLI only as a lower-priority image/video fallback after configured provider APIs.</li>
|
||||
<li>Agents/subagents: limit default sub-agent bootstrap context to <code>AGENTS.md</code> and <code>TOOLS.md</code>, keeping persona, identity, user, memory, heartbeat, and setup files out of delegated workers by default. (#85283) Thanks @100yenadmin.</li>
|
||||
<li>Maintainer skills: exclude plugin SDK/API boundary work from <code>openclaw-landable-bug-sweep</code> so bugbash sweeps stay focused on small paper-cut fixes.</li>
|
||||
<li>QA-Lab/diagnostics: extend the OpenTelemetry smoke harness to prove trace, metric, and log export, and add first-class Prometheus and observability smoke aliases.</li>
|
||||
<li>Plugin SDK: add a generic channel-message poll sender so channel plugins can expose poll delivery without depending on channel-specific SDK facades.</li>
|
||||
<li>Crabbox: keep the local wrapper's provider validation synced with the installed Crabbox binary while preserving supported aliases such as <code>docker</code> and <code>blacksmith</code>. (#85302) Thanks @hxy91819.</li>
|
||||
<li>Maintainer skills: add <code>openclaw-landable-bug-sweep</code> for producing five small, reviewed, CI-green OpenClaw bugfix PRs from issue/PR sweeps.</li>
|
||||
<li>Control UI/chat: add search and Load More pagination to the chat session picker, keeping initial session loads bounded while making older conversations reachable. (#85237) Thanks @amknight.</li>
|
||||
<li>CLI/onboarding: start classic onboarding when bare <code>openclaw</code> runs before an authored config exists, while keeping configured installs on Crestodian. (#72343) Thanks @fuller-stack-dev.</li>
|
||||
<li>Discord: allow configuring a bounded <code>agentComponents.ttlMs</code> callback registry lifetime for long-running component workflows, with per-account overrides and a 24-hour cap. (#84189) Thanks @100menotu001.</li>
|
||||
<li>xAI/Grok: reuse xAI OAuth auth profiles for Grok <code>web_search</code>, thread active-agent auth through web search, add Grok model aliases, and let media providers declare default operation timeouts. (#85182) Thanks @fuller-stack-dev.</li>
|
||||
<li>Plugin SDK: add row-level session workflow helpers and deprecate <code>loadSessionStore</code> so plugins can read and patch sessions without depending on the legacy whole-store shape. (#84693) Thanks @efpiva.</li>
|
||||
<li>Gateway/plugins: reuse a compatible Gateway startup plugin registry during dispatch so safe plugin dispatches avoid redundant registry loading. (#84324) Thanks @ai-hpc.</li>
|
||||
<li>Plugins/SDK: add a general <code>embeddingProviders</code> capability contract and registration API so embeddings can become a reusable provider surface outside memory-specific adapters.</li>
|
||||
<li>Dependencies: refresh provider, plugin, UI, and tooling packages, update <code>protobufjs</code> to 8.4.0 to clear the current npm advisory, and carry the Claude ACP completion patch forward to <code>@agentclientprotocol/claude-agent-acp</code> 0.36.1.</li>
|
||||
<li>Agents/tools: remove the old sender-owner tool gating path so configured tools stay visible for trusted sessions while command and channel-action auth still carry real sender identity.</li>
|
||||
<li>QA-Lab: add curated mock JSONL replay fixtures and first-drift reporting for runtime-parity audits. (#80323, refs #80176) Thanks @100yenadmin.</li>
|
||||
<li>QA-Lab: add a QA bus tool-trace visibility scenario for sanitized tool-call assertions.</li>
|
||||
<li>QA-Lab: replace generic evidence framing in seeded scenario prompts with concrete observed QA behavior.</li>
|
||||
<li>QA-Lab: list named scenario packs in the coverage report so personal-agent privacy coverage stays visible in audits.</li>
|
||||
<li>QA-Lab: list live transport lane membership in the coverage report so real transport checks stay separate from seeded qa-channel scenarios.</li>
|
||||
<li>Release/package: run package integrity checks before package acceptance lanes so public install/update validation fails before private QA assets can leak into the package.</li>
|
||||
<li>QA-Lab: include the optional 100-turn runtime parity soak in release-soak artifacts so long-run Codex/Pi transcript drift stays visible outside the default gate. (#80395) Thanks @100yenadmin.</li>
|
||||
<li>QA-Lab: add a live-only long-context progress watchdog scenario for Codex app-server timeout and stalled-run sentinels. (#80323) Thanks @100yenadmin.</li>
|
||||
<li>QA-Lab: tag gateway restart recovery and streaming final-integrity scenarios as live-only runtime parity lanes. (#80323) Thanks @100yenadmin.</li>
|
||||
<li>QA-Lab: add a personal-agent failure recovery scenario that checks honest partial status, retry boundaries, and local recovery artifacts. (#83872) Thanks @iFiras-Max1.</li>
|
||||
<li>QA-Lab: include an opt-in <code>update.run</code> package self-upgrade sentinel for destructive latest-package recovery checks.</li>
|
||||
<li>QA-Lab: add Codex plugin lifecycle and auth-profile fixture coverage for missing installs, pinned-version drift, first-turn install ordering, and doctor migration safety. (#80323, refs #80174) Thanks @100yenadmin.</li>
|
||||
<li>Models/perf: pre-warm the provider auth-state map at gateway startup so <code>/models</code> and every model-listing call short-circuits the per-provider plugin / external-CLI discovery on the hot path. Per-call cost drops from ~20 s to ~5 ms (~4,100×); the one-time startup warm resets and re-warms after hot reloads. (#84816) Thanks @sjf.</li>
|
||||
<li>Release/security: ship the root npm package and OpenClaw-owned npm plugins with generated shrinkwrap, support bundled plugin runtime dependencies for suitable plugin tarballs, and require review for lockfile/shrinkwrap changes so published installs use locked dependency graphs.</li>
|
||||
<li>Tests/perf: isolate doctor core health check unit coverage from real skills/workspace discovery so <code>doctor-core-checks</code> no longer dominates unit perf while keeping one real skills-readiness smoke. (#84493) Thanks @frankekn.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>WebChat: summarize internal message-tool source replies so tool cards no longer duplicate the visible reply body. (#84773) Thanks @jason-allen-oneal.</li>
|
||||
<li>Gateway: preserve deferred lifecycle-error cleanup across later non-terminal events so provider timeouts can persist failed session state instead of leaving sessions stuck running. (#85256, fixes #63819) Thanks @samzong.</li>
|
||||
<li>Agents/subagents: report tool-only child progress during timeout summaries instead of showing no visible output.</li>
|
||||
<li>Telegram/ACP: preserve explicit <code>:topic:</code> conversation suffixes when inbound ACP targets do not carry a separate thread id.</li>
|
||||
<li>Browser/proxy: bypass the managed proxy for the exact local managed Chrome CDP readiness and DevTools WebSocket endpoints, so <code>openclaw browser start</code> works when the operator proxy blocks loopback egress. (#83255) Thanks @lightcap.</li>
|
||||
<li>Ollama: bypass the managed proxy for configured local embedding origins while keeping SSRF guardrails on unconfigured targets. Thanks @Kaspre.</li>
|
||||
<li>OpenAI/images: route Codex API-key image generation through the native OpenAI Images API instead of the Codex OAuth streaming backend, avoiding 401s from valid API keys.</li>
|
||||
<li>Agents/OpenAI completions: omit empty tool payload fields for proxy-like OpenAI-compatible endpoints so strict vLLM-style servers accept tool-free turns. (#85835) Thanks @rendrag-git.</li>
|
||||
<li>Checks/Windows: route full <code>pnpm check</code> stage commands through the managed child runner so Windows avoids Node shell-argv deprecation warnings there too.</li>
|
||||
<li>Checks/Windows: run managed child commands through explicit <code>cmd.exe</code> wrapping instead of Node shell mode with argv, avoiding Node 24 subprocess deprecation warnings during changed checks.</li>
|
||||
<li>Gateway: omit internal stream-error placeholder entries from agent prompt history so failed assistant turns are not replayed as model-authored text. (#85652) Thanks @anyech.</li>
|
||||
<li>Sessions: enforce the session write-lock max-hold policy during lock acquisition so long-held locks can be reclaimed before the stale-lock window. (#85764) Thanks @njuboy11.</li>
|
||||
<li>Models: prune retired Groq, GitHub Copilot, OpenAI, xAI, and old Claude catalog entries, with doctor migration to upgrade existing configs to current provider refs.</li>
|
||||
<li>Doctor/update: recognize junction-backed source checkouts as git installs by comparing canonical paths before showing package-manager update guidance. Fixes #82215. Thanks @igormf.</li>
|
||||
<li>Channels: honor <code>/verbose on</code> for tool/progress summaries across direct chats, groups, channels, and forum topics while preserving quiet default behavior. (#85488) Thanks @kurplunkin.</li>
|
||||
<li>CLI/skills: show an all-ready note with next-step commands when skill setup has no missing dependencies to install. (#85032) Thanks @aniruddhaadak80.</li>
|
||||
<li>Microsoft Foundry: route DeepSeek V4 Pro and Flash models through the Foundry Responses API while keeping older DeepSeek models on their existing path. (#85549) Thanks @roslinmahmud.</li>
|
||||
<li>Status/usage: show configured cost estimates for AWS SDK models in full usage output while keeping token-only usage replies cost-free. (#85619) Thanks @ItsOtherMauridian.</li>
|
||||
<li>Agents/OpenAI Responses: retry non-visible reasoning-only turns for OpenAI Responses API families instead of treating them as empty failed turns. (#85603) Thanks @SebTardif.</li>
|
||||
<li>Directive tags: preserve message and content-part object identity when display stripping makes no directive-tag changes. (#85682) Thanks @willamhou.</li>
|
||||
<li>Telegram: send local <code>path</code>/<code>filePath</code> and structured attachment media from <code>sendMessage</code> actions instead of dropping them or sending text-only messages. (#85219) Thanks @keshavbotagent.</li>
|
||||
<li>Sessions/status: show the estimated context budget when fresh provider usage is unavailable and clear stale estimates across session resets and compaction boundaries. (#84830) Thanks @giodl73-repo.</li>
|
||||
<li>Gateway/config: pin relative <code>OPENCLAW_STATE_DIR</code> overrides to an absolute path at startup so later working-directory changes cannot retarget gateway state. (#52264) Thanks @PerfectPan.</li>
|
||||
<li>Release/package: run npm release, prepublish, and postpublish verification through Windows-safe npm command shims so native Windows checks can execute <code>npm.cmd</code> instead of treating it as a binary.</li>
|
||||
<li>Agents/harness: pass CLI runtime aliases through harness selection so provider-owned CLI aliases no longer get rejected before reaching the right runtime. (#85631) Thanks @potterdigital.</li>
|
||||
<li>Secrets: show the irreversible apply warning after interactive <code>secrets configure</code> confirmation so confirmed migrations still get the final safety prompt. (#85638) Thanks @alkor2000.</li>
|
||||
<li>Agents/CLI output: ignore cumulative Claude <code>stream-json</code> result usage when assistant usage events are present, preventing inflated cache-read accounting. (#85625) Thanks @zhouhe-xydt.</li>
|
||||
<li>CLI: keep <code>waitForever()</code> alive by leaving its keep-alive interval ref'd so the public helper no longer exits immediately with Node's unsettled-await code. (#85694) Thanks @m1qaweb.</li>
|
||||
<li>Agents/bootstrap: guard bootstrap name checks against missing file names so malformed bootstrap entries warn and truncate instead of crashing. Fixes #85523. (#85615) Thanks @zhouhe-xydt.</li>
|
||||
<li>CLI/tasks: reject partially numeric <code>openclaw tasks audit --limit</code> values so audit limits must be real positive integers instead of accepting strings like <code>5abc</code>. (#84901) Thanks @jbetala7.</li>
|
||||
<li>Status/diagnostics: bound deep Docker audit probes so <code>openclaw status --deep</code> reports slow container checks instead of hanging behind unbounded inspection. (#85476) Thanks @giodl73-repo.</li>
|
||||
<li>Providers/Anthropic: migrate 1M context handling to GA-capable Claude 4.x models by sizing eligible models at 1M without the retired <code>context-1m-2025-08-07</code> beta, ignoring that retired beta in older configs, and preserving OAuth-required Anthropic beta headers. (#45613) Thanks @haoyu-haoyu.</li>
|
||||
<li>Cron/Telegram: parse forum-topic delivery targets through the Telegram plugin instead of cron core, including <code>:topic:</code> and <code>:topicId</code> forms for announce delivery. Thanks @etticat.</li>
|
||||
<li>Twitch: keep stale message-handler cleanup callbacks from removing newer handler registrations for the same account, preserving inbound message delivery after reconnects. Fixes #83888. (#85425) Thanks @alkor2000.</li>
|
||||
<li>Memory/LanceDB: expose public memory artifacts through the active memory provider bridge so memory-wiki imports durable memory files, daily notes, dream reports, and event logs without depending on memory-core internals. Fixes #83604. (#85060) Thanks @brokemac79.</li>
|
||||
<li>Crabbox: keep AWS hydration compatible with local Actions replay by inlining the hydrate workflow's Node/pnpm setup instead of invoking repo-local composite actions.</li>
|
||||
<li>Agents/subagents: simplify native sub-agent completion handoff so children report their latest visible assistant result to the requester without using <code>message</code>, while keeping parent-owned message-tool delivery policy intact. Fixes #85070. (#85089) Thanks @brokemac79.</li>
|
||||
<li>Docker setup: stop printing the Gateway bearer token in setup logs and printed follow-up commands.</li>
|
||||
<li>Agents: let embedded compaction fallback retries proceed when PI-compatible candidates do not need agent harness plugin preparation.</li>
|
||||
<li>Agents/tools: honor configured custom provider API keys when deciding whether media, image-generation, video-generation, music-generation, and PDF tools are available. (#85570)</li>
|
||||
<li>StepFun: stop advertising stale generic API key auth choices so onboarding only offers runtime-backed Standard and Step Plan choices.</li>
|
||||
<li>Diagnostics: keep OpenTelemetry log bodies behind explicit content capture and scrub scoped agent-session keys from OpenTelemetry and Prometheus labels while preserving bounded queue-lane prefixes.</li>
|
||||
<li>Windows installer: fail Git checkout installs when <code>pnpm install</code> or <code>pnpm build</code> fails instead of writing a wrapper to a missing CLI build.</li>
|
||||
<li>Sessions: surface previous-transcript archive failures during <code>/new</code> rotation so disk rename errors are logged instead of silently hiding stranded transcript files. Fixes #81984. (#85586, from #82081) Thanks @0xghost42.</li>
|
||||
<li>TUI/agents: mirror internal-ui message-tool replies into final chat output so message-tool-only agents remain visible in <code>openclaw tui</code>. Fixes #85538. Thanks @danpolasek.</li>
|
||||
<li>Agents: keep parallel OpenAI-compatible tool-call deltas in separate argument buffers so interleaved tool calls no longer corrupt streamed arguments. (#82263) Thanks @luna-system.</li>
|
||||
<li>Memory/doctor: report missing or unusable QMD workspace directories as workspace failures instead of generic binary failures. (#63167) Thanks @sercada.</li>
|
||||
<li>Debug proxy: record CONNECT client-socket errors and destroy the paired upstream socket so abrupt client disconnects no longer leak tunnel resources. (#82444) Thanks @SebTardif.</li>
|
||||
<li>Diffs: continue hydrating later diff cards when one card fails so a single broken card no longer blanks the whole diff viewer. (#84775) Thanks @cosmopolitan033.</li>
|
||||
<li>Mac app: use the native settings sidebar window chrome so the sidebar toggle stays on the left and content no longer clips under oversized titlebar padding.</li>
|
||||
<li>QA-Lab/Codex: bundle auth/plugin fixture imports for flow scenarios and let terminal async media tools end Codex app-server turns without timing out. (#80397, refs #80323) Thanks @100yenadmin.</li>
|
||||
<li>Gateway/agents: preserve fresh session overrides and metadata when stale cached agent-session entries race with store updates, so subagent model/provider overrides and routing policy survive concurrent writes. (#19328) Thanks @CodeReclaimers.</li>
|
||||
<li>Control UI/chat: keep chat session search inline with the session selector so the header no longer shows a duplicate standalone search row.</li>
|
||||
<li>Control UI/chat: collapse focused-mode header chrome and suppress hidden-header scroll updates so focus mode no longer jumps while scrolling. Thanks @amknight.</li>
|
||||
<li>Codex app-server: restart the native app-server and retry once when server-side compaction times out, so preflight compaction stalls recover instead of failing every dispatch. (#85500)</li>
|
||||
<li>Restore Control UI gateway token pairing [AI]. (#85459) Thanks @pgondhi987.</li>
|
||||
<li>OpenAI video: honor configured provider request private-network opt-in for local/custom video endpoints so explicitly trusted mock and self-hosted providers are not blocked. Thanks @shakkernerd.</li>
|
||||
<li>OpenAI video: send uploaded video edit requests to the documented <code>/videos/edits</code> endpoint with a <code>video</code> file instead of posting MP4 references to <code>/videos</code>. Thanks @shakkernerd.</li>
|
||||
<li>Agents/channels: preserve message-tool delivery evidence through gateway agent completion handoffs so successful generated media sends are not followed by false failure messages. Thanks @shakkernerd.</li>
|
||||
<li>CLI/update: repair managed npm plugin <code>openclaw</code> peer links during post-core convergence and reject stale or wrong-target peer links before restart. (#83794) Thanks @fuller-stack-dev.</li>
|
||||
<li>CLI/agents: default new omitted-account bindings to all accounts when the channel has multiple configured accounts, and clarify account-scope docs. (#49769) Thanks @Gcaufy.</li>
|
||||
<li>Codex app-server: let authorized <code>/codex</code> control commands such as <code>/codex detach</code> escape plugin-owned conversation bindings while keeping unknown or unauthorized slash text routed to the bound plugin. Fixes #85157. (#85188) Thanks @TurboTheTurtle.</li>
|
||||
<li>Auto-reply/models: keep <code>/models</code> browse replies fast by sharing the bounded read-only catalog path with Gateway model listing. (#84735) Thanks @safrano9999.</li>
|
||||
<li>Codex app-server: disable native Code Mode when the effective exec host is <code>node</code> and keep OpenClaw <code>exec</code>/<code>process</code> available, so <code>/exec host=node</code> routes shell commands through the selected node instead of the gateway. Fixes #85012. (#85090) Thanks @sahilsatralkar.</li>
|
||||
<li>Agents: bound embedded auto-compaction session write-lock watchdogs to the compaction timeout instead of the full run timeout, so stuck compaction cannot hold the live session lock for the whole run window. (#84949) Thanks @luoyanglang.</li>
|
||||
<li>Gateway/agents: return phase-aware <code>agent.wait</code> timeout attribution and only cool auth profiles on provider-started timeouts. Refs #65504. Thanks @100yenadmin.</li>
|
||||
<li>Gateway: defer provider auth-state prewarm until after startup readiness so early gateway tool/session requests are not blocked by provider auth discovery. (#85272) Thanks @dutifulbob.</li>
|
||||
<li>Gateway/models: coalesce provider auth-state rewarms after auth-profile failures and log event-loop delay for warm/rewarm work, so provider auth bursts no longer stack full auth sweeps behind channel replies.</li>
|
||||
<li>Gateway/models: stop cancelled provider auth-state prewarms from continuing full provider sweeps, so reload and auth-failure bursts no longer keep startup busy.</li>
|
||||
<li>Agents/Codex: show the first plan update as a transient chat status notice without counting it as final assistant content.</li>
|
||||
<li>CLI/update: walk the macOS process ancestry and honor the inherited Gateway runtime PID before package updates stop the managed Gateway service, so nested in-band updater children can refuse instead of killing the LaunchAgent-supervised Gateway that owns them. Fixes #85120.</li>
|
||||
<li>Gateway/LaunchAgent: wait for launchd reload bootout to finish and fall back to kickstart when bootstrap races, so reload handoff does not leave the service deregistered. Fixes #84630. (#84641) Thanks @NianJiuZst.</li>
|
||||
<li>Gateway/LaunchAgent: treat a concurrent launchd bootstrap as a successful restart when the service is already loaded, avoiding false macOS Gateway restart failures. Fixes #84721. (#84722) Thanks @googlerest.</li>
|
||||
<li>Gateway/service: include the active <code>openclaw</code> command bin directory in managed service PATH generation and doctor audit expectations for npm-global macOS installs. Fixes #84201. (#84475) Thanks @jbetala7.</li>
|
||||
<li>Control UI/chat: disable the thinking selector for known non-reasoning models instead of showing duplicate Off choices. Fixes #84069. Thanks @DrippingMellow.</li>
|
||||
<li>Memory: expand <code>~</code> in configured extra memory paths before resolving them, so home-relative folders are not treated as workspace-relative. Fixes #58026. Thanks @stadman.</li>
|
||||
<li>Skills: treat <code>openclaw.os: macos</code> as Darwin when checking skill requirements, so macOS-only skills no longer report as missing on macOS hosts. Fixes #61338. Thanks @Jessecq1995.</li>
|
||||
<li>Control UI/logs: strip ANSI escape sequences from displayed Gateway log messages so color codes no longer appear as raw text. Fixes #64399. Thanks @guguangxin-eng.</li>
|
||||
<li>Docker: pre-create the workspace and auth-profile config mount points with <code>node</code> ownership so first-run named volumes do not start root-owned. Fixes #85076. Thanks @Noerr.</li>
|
||||
<li>Telegram: pass configured markdown table mode through outbound markdown chunking so chunked sends render tables consistently. Fixes #85085. Thanks @ShuaiHui.</li>
|
||||
<li>CLI/update: preserve managed Gateway service environment during package cutovers so macOS LaunchAgent repair/restart reads the pre-update service state instead of caller shell state. (#83026)</li>
|
||||
<li>Agents/providers: honor per-model <code>api</code> and <code>baseUrl</code> overrides in custom provider auth hooks and transport selection. Fixes #80487. (#80488) Thanks @huveewomg.</li>
|
||||
<li>Gateway/restart: eager-load the lifecycle runtime before in-place upgrade signal handling so package replacement does not deadlock restart imports. (#84890) Thanks @myps6415.</li>
|
||||
<li>CLI/update: start managed Gateway update handoff helpers from a stable existing directory and tolerate deleted cwd/package roots during macOS LaunchAgent handoff. Fixes #83808. (#83875) Thanks @jason-allen-oneal.</li>
|
||||
<li>Skills: watch each shared skill directory once across agent workspaces instead of once per agent, preventing file-descriptor exhaustion (<code>EMFILE</code>) that disposed bundle-mcp processes and stalled sessions on multi-agent gateways. Fixes #84968. (#85130) Thanks @openperf.</li>
|
||||
<li>Release/security: keep generated npm shrinkwrap package versions inside the pnpm lock graph so published package locks cannot bypass pnpm dependency age and override policy.</li>
|
||||
<li>Cron: honor <code>cron.retry.retryOn: ["network"]</code> for common network error codes such as <code>EAI_AGAIN</code>, <code>EHOSTUNREACH</code>, and <code>ENETUNREACH</code>.</li>
|
||||
<li>Gateway chat: broadcast returned agent-run error payloads after an agent starts so ACP/WebChat clients receive terminal idle-timeout errors. Fixes #84945.</li>
|
||||
<li>Gateway chat display: preserve OpenAI-compatible <code>prompt_tokens</code>, <code>completion_tokens</code>, and <code>total_tokens</code> usage fields in sanitized chat history so llama.cpp sessions keep context counts. Fixes #77992. Thanks @MarTT79.</li>
|
||||
<li>Dashboard/CLI: allow macOS browser launching through <code>open</code> even when SSH environment variables are present, while preserving Linux SSH no-display protection. Fixes #67088. Thanks @theglove44.</li>
|
||||
<li>Codex app-server: keep native web search observations out of mirrored chat transcripts while preserving tool progress telemetry. Fixes #85109. Thanks @ugitmebaby.</li>
|
||||
<li>OpenCode Go: strip unsupported Kimi reasoning replay fields before provider requests so repeated <code>kimi-k2.6</code> turns do not fail schema validation. Fixes #83812. Thanks @Sleeck.</li>
|
||||
<li>Browser/CDP: add a WSL2 portproxy self-loop hint when Chrome DevTools endpoints accept connections but return an empty HTTP reply. Fixes #59209. Thanks @Owlock.</li>
|
||||
<li>Agents/OpenAI: preserve structured provider error code, type, and redacted body metadata on boundary-aware transport failures.</li>
|
||||
<li>Doctor/Codex: point native Codex asset warnings at the canonical <code>openclaw migrate plan codex</code> preview command. Fixes #84948. Thanks @markoa.</li>
|
||||
<li>CLI/models: make <code>capability model auth logout --agent</code> remove auth profiles from the selected non-default agent store. Fixes #85092. Thanks @islandpreneur007.</li>
|
||||
<li>Gateway/models: reuse prepared provider auth metadata during model-listing auth checks so repeated lookups avoid broad plugin discovery while preserving synthetic local auth.</li>
|
||||
<li>CLI/status: suppress systemd user-service setup hints when <code>openclaw status --deep</code> can already reach a running Gateway RPC service. Fixes #85094. Thanks @islandpreneur007.</li>
|
||||
<li>CLI/devices: recover local approval when a same-device repair request replaces the request ID being approved.</li>
|
||||
<li>CLI/agents: retry transient normal-close Gateway handshakes before falling back to embedded <code>openclaw agent</code> execution.</li>
|
||||
<li>CLI/update: keep managed Gateway service stop/restart status lines out of <code>openclaw update --json</code> stdout so package-update automation can parse the JSON payload.</li>
|
||||
<li>Plugins: resolve OpenClaw plugin SDK subpaths for native external plugin runtimes without mutating package installs or broadening process-wide module resolution.</li>
|
||||
<li>Agents/OpenAI: preserve Responses and Chat Completions <code>reasoning_tokens</code> usage metadata without double-counting it in aggregate output tokens. (#85319)</li>
|
||||
<li>Control UI/chat: convert pasted <code>data:image/...;base64,...</code> clipboard text into an image attachment instead of dumping the payload into the composer. Fixes #62604. Thanks @cpwilhelmi.</li>
|
||||
<li>Providers/Gemini: strip fractional seconds from web-search time range filters so Gemini accepts freshness-bound search requests. (#85071) Thanks @Noerr.</li>
|
||||
<li>OpenAI Codex: preserve image input support for sparse <code>openai-codex/gpt-5.5</code> catalog rows. (#85095) Thanks @sercada.</li>
|
||||
<li>CLI/models: add a piped or pasted API-key path for OpenAI Codex auth and warn when API keys are pasted into token-mode auth. (#85533) Thanks @joshavant.</li>
|
||||
<li>Telegram: dead-letter missing-harness isolated ingress failures so a poisoned spooled update no longer blocks later same-lane messages. Fixes #85470. (#85605) Thanks @joshavant.</li>
|
||||
<li>Plugins/discovery: strip <code>-plugin</code> package suffixes when deriving plugin id hints so package names line up with manifest ids. (#85170) Thanks @JulyanXu.</li>
|
||||
<li>Tlon: stop advertising a non-existent agent tool contract in the plugin manifest.</li>
|
||||
<li>Telegram: preserve fenced code block languages through Markdown rendering so Telegram receives <code>language-*</code> code classes. (#85209) Thanks @leno23.</li>
|
||||
<li>Windows installer: run npm and Corepack command shims from a Windows-local directory so installs launched from WSL2 UNC paths do not fail before OpenClaw is installed.</li>
|
||||
<li>Windows updates: roll back git-backed updates to the previous checkout when dependency install, build, UI build, or doctor repair fails.</li>
|
||||
<li>Windows installer: persist user-local portable Git on PATH and activate the repo-pinned pnpm version for git-backed installs and updates.</li>
|
||||
<li>Windows installer: bootstrap a user-local portable Node.js when native Windows has no Node and no winget, Chocolatey, or Scoop, so first-run installs can continue on raw hosts.</li>
|
||||
<li>Windows installer: extract the downloaded portable Node.js directory with native <code>tar</code> before falling back to .NET zip extraction, avoiding PowerShell 5.1 archive and path-length failures.</li>
|
||||
<li>fix(integrations): enforce channel read target allowlists [AI]. (#84982) Thanks @pgondhi987.</li>
|
||||
<li>Agents/heartbeat: route single-owner <code>session.dmScope=main</code> direct-message exec and cron event wakes back to the agent main session so async completions no longer strand context in orphan direct-DM queues. Fixes #71581. (#83743) Thanks @Kaspre.</li>
|
||||
<li>Agents/code-mode: expose outer code-mode <code>exec</code> source through the <code>command</code> hook alias with <code>toolKind</code>/<code>toolInputKind</code> discriminators so exec-shaped policies can distinguish code-mode cells. (#83483) Thanks @Kaspre.</li>
|
||||
<li>Agents/code mode: return structured timeout and runtime-unavailable error codes for known worker failures. Fixes #83389. (#83444) Thanks @Kaspre.</li>
|
||||
<li>QA-Lab: isolate multi-scenario suite workers when scenarios need startup config patches, preventing message-routing config from leaking into unrelated scenarios.</li>
|
||||
<li>QA-Lab: make the commitments heartbeat-target-none scenario request an immediate heartbeat instead of waiting for the next scheduled heartbeat.</li>
|
||||
<li>Codex/Plugin SDK: deliver Codex-native subagent completions through a generic harness task runtime so harness-backed plugins can mirror durable task lifecycle and completion delivery without Codex-specific SDK imports. (#83445) Thanks @bryanpearson.</li>
|
||||
<li>Gateway CLI: surface local post-challenge connect assembly failures immediately instead of waiting for the wrapper timeout. Fixes #68944. (#85253) Thanks @samzong.</li>
|
||||
<li>Messages: strip unsupported web-search citation control markers from outbound replies before they reach WebChat or external channels. Fixes #85193. (#85204) Thanks @neeravmakwana.</li>
|
||||
<li>Agents/exec: treat denied exec approvals as terminal instead of feeding them back into agent follow-up work, and recognize Chinese stop phrases in abort handling. Fixes #69386. (#85194) Thanks @samzong.</li>
|
||||
<li>CLI/agents: abort accepted Gateway-backed <code>openclaw agent</code> runs on SIGINT/SIGTERM so cron and supervisor timeouts do not leave remote agent work alive. Fixes #71710. (#84381) Thanks @Kaspre.</li>
|
||||
<li>Codex app-server: retry replay-safe stdio client-close turns once using structured failure metadata, while surfacing idle <code>turn/completed</code> timeouts instead of blindly replaying active shared-server turns. Thanks @VACInc.</li>
|
||||
<li>Codex app-server: reject command overrides that embed Node or package-manager arguments and point users to <code>appServer.args</code>, so Windows startup avoids shell parsing failures. (#84417) Thanks @TurboTheTurtle.</li>
|
||||
<li>Agents/Copilot: drop unsafe GitHub Copilot Responses reasoning replay items before send so Telegram direct sessions no longer fail on overlong replay IDs. Fixes #85197. (#85198) Thanks @galiniliev.</li>
|
||||
<li>UI: add accessible tooltips to the topbar color-mode buttons so System, Light, and Dark choices are labeled on hover and focus. (#85227) Thanks @amknight.</li>
|
||||
<li>fix: constrain Windows task script names [AI]. (#85064) Thanks @pgondhi987.</li>
|
||||
<li>Control UI: keep the chat session picker from hiding older or cross-agent configured conversations while preserving the bounded configured-agent refresh. (#85211) Thanks @amknight.</li>
|
||||
<li>Agents/Anthropic: preserve unsafe integer tool-call input values in streamed Anthropic tool-use JSON, preventing Discord-style IDs from being rounded before dispatch. Fixes #47229. (#83063) Thanks @leno23.</li>
|
||||
<li>Agents/Codex: estimate tool-heavy prompt pressure at the LLM boundary before provider submission, so persistent sessions compact before overflowing context windows. (#85541) Thanks @fuller-stack-dev and @joshavant.</li>
|
||||
<li>Agents/hooks: wait for local one-shot CLI and Codex <code>agent_end</code> plugin hooks before process cleanup so terminal observability flushes reliably. (#85007)</li>
|
||||
<li>Providers/Google: preserve Gemini 3 cron <code>thinkingDefault: "low"</code> when stale catalog metadata says <code>reasoning:false</code>, so scheduled runs keep provider-supported thinking instead of downgrading to off. (#85185) Thanks @neeravmakwana.</li>
|
||||
<li>CLI/agents: allow <code>openclaw agent --session-key</code> to target explicit session keys, including agent-scoped legacy keys. (#85121) Thanks @Kaspre.</li>
|
||||
<li>Auto-reply/ACP: wait for same-channel block reply delivery before starting tool work, while still honoring ACP dispatch aborts so stopped turns do not wait on slow channel sends. (#83722) Thanks @IWhatsskill.</li>
|
||||
<li>Codex/ACP: mark required child-run completions that only report progress, omit a final deliverable, or fail requester delivery as blocked while preserving real final reports. (#85110) Thanks @IWhatsskill.</li>
|
||||
<li>Channels: treat bare abort messages such as <code>stop</code>, <code>abort</code>, and <code>wait</code> as immediate control commands in inbound debounce paths so stop requests are not delayed behind pending message coalescing. (#83348) Thanks @IWhatsskill.</li>
|
||||
<li>Channels/message tool: resolve configured external channel plugins during in-agent channel selection, so <code>openclaw agent --local</code> message-tool sends no longer report an available channel as unavailable. (#85022) Thanks @Kaspre.</li>
|
||||
<li>Agents/heartbeat: honor group/channel <code>message_tool</code> visible-reply policy and model-specific Codex runtime config for scheduled heartbeat runs, so failed internal tool output stays private. Fixes #85310. (#85357) Thanks @neeravmakwana.</li>
|
||||
<li>Gateway/ACP: close child ACP sessions spawned via <code>sessions_spawn</code> when their parent session is reset or deleted, instead of leaving orphaned <code>claude-agent-acp</code> processes that accumulate and exhaust memory. Fixes #68916. (#85190) Thanks @openperf.</li>
|
||||
<li>Codex app-server: block native execution paths when OpenClaw exec resolves to a node host while preserving the first-party CLI node binding path. Fixes #85012. (#85534) Thanks @joshavant.</li>
|
||||
<li>Diagnostics: bound cleanup timeout detail logs, emit drop summaries when async diagnostic bursts exceed the queue cap, and surface async queue drops through diagnostic telemetry.</li>
|
||||
<li>Agents/subagents: surface blocked child-run completions as errors instead of successful subagent finishes. (#80886) Thanks @TurboTheTurtle.</li>
|
||||
<li>Context engines: fail closed with a descriptive error when the selected agent runtime cannot satisfy declared context-engine host requirements.</li>
|
||||
<li>Agents/Pi: treat accepted embedded <code>sessions_spawn</code> child-session handoffs as terminal progress so parent turns no longer report false non-deliverable failures. (#85054) Thanks @samzong.</li>
|
||||
<li>CLI/models: resolve <code>openclaw models set</code> aliases from the runtime config while keeping authored aliases ahead of runtime-only defaults. (#83262) Thanks @IWhatsskill.</li>
|
||||
<li>Doctor: show personal Codex CLI asset notices as info instead of warnings. Fixes #84859.</li>
|
||||
<li>WhatsApp: update Baileys to <code>7.0.0-rc13</code> and drop the obsolete logger type patch.</li>
|
||||
<li>CLI/update: pre-pack GitHub/git package update targets before the staged npm install, restoring <code>openclaw update --tag main</code> for one-off package updates. (#81296) Thanks @fuller-stack-dev.</li>
|
||||
<li>Gateway: mirror successful same-source message-tool sends into session transcripts so delivered replies stay in later history/context. (#84837) Thanks @iFiras-Max1.</li>
|
||||
<li>Media generation: keep image, music, and video completion delivery from duplicating or losing task ownership when generated media finishes through active session replies. (#84006) Thanks @fuller-stack-dev.</li>
|
||||
<li>Infra/json: retry transient <code>File changed during read</code> races while loading JSON state so config and state reads recover instead of failing the turn. (#84285)</li>
|
||||
<li>Plugins/providers: fail closed for workspace provider plugins during setup-mode discovery unless explicitly trusted, preventing untrusted workspace plugin code from running during provider setup. (#81069) Thanks @mmaps.</li>
|
||||
<li>Providers/Ollama: resolve configured Ollama Cloud <code>OLLAMA_API_KEY</code> markers to the real discovery key so cloud provider entries keep authenticated model catalog access. (#85037)</li>
|
||||
<li>Discord: keep persistent component registry fallback warnings actionable by forwarding structured error and cause metadata through the runtime logger. Fixes #84185. (#84190) Thanks @100menotu001.</li>
|
||||
<li>Gateway/sessions: preserve compatible session auth profile overrides when switching models within the same provider, including provider-auth aliases. Fixes #81837. (#81886) Thanks @TurboTheTurtle.</li>
|
||||
<li>Gateway/status: surface inbound delivery telemetry counters and transport-liveness warnings in <code>openclaw status --all</code>. Fixes #49577. (#72724)</li>
|
||||
<li>Docker: prune package-excluded plugin source workspaces and dependency closures so runtime images do not keep packages for plugins that were not opted in.</li>
|
||||
<li>Providers/Ollama: treat Docker/OrbStack host aliases as local Ollama endpoints so <code>ollama-local</code> marker auth works when OpenClaw runs inside a VM/container and Ollama runs on the host. Fixes #84875.</li>
|
||||
<li>QA-Lab: keep explicitly searchable/deferred OpenClaw dynamic tool rows report-only by default so tool-coverage gates do not treat mock discovery gaps as hard product failures. (#80319) Thanks @100yenadmin.</li>
|
||||
<li>Agents/config: keep non-Google provider model refs from being rewritten by Google Gemini preview-id normalization. (#84762) Thanks @zhangguiping-xydt.</li>
|
||||
<li>Installer: require a real controlling terminal before launching onboarding so headless <code>curl | bash</code> installs finish cleanly after installing the CLI.</li>
|
||||
<li>Agents/Codex: promote a completed final assistant response when a prompt timeout races Codex app-server completion instead of returning an empty timeout envelope. Refs #84516.</li>
|
||||
<li>Codex app-server: keep interrupted turn statuses from being treated as OpenClaw aborts by themselves, so tool-only turns remain eligible for no-visible-answer recovery. Fixes #84492.</li>
|
||||
<li>Agents: cap heartbeat model bleed context hints by the stored session window when runtime model metadata is unavailable, so overflow recovery advice does not suggest a larger window than the active session actually has.</li>
|
||||
<li>Control UI/Web Push: use <code>https://openclaw.ai</code> as the generated default VAPID subject instead of the old localhost mailbox so iOS PWA push setup uses an Apple-acceptable subject when <code>OPENCLAW_VAPID_SUBJECT</code> is unset. Fixes #83134. (#83317) Thanks @IWhatsskill.</li>
|
||||
<li>Control UI: distinguish inherited thinking-off settings from explicit Off selections so the thinking selector no longer shows two identical Off rows. (#85223) Thanks @amknight.</li>
|
||||
<li>Agents/Pi: keep embedded session transcript writes from tripping false takeover detection after packaged npm onboarding agent turns.</li>
|
||||
<li>Codex/TUI: surface Codex-native post-turn compaction failures instead of continuing uncompacted, and keep successful native compaction serialized before local idle/next-turn handling. Fixes #84305. (#85160) Thanks @joshavant.</li>
|
||||
<li>Memory/search: stop recall tracking from writing dreaming side-effect artifacts when <code>dreaming.enabled=false</code>, while preserving normal search results. Fixes #84436. (#84444) Thanks @NianJiuZst.</li>
|
||||
<li>Diffs: render viewer toolbar icons from a closed icon-name map instead of HTML strings, removing the toolbar icon XSS sink. (#83955) Thanks @tanshanshan.</li>
|
||||
<li>QA: keep <code>pnpm qa:e2e</code> self-check runs inside the private QA runtime envelope even when inherited shell env disables bundled plugins.</li>
|
||||
<li>fix(config): validate browser sandbox bind sources [AI]. (#84799) Thanks @pgondhi987.</li>
|
||||
<li>doctor: constrain legacy plugin cleanup paths [AI]. (#84801) Thanks @pgondhi987.</li>
|
||||
<li>Update/doctor: prune stale local bundled plugin install records that point at old compiled bundled output so current bundled plugin schemas win after upgrade. (#84863) Thanks @fuller-stack-dev.</li>
|
||||
<li>Providers/Ollama: preserve native Ollama tool-call IDs across assistant replay so Gemini over Ollama Cloud can keep its hidden function-call thought-signature handle.</li>
|
||||
<li>Discord: keep session recovery and <code>/stop</code> abort ownership on the source dispatch lane while bound ACP turns continue routing to their target session, so stalled pre-run work and late replies are cleared instead of leaking after stop. Fixes #84477. (#85100) Thanks @joshavant.</li>
|
||||
<li>Codex app-server: mark missing turn completion after observed execution as replay-unsafe and release the session so follow-up turns can run. Fixes #84076. (#85107) Thanks @joshavant.</li>
|
||||
<li>Codex app-server: give visible <code>message</code> dynamic tool sends a longer timeout budget so slow channel delivery can return its own result or error instead of hitting the 30-second Codex wrapper. (#85216) Thanks @amknight.</li>
|
||||
<li>Codex app-server: add a dedicated post-tool raw assistant completion idle timeout config so trusted heavy turns can wait longer after tool handoff without weakening final assistant release.</li>
|
||||
<li>Matrix: keep explicitly configured two-person rooms on the room route before stale <code>m.direct</code> or strict two-member DM fallback can bypass mention gating. Fixes #85017. (#85137) Thanks @joshavant.</li>
|
||||
<li>Agents/subagents: require explicit subagent allowlist targets to be configured agents so stale deleted-agent ids are omitted from <code>agents_list</code> and rejected by <code>sessions_spawn</code>. Fixes #84811. (#85154) Thanks @joshavant.</li>
|
||||
<li>PDF tool: time out idle remote PDF body reads after 120 seconds so stalled remote documents return an error instead of wedging the session. Fixes #68649. (#84768) Thanks @luoyanglang.</li>
|
||||
<li>Diagnostics/OpenTelemetry plugin: suppress handled OTLP exporter promise rejections so collector shutdowns no longer crash the Gateway. (#81085) Thanks @luoyanglang.</li>
|
||||
<li>Agents/exec: omit raw command text and env values from denied exec failure logs while keeping safe correlation metadata. Fixes #85049. (#85140) Thanks @joshavant.</li>
|
||||
<li>Media/audio: skip empty structured sherpa-onnx transcripts instead of treating the raw JSON payload as spoken text. (#84667) Thanks @TurboTheTurtle.</li>
|
||||
<li>Agents/exec: preserve inherited XDG base-directory environment values for subprocesses while still rejecting agent-supplied XDG overrides. Fixes #84854. (#85139) Thanks @joshavant.</li>
|
||||
<li>Node/Linux: keep <code>OPENCLAW_GATEWAY_TOKEN</code> out of generated systemd unit files by writing node service token values to a node-specific env file. (#84408)</li>
|
||||
<li>Memory-core/dreaming: reuse stable narrative subagent session keys per workspace and phase while keeping per-run idempotency and bounded cleanup, so stale <code>dreaming-narrative-*</code> sessions do not accumulate. Fixes #68252, #69187, and #70402. (#70464) Thanks @chiyouYCH.</li>
|
||||
<li>Trajectory/support: tolerate partial skill snapshot entries when building support metadata so rejected skill path scans no longer abort trajectory capture. (#71185) Thanks @lukeboyett.</li>
|
||||
<li>TUI: coalesce repeated idle Esc abort notices into a single <code>no active run xN</code> system row instead of appending duplicate rows.</li>
|
||||
<li>Telegram: honor <code>channels.telegram.pollingStallThresholdMs</code> in the default isolated polling path, restarting silent workers instead of leaving inbound updates wedged. Fixes #83950. (#84861) Thanks @joshavant.</li>
|
||||
<li>Telegram: dedupe replayed message dispatches by Telegram chat/message identity so isolated-ingress replays do not trigger duplicate model dispatches. Fixes #84886. (#85208) Thanks @joshavant.</li>
|
||||
<li>Slack: suppress reasoning payloads before reply delivery and dispatch accounting, so Slack monitor, slash-command, fallback, and direct reply paths do not leak model reasoning. Fixes #84319. (#84322) Thanks @ffluk3 and @joshavant.</li>
|
||||
<li>Slack: deliver native plugin approval prompts and updates when Slack native approvals are enabled, while keeping plugin approval authorization separate from exec approvers.</li>
|
||||
<li>Slack: keep native plugin approval prompts in the originating app conversation thread when the live Slack turn source is a <code>D...</code> conversation.</li>
|
||||
<li>Agents/Pi: disable the embedded pi-coding-agent runtime auto-retry so OpenClaw's own retry and failover loop does not replay failed tool calls through a nested SDK retry. Fixes #73781. (#74434) Thanks @yelog.</li>
|
||||
<li>CLI/perf: keep <code>setup --help</code>, <code>onboard --help</code>, and <code>configure --help</code> out of the full wizard runtime while preserving the existing help output. (#84488) Thanks @frankekn.</li>
|
||||
<li>CLI/perf: keep <code>agents --help</code> out of agents action/runtime imports so help, completion, and command discovery paths avoid loading the full agents runtime. (#84483) Thanks @frankekn.</li>
|
||||
<li>CLI/perf: keep <code>secrets --help</code> and <code>nodes --help</code> on the precomputed help path so parent help avoids loading action-heavy command runtime modules. (#84818) Thanks @frankekn.</li>
|
||||
<li>CLI/perf: serve <code>doctor</code>, <code>gateway</code>, <code>models</code>, and <code>plugins</code> parent help from startup metadata so common subcommand help avoids full CLI program construction. (#84786) Thanks @frankekn.</li>
|
||||
<li>Codex/Lossless: keep context-engine history on the canonical run session when Telegram DMs use per-peer runtime policy keys. Fixes #84936. (#84954) Thanks @neeravmakwana.</li>
|
||||
<li>Codex: keep heartbeat response tool schemas durable without exposing dynamic tools disabled by turn policy, so heartbeat wakeups can reuse threads while scoped tool allowlists stay enforced. (#84681) Thanks @jalehman.</li>
|
||||
<li>Auth/OAuth: skip the refresh adapter when a stored OAuth credential has no refresh token so agent turns fail fast on missing-key instead of waiting on the 120s refresh timeout. Thanks @romneyda.</li>
|
||||
<li>Auth/Codex: load legacy OAuth sidecar credentials in the embedded runner's secrets-runtime auth loaders so Telegram replies, cron-triggered turns, and other isolated sub-agent lanes can reach the existing #83312 refresh-and-rewrite migration instead of failing with <code>No API key found for provider "openai-codex"</code> until the user runs <code>openclaw doctor</code>. Thanks @Totalsolutionsync and @romneyda.</li>
|
||||
<li>Codex/failover: classify <code>deactivated_workspace</code> as a permanent auth failure so configured fallback models can advance when a Codex workspace is deactivated. (#55893) Thanks @litang9.</li>
|
||||
<li>Exec: keep configured <code>tools.exec.pathPrepend</code> entries ahead of user shell startup PATH changes on POSIX gateway runs. (#81403) Thanks @medns.</li>
|
||||
<li>Gateway/sessions: allow shared-secret bearer callers to read and stream session history without an explicit scope header. (#81815) Thanks @medns.</li>
|
||||
<li>Agents/embedded runner: classify HTML auth provider responses as <code>auth_html</code> and return a re-authentication hint instead of the CDN-blocked copy that <code>upstream_html</code> returns. Cloudflare Access login pages, nginx basic-auth challenges, and gateway login walls all produce HTML auth bodies that were previously misdiagnosed as transient CDN blocks. (#79900) Thanks @martingarramon.</li>
|
||||
<li>TUI/streaming watchdog: dismiss the <code>This response is taking longer than expected</code> notice as soon as a chat event for the same run arrives, so the message no longer sits next to the recovered response when the run was only briefly silent. Refs #67052, #69081 (closed), prior attempt #69026. Thanks @jpruit20 and @romneyda.</li>
|
||||
<li>Agents/Pi: tolerate OpenClaw-owned transcript writes while embedded prompts are released for model I/O, keeping long-running Feishu, Slack, Telegram, and cron turns from failing with false session-takeover errors. Fixes #84059. (#84250) Thanks @tianxiaochannel-oss88.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.22/OpenClaw-2026.5.22.zip" length="54409357" type="application/octet-stream" sparkle:edSignature="am1mwLOmUHor9QuQWtxSsKoBOCySUBo4fB+0Qdcrz0E3wf6ESIMTfOC0k+dKJSh9gtLZw5jzpWVqTBzEdU36Aw=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -65,8 +65,8 @@ android {
|
||||
applicationId = "ai.openclaw.app"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 2026053001
|
||||
versionName = "2026.5.30"
|
||||
versionCode = 2026052801
|
||||
versionName = "2026.5.28"
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
|
||||
@@ -6,7 +6,6 @@ import ai.openclaw.app.chat.ChatPendingToolCall
|
||||
import ai.openclaw.app.chat.ChatSessionEntry
|
||||
import ai.openclaw.app.chat.OutgoingAttachment
|
||||
import ai.openclaw.app.gateway.DeviceAuthStore
|
||||
import ai.openclaw.app.gateway.DeviceAuthTokenStore
|
||||
import ai.openclaw.app.gateway.DeviceIdentityStore
|
||||
import ai.openclaw.app.gateway.GatewayDiscovery
|
||||
import ai.openclaw.app.gateway.GatewayEndpoint
|
||||
@@ -80,7 +79,6 @@ class NodeRuntime(
|
||||
context: Context,
|
||||
val prefs: SecurePrefs = SecurePrefs(context.applicationContext),
|
||||
private val tlsFingerprintProbe: suspend (String, Int) -> GatewayTlsProbeResult = ::probeGatewayTlsFingerprint,
|
||||
private val deviceAuthStore: DeviceAuthTokenStore = DeviceAuthStore(context.applicationContext),
|
||||
) {
|
||||
data class GatewayConnectAuth(
|
||||
val token: String?,
|
||||
@@ -90,6 +88,7 @@ class NodeRuntime(
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val deviceAuthStore = DeviceAuthStore(prefs)
|
||||
val canvas = CanvasController()
|
||||
val camera = CameraCaptureManager(appContext)
|
||||
val location = LocationCaptureManager(appContext)
|
||||
@@ -110,6 +109,7 @@ class NodeRuntime(
|
||||
|
||||
private val cameraHandler: CameraHandler =
|
||||
CameraHandler(
|
||||
appContext = appContext,
|
||||
camera = camera,
|
||||
externalAudioCaptureActive = externalAudioCaptureActive,
|
||||
showCameraHud = ::showCameraHud,
|
||||
@@ -119,6 +119,7 @@ class NodeRuntime(
|
||||
|
||||
private val debugHandler: DebugHandler =
|
||||
DebugHandler(
|
||||
appContext = appContext,
|
||||
identityStore = identityStore,
|
||||
)
|
||||
|
||||
@@ -2870,7 +2871,7 @@ fun providerDisplayName(provider: String): String =
|
||||
when (provider.trim().lowercase()) {
|
||||
"openai" -> "OpenAI"
|
||||
"openrouter" -> "OpenRouter"
|
||||
"codex" -> "Codex"
|
||||
"openai-codex", "codex" -> "Codex"
|
||||
"ollama", "ollama-local" -> "Ollama Local"
|
||||
else ->
|
||||
provider
|
||||
|
||||
@@ -442,23 +442,6 @@ class SecurePrefs(
|
||||
securePrefs.edit { remove(key) }
|
||||
}
|
||||
|
||||
fun keysWithPrefix(prefix: String): Set<String> =
|
||||
securePrefs
|
||||
.all
|
||||
.keys
|
||||
.filter { it.startsWith(prefix) }
|
||||
.toSet()
|
||||
|
||||
fun removeKeysWithPrefix(prefix: String) {
|
||||
val keys = keysWithPrefix(prefix)
|
||||
if (keys.isEmpty()) return
|
||||
securePrefs.edit {
|
||||
for (key in keys) {
|
||||
remove(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSecurePrefs(
|
||||
context: Context,
|
||||
name: String,
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package ai.openclaw.app.gateway
|
||||
|
||||
import ai.openclaw.app.SecurePrefs
|
||||
import android.content.Context
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@@ -20,10 +18,6 @@ private data class PersistedDeviceAuthMetadata(
|
||||
val updatedAtMs: Long = 0L,
|
||||
)
|
||||
|
||||
private const val deviceAuthTokenPrefix = "gateway.deviceToken."
|
||||
private const val deviceAuthMetadataPrefix = "gateway.deviceTokenMeta."
|
||||
private const val sqliteSecurePrefsTokenMarker = "__openclaw_secure_prefs__"
|
||||
|
||||
interface DeviceAuthTokenStore {
|
||||
fun loadEntry(
|
||||
deviceId: String,
|
||||
@@ -48,104 +42,29 @@ interface DeviceAuthTokenStore {
|
||||
)
|
||||
}
|
||||
|
||||
internal interface DeviceAuthStateStore {
|
||||
fun readDeviceAuthToken(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
): OpenClawSQLiteDeviceAuthTokenRow?
|
||||
|
||||
fun readLatestDeviceAuthDeviceId(): String?
|
||||
|
||||
fun upsertDeviceAuthToken(row: OpenClawSQLiteDeviceAuthTokenRow)
|
||||
|
||||
fun deleteDeviceAuthToken(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
)
|
||||
|
||||
fun deleteAllDeviceAuthTokens()
|
||||
}
|
||||
|
||||
private class OpenClawSQLiteDeviceAuthStateStore(
|
||||
private val store: OpenClawSQLiteStateStore,
|
||||
) : DeviceAuthStateStore {
|
||||
override fun readDeviceAuthToken(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
): OpenClawSQLiteDeviceAuthTokenRow? = store.readDeviceAuthToken(deviceId, role)
|
||||
|
||||
override fun readLatestDeviceAuthDeviceId(): String? = store.readLatestDeviceAuthDeviceId()
|
||||
|
||||
override fun upsertDeviceAuthToken(row: OpenClawSQLiteDeviceAuthTokenRow) {
|
||||
store.upsertDeviceAuthToken(row)
|
||||
}
|
||||
|
||||
override fun deleteDeviceAuthToken(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
) {
|
||||
store.deleteDeviceAuthToken(deviceId, role)
|
||||
}
|
||||
|
||||
override fun deleteAllDeviceAuthTokens() {
|
||||
store.deleteAllDeviceAuthTokens()
|
||||
}
|
||||
}
|
||||
|
||||
class DeviceAuthStore private constructor(
|
||||
private val context: Context,
|
||||
private val legacyPrefsOverride: SecurePrefs? = null,
|
||||
private val stateStore: DeviceAuthStateStore,
|
||||
class DeviceAuthStore(
|
||||
private val prefs: SecurePrefs,
|
||||
) : DeviceAuthTokenStore {
|
||||
constructor(
|
||||
context: Context,
|
||||
legacyPrefsOverride: SecurePrefs? = null,
|
||||
) : this(
|
||||
context = context,
|
||||
legacyPrefsOverride = legacyPrefsOverride,
|
||||
stateStore = OpenClawSQLiteDeviceAuthStateStore(OpenClawSQLiteStateStore(context)),
|
||||
)
|
||||
|
||||
internal companion object {
|
||||
fun createForTesting(
|
||||
context: Context,
|
||||
legacyPrefsOverride: SecurePrefs? = null,
|
||||
stateStoreOverride: DeviceAuthStateStore,
|
||||
): DeviceAuthStore =
|
||||
DeviceAuthStore(
|
||||
context = context,
|
||||
legacyPrefsOverride = legacyPrefsOverride,
|
||||
stateStore = stateStoreOverride,
|
||||
)
|
||||
}
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val legacyPrefs by lazy { legacyPrefsOverride ?: SecurePrefs(context) }
|
||||
|
||||
override fun loadEntry(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
): DeviceAuthEntry? {
|
||||
val normalizedDevice = normalizeDeviceId(deviceId)
|
||||
val key = tokenKey(deviceId, role)
|
||||
val token = prefs.getString(key)?.trim()?.takeIf { it.isNotEmpty() } ?: return null
|
||||
val normalizedRole = normalizeRole(role)
|
||||
val row =
|
||||
stateStore.readDeviceAuthToken(normalizedDevice, normalizedRole)
|
||||
?: return migrateLegacyEntryIfNoSqliteAuthRows(normalizedDevice, normalizedRole)
|
||||
val token =
|
||||
legacyPrefs
|
||||
.getString(tokenKey(normalizedDevice, normalizedRole))
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: row.token.trim().takeIf { it.isNotEmpty() && it != sqliteSecurePrefsTokenMarker }?.also {
|
||||
legacyPrefs.putString(tokenKey(normalizedDevice, normalizedRole), it)
|
||||
stateStore.upsertDeviceAuthToken(row.copy(token = sqliteSecurePrefsTokenMarker))
|
||||
val metadata =
|
||||
prefs
|
||||
.getString(metadataKey(deviceId, role))
|
||||
?.let { raw ->
|
||||
runCatching { json.decodeFromString<PersistedDeviceAuthMetadata>(raw) }.getOrNull()
|
||||
}
|
||||
?: return null
|
||||
return DeviceAuthEntry(
|
||||
token = token,
|
||||
role = normalizedRole,
|
||||
scopes = decodeScopes(row.scopesJson),
|
||||
updatedAtMs = row.updatedAtMs,
|
||||
scopes = metadata?.scopes ?: emptyList(),
|
||||
updatedAtMs = metadata?.updatedAtMs ?: 0L,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -155,35 +74,16 @@ class DeviceAuthStore private constructor(
|
||||
token: String,
|
||||
scopes: List<String>,
|
||||
) {
|
||||
val normalizedDevice = normalizeDeviceId(deviceId)
|
||||
val normalizedRole = normalizeRole(role)
|
||||
val normalizedScopes = normalizeScopes(scopes)
|
||||
val latestDeviceId = stateStore.readLatestDeviceAuthDeviceId()
|
||||
val shouldSeedSameDeviceLegacyRoles = latestDeviceId == null
|
||||
val sqliteDeviceChanged = latestDeviceId != null && latestDeviceId != normalizedDevice
|
||||
val shouldDropLegacyAuth =
|
||||
sqliteDeviceChanged ||
|
||||
legacyPrefs.keysWithPrefix(deviceAuthTokenPrefix).any {
|
||||
!it.startsWith(tokenKeyPrefix(normalizedDevice))
|
||||
}
|
||||
if (sqliteDeviceChanged) {
|
||||
stateStore.deleteAllDeviceAuthTokens()
|
||||
}
|
||||
if (shouldDropLegacyAuth) {
|
||||
removeForeignLegacyEntries(normalizedDevice)
|
||||
}
|
||||
if (shouldSeedSameDeviceLegacyRoles) {
|
||||
migrateLegacyEntriesForDevice(normalizedDevice)
|
||||
}
|
||||
legacyPrefs.putString(tokenKey(normalizedDevice, normalizedRole), token.trim())
|
||||
removeLegacyMetadata(normalizedDevice, normalizedRole)
|
||||
stateStore.upsertDeviceAuthToken(
|
||||
OpenClawSQLiteDeviceAuthTokenRow(
|
||||
deviceId = normalizedDevice,
|
||||
role = normalizedRole,
|
||||
token = sqliteSecurePrefsTokenMarker,
|
||||
scopesJson = json.encodeToString(normalizedScopes),
|
||||
updatedAtMs = System.currentTimeMillis(),
|
||||
val key = tokenKey(deviceId, role)
|
||||
prefs.putString(key, token.trim())
|
||||
prefs.putString(
|
||||
metadataKey(deviceId, role),
|
||||
json.encodeToString(
|
||||
PersistedDeviceAuthMetadata(
|
||||
scopes = normalizedScopes,
|
||||
updatedAtMs = System.currentTimeMillis(),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -192,124 +92,28 @@ class DeviceAuthStore private constructor(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
) {
|
||||
val normalizedDevice = normalizeDeviceId(deviceId)
|
||||
val normalizedRole = normalizeRole(role)
|
||||
removeLegacyEntry(normalizedDevice, normalizedRole)
|
||||
stateStore.deleteDeviceAuthToken(
|
||||
deviceId = normalizedDevice,
|
||||
role = normalizedRole,
|
||||
)
|
||||
val key = tokenKey(deviceId, role)
|
||||
prefs.remove(key)
|
||||
prefs.remove(metadataKey(deviceId, role))
|
||||
}
|
||||
|
||||
private fun migrateLegacyEntryIfNoSqliteAuthRows(
|
||||
normalizedDevice: String,
|
||||
normalizedRole: String,
|
||||
): DeviceAuthEntry? {
|
||||
if (stateStore.readLatestDeviceAuthDeviceId() != null) {
|
||||
removeLegacyEntry(normalizedDevice, normalizedRole)
|
||||
return null
|
||||
}
|
||||
return migrateLegacyEntriesForDevice(normalizedDevice)[normalizedRole]
|
||||
}
|
||||
|
||||
private fun migrateLegacyEntriesForDevice(normalizedDevice: String): Map<String, DeviceAuthEntry> {
|
||||
val prefix = tokenKeyPrefix(normalizedDevice)
|
||||
return legacyPrefs
|
||||
.keysWithPrefix(prefix)
|
||||
.mapNotNull { key ->
|
||||
val role = normalizeRole(key.removePrefix(prefix))
|
||||
if (role.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
migrateLegacyEntry(normalizedDevice, role)?.let { role to it }
|
||||
}
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
private fun migrateLegacyEntry(
|
||||
normalizedDevice: String,
|
||||
normalizedRole: String,
|
||||
): DeviceAuthEntry? {
|
||||
val token =
|
||||
legacyPrefs
|
||||
.getString(tokenKey(normalizedDevice, normalizedRole))
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: return null
|
||||
val metadata =
|
||||
legacyPrefs
|
||||
.getString(metadataKey(normalizedDevice, normalizedRole))
|
||||
?.let { raw -> runCatching { json.decodeFromString<PersistedDeviceAuthMetadata>(raw) }.getOrNull() }
|
||||
val entry =
|
||||
DeviceAuthEntry(
|
||||
token = token,
|
||||
role = normalizedRole,
|
||||
scopes = normalizeScopes(metadata?.scopes ?: emptyList()),
|
||||
updatedAtMs = metadata?.updatedAtMs?.takeIf { it > 0L } ?: System.currentTimeMillis(),
|
||||
)
|
||||
val migrated =
|
||||
runCatching {
|
||||
stateStore.upsertDeviceAuthToken(
|
||||
OpenClawSQLiteDeviceAuthTokenRow(
|
||||
deviceId = normalizedDevice,
|
||||
role = normalizedRole,
|
||||
token = sqliteSecurePrefsTokenMarker,
|
||||
scopesJson = json.encodeToString(entry.scopes),
|
||||
updatedAtMs = entry.updatedAtMs,
|
||||
),
|
||||
)
|
||||
}.isSuccess
|
||||
if (migrated) {
|
||||
legacyPrefs.putString(tokenKey(normalizedDevice, normalizedRole), entry.token)
|
||||
removeLegacyMetadata(normalizedDevice, normalizedRole)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
private fun removeLegacyMetadata(
|
||||
normalizedDevice: String,
|
||||
normalizedRole: String,
|
||||
) {
|
||||
legacyPrefs.remove(metadataKey(normalizedDevice, normalizedRole))
|
||||
}
|
||||
|
||||
private fun removeLegacyEntry(
|
||||
normalizedDevice: String,
|
||||
normalizedRole: String,
|
||||
) {
|
||||
legacyPrefs.remove(tokenKey(normalizedDevice, normalizedRole))
|
||||
legacyPrefs.remove(metadataKey(normalizedDevice, normalizedRole))
|
||||
}
|
||||
|
||||
private fun removeForeignLegacyEntries(normalizedDevice: String) {
|
||||
val currentTokenPrefix = tokenKeyPrefix(normalizedDevice)
|
||||
legacyPrefs
|
||||
.keysWithPrefix(deviceAuthTokenPrefix)
|
||||
.filterNot { it.startsWith(currentTokenPrefix) }
|
||||
.forEach { legacyPrefs.remove(it) }
|
||||
val currentMetadataPrefix = "$deviceAuthMetadataPrefix$normalizedDevice."
|
||||
legacyPrefs
|
||||
.keysWithPrefix(deviceAuthMetadataPrefix)
|
||||
.filterNot { it.startsWith(currentMetadataPrefix) }
|
||||
.forEach { legacyPrefs.remove(it) }
|
||||
}
|
||||
|
||||
private fun tokenKeyPrefix(normalizedDevice: String): String = "$deviceAuthTokenPrefix$normalizedDevice."
|
||||
|
||||
private fun tokenKey(
|
||||
normalizedDevice: String,
|
||||
normalizedRole: String,
|
||||
): String = "${tokenKeyPrefix(normalizedDevice)}$normalizedRole"
|
||||
deviceId: String,
|
||||
role: String,
|
||||
): String {
|
||||
val normalizedDevice = normalizeDeviceId(deviceId)
|
||||
val normalizedRole = normalizeRole(role)
|
||||
return "gateway.deviceToken.$normalizedDevice.$normalizedRole"
|
||||
}
|
||||
|
||||
private fun metadataKey(
|
||||
normalizedDevice: String,
|
||||
normalizedRole: String,
|
||||
): String = "$deviceAuthMetadataPrefix$normalizedDevice.$normalizedRole"
|
||||
|
||||
private fun decodeScopes(raw: String): List<String> =
|
||||
runCatching { json.decodeFromString<List<String>>(raw) }
|
||||
.getOrDefault(emptyList())
|
||||
.let(::normalizeScopes)
|
||||
deviceId: String,
|
||||
role: String,
|
||||
): String {
|
||||
val normalizedDevice = normalizeDeviceId(deviceId)
|
||||
val normalizedRole = normalizeRole(role)
|
||||
return "gateway.deviceTokenMeta.$normalizedDevice.$normalizedRole"
|
||||
}
|
||||
|
||||
private fun normalizeDeviceId(deviceId: String): String = deviceId.trim().lowercase()
|
||||
|
||||
|
||||
@@ -19,8 +19,7 @@ class DeviceIdentityStore(
|
||||
context: Context,
|
||||
) {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val stateStore = OpenClawSQLiteStateStore(context)
|
||||
private val legacyIdentityFile = File(context.filesDir, "openclaw/identity/device.json")
|
||||
private val identityFile = File(context.filesDir, "openclaw/identity/device.json")
|
||||
|
||||
@Volatile private var cachedIdentity: DeviceIdentity? = null
|
||||
|
||||
@@ -29,14 +28,16 @@ class DeviceIdentityStore(
|
||||
cachedIdentity?.let { return it }
|
||||
val existing = load()
|
||||
if (existing != null) {
|
||||
val derived = deriveDeviceId(existing.publicKeyRawBase64)
|
||||
if (derived != null && derived != existing.deviceId) {
|
||||
val updated = existing.copy(deviceId = derived)
|
||||
save(updated)
|
||||
cachedIdentity = updated
|
||||
return updated
|
||||
}
|
||||
cachedIdentity = existing
|
||||
return existing
|
||||
}
|
||||
if (legacyIdentityFile.exists()) {
|
||||
val migrated = migrateLegacyIdentity()
|
||||
cachedIdentity = migrated
|
||||
return migrated
|
||||
}
|
||||
val fresh = generate()
|
||||
save(fresh)
|
||||
cachedIdentity = fresh
|
||||
@@ -110,76 +111,34 @@ class DeviceIdentityStore(
|
||||
null
|
||||
}
|
||||
|
||||
private fun load(): DeviceIdentity? {
|
||||
val row = stateStore.readDeviceIdentity(IDENTITY_KEY) ?: return null
|
||||
return readIdentity(row)
|
||||
?: throw IllegalStateException(
|
||||
"Stored OpenClaw device identity is invalid. Run openclaw doctor --fix.",
|
||||
)
|
||||
}
|
||||
private fun load(): DeviceIdentity? = readIdentity(identityFile)
|
||||
|
||||
private fun migrateLegacyIdentity(): DeviceIdentity {
|
||||
val raw =
|
||||
try {
|
||||
legacyIdentityFile.readText(Charsets.UTF_8)
|
||||
} catch (error: Throwable) {
|
||||
throw IllegalStateException("Failed to read legacy OpenClaw device identity.", error)
|
||||
private fun readIdentity(file: File): DeviceIdentity? {
|
||||
return try {
|
||||
if (!file.exists()) return null
|
||||
val raw = file.readText(Charsets.UTF_8)
|
||||
val decoded = json.decodeFromString(DeviceIdentity.serializer(), raw)
|
||||
if (decoded.deviceId.isBlank() ||
|
||||
decoded.publicKeyRawBase64.isBlank() ||
|
||||
decoded.privateKeyPkcs8Base64.isBlank()
|
||||
) {
|
||||
null
|
||||
} else {
|
||||
decoded
|
||||
}
|
||||
val identity =
|
||||
runCatching { json.decodeFromString(DeviceIdentity.serializer(), raw) }
|
||||
.getOrNull()
|
||||
?.let(::normalizeRawIdentity)
|
||||
?: throw IllegalStateException(
|
||||
"Legacy OpenClaw device identity is invalid. Run openclaw doctor --fix.",
|
||||
)
|
||||
save(identity)
|
||||
legacyIdentityFile.delete()
|
||||
return identity
|
||||
}
|
||||
|
||||
private fun normalizeRawIdentity(identity: DeviceIdentity): DeviceIdentity? =
|
||||
try {
|
||||
if (identity.publicKeyRawBase64.isBlank() || identity.privateKeyPkcs8Base64.isBlank()) {
|
||||
return null
|
||||
}
|
||||
val publicRaw = Base64.decode(identity.publicKeyRawBase64, Base64.DEFAULT)
|
||||
val privateDer = Base64.decode(identity.privateKeyPkcs8Base64, Base64.DEFAULT)
|
||||
if (publicRaw.size != ED25519_KEY_SIZE || privateDer.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
val normalized = identity.copy(deviceId = sha256Hex(publicRaw))
|
||||
if (!hasMatchingKeyPair(normalized)) {
|
||||
return null
|
||||
}
|
||||
normalized
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun readIdentity(row: OpenClawSQLiteDeviceIdentityRow): DeviceIdentity? =
|
||||
PersistedDeviceIdentity(
|
||||
deviceId = row.deviceId,
|
||||
publicKeyPem = row.publicKeyPem,
|
||||
privateKeyPem = row.privateKeyPem,
|
||||
createdAtMs = row.createdAtMs,
|
||||
).toRuntimeIdentity()?.takeIf(::hasMatchingKeyPair)
|
||||
|
||||
private fun hasMatchingKeyPair(identity: DeviceIdentity): Boolean {
|
||||
val signature = signPayload(KEYPAIR_VALIDATION_PAYLOAD, identity) ?: return false
|
||||
return verifySelfSignature(KEYPAIR_VALIDATION_PAYLOAD, signature, identity)
|
||||
}
|
||||
|
||||
private fun save(identity: DeviceIdentity) {
|
||||
val persisted = PersistedDeviceIdentity.fromRuntimeIdentity(identity)
|
||||
stateStore.writeDeviceIdentity(
|
||||
OpenClawSQLiteDeviceIdentityRow(
|
||||
deviceId = persisted.deviceId,
|
||||
publicKeyPem = persisted.publicKeyPem,
|
||||
privateKeyPem = persisted.privateKeyPem,
|
||||
createdAtMs = persisted.createdAtMs,
|
||||
),
|
||||
identityKey = IDENTITY_KEY,
|
||||
)
|
||||
try {
|
||||
identityFile.parentFile?.mkdirs()
|
||||
val encoded = json.encodeToString(DeviceIdentity.serializer(), identity)
|
||||
identityFile.writeText(encoded, Charsets.UTF_8)
|
||||
} catch (_: Throwable) {
|
||||
// best-effort only
|
||||
}
|
||||
}
|
||||
|
||||
private fun generate(): DeviceIdentity {
|
||||
@@ -209,6 +168,14 @@ class DeviceIdentityStore(
|
||||
)
|
||||
}
|
||||
|
||||
private fun deriveDeviceId(publicKeyRawBase64: String): String? =
|
||||
try {
|
||||
val raw = Base64.decode(publicKeyRawBase64, Base64.DEFAULT)
|
||||
sha256Hex(raw)
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun sha256Hex(data: ByteArray): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(data)
|
||||
val out = CharArray(digest.size * 2)
|
||||
@@ -227,92 +194,7 @@ class DeviceIdentityStore(
|
||||
Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class PersistedDeviceIdentity(
|
||||
val version: Int = 1,
|
||||
val deviceId: String,
|
||||
val publicKeyPem: String,
|
||||
val privateKeyPem: String,
|
||||
val createdAtMs: Long,
|
||||
) {
|
||||
fun toRuntimeIdentity(): DeviceIdentity? {
|
||||
if (version != 1 || deviceId.isBlank() || publicKeyPem.isBlank() || privateKeyPem.isBlank()) {
|
||||
return null
|
||||
}
|
||||
val publicDer = decodePem(publicKeyPem, "PUBLIC KEY") ?: return null
|
||||
if (!publicDer.startsWith(PUBLIC_KEY_INFO_PREFIX)) return null
|
||||
val publicRaw = publicDer.copyOfRange(PUBLIC_KEY_INFO_PREFIX.size, publicDer.size)
|
||||
if (publicRaw.size != ED25519_KEY_SIZE) return null
|
||||
val derivedDeviceId = sha256HexStatic(publicRaw)
|
||||
if (derivedDeviceId != deviceId.lowercase()) return null
|
||||
val privateDer = decodePem(privateKeyPem, "PRIVATE KEY") ?: return null
|
||||
return DeviceIdentity(
|
||||
deviceId = derivedDeviceId,
|
||||
publicKeyRawBase64 = Base64.encodeToString(publicRaw, Base64.NO_WRAP),
|
||||
privateKeyPkcs8Base64 = Base64.encodeToString(privateDer, Base64.NO_WRAP),
|
||||
createdAtMs = createdAtMs,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromRuntimeIdentity(identity: DeviceIdentity): PersistedDeviceIdentity {
|
||||
val publicRaw = Base64.decode(identity.publicKeyRawBase64, Base64.DEFAULT)
|
||||
val privateDer = Base64.decode(identity.privateKeyPkcs8Base64, Base64.DEFAULT)
|
||||
return PersistedDeviceIdentity(
|
||||
deviceId = identity.deviceId,
|
||||
publicKeyPem = encodePem("PUBLIC KEY", PUBLIC_KEY_INFO_PREFIX + publicRaw),
|
||||
privateKeyPem = encodePem("PRIVATE KEY", privateDer),
|
||||
createdAtMs = identity.createdAtMs,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val IDENTITY_KEY = "default"
|
||||
private const val KEYPAIR_VALIDATION_PAYLOAD = "openclaw-device-identity-keypair-validation"
|
||||
private const val ED25519_KEY_SIZE = 32
|
||||
private val HEX = "0123456789abcdef".toCharArray()
|
||||
private val PUBLIC_KEY_INFO_PREFIX =
|
||||
byteArrayOf(0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00)
|
||||
|
||||
private fun ByteArray.startsWith(prefix: ByteArray): Boolean = size >= prefix.size && prefix.indices.all { this[it] == prefix[it] }
|
||||
|
||||
private fun encodePem(
|
||||
label: String,
|
||||
bytes: ByteArray,
|
||||
): String {
|
||||
val body = Base64.encodeToString(bytes, Base64.NO_WRAP)
|
||||
val wrapped = body.chunked(64).joinToString("\n")
|
||||
return "-----BEGIN $label-----\n$wrapped\n-----END $label-----\n"
|
||||
}
|
||||
|
||||
private fun decodePem(
|
||||
pem: String,
|
||||
label: String,
|
||||
): ByteArray? {
|
||||
val header = "-----BEGIN $label-----"
|
||||
val footer = "-----END $label-----"
|
||||
val trimmed = pem.trim()
|
||||
if (!trimmed.startsWith(header) || !trimmed.endsWith(footer)) return null
|
||||
val body =
|
||||
trimmed
|
||||
.removePrefix(header)
|
||||
.removeSuffix(footer)
|
||||
.replace("\\s".toRegex(), "")
|
||||
return runCatching { Base64.decode(body, Base64.DEFAULT) }.getOrNull()
|
||||
}
|
||||
|
||||
private fun sha256HexStatic(data: ByteArray): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(data)
|
||||
val out = CharArray(digest.size * 2)
|
||||
var i = 0
|
||||
for (byte in digest) {
|
||||
val v = byte.toInt() and 0xff
|
||||
out[i++] = HEX[v ushr 4]
|
||||
out[i++] = HEX[v and 0x0f]
|
||||
}
|
||||
return String(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,310 +0,0 @@
|
||||
package ai.openclaw.app.gateway
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import java.io.File
|
||||
|
||||
data class OpenClawSQLiteDeviceIdentityRow(
|
||||
val deviceId: String,
|
||||
val publicKeyPem: String,
|
||||
val privateKeyPem: String,
|
||||
val createdAtMs: Long,
|
||||
)
|
||||
|
||||
data class OpenClawSQLiteDeviceAuthTokenRow(
|
||||
val deviceId: String,
|
||||
val role: String,
|
||||
val token: String,
|
||||
val scopesJson: String,
|
||||
val updatedAtMs: Long,
|
||||
)
|
||||
|
||||
class OpenClawSQLiteStateStore(
|
||||
context: Context,
|
||||
) {
|
||||
private val appContext = context.applicationContext
|
||||
private val databaseFile = File(appContext.filesDir, "openclaw/state/openclaw.sqlite")
|
||||
|
||||
fun databaseFile(): File = databaseFile
|
||||
|
||||
@Synchronized
|
||||
fun readDeviceIdentity(identityKey: String = "default"): OpenClawSQLiteDeviceIdentityRow? {
|
||||
if (!databaseFile.exists()) return null
|
||||
return openDatabase().use { db ->
|
||||
db
|
||||
.rawQuery(
|
||||
"""
|
||||
SELECT device_id, public_key_pem, private_key_pem, created_at_ms
|
||||
FROM device_identities
|
||||
WHERE identity_key = ?
|
||||
""".trimIndent(),
|
||||
arrayOf(identityKey),
|
||||
).use { cursor ->
|
||||
if (!cursor.moveToFirst()) return@use null
|
||||
OpenClawSQLiteDeviceIdentityRow(
|
||||
deviceId = cursor.getString(0),
|
||||
publicKeyPem = cursor.getString(1),
|
||||
privateKeyPem = cursor.getString(2),
|
||||
createdAtMs = cursor.getLong(3),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun writeDeviceIdentity(
|
||||
identity: OpenClawSQLiteDeviceIdentityRow,
|
||||
identityKey: String = "default",
|
||||
updatedAtMs: Long = System.currentTimeMillis(),
|
||||
) {
|
||||
openDatabase().use { db ->
|
||||
db.inWriteTransaction {
|
||||
val values =
|
||||
ContentValues().apply {
|
||||
put("identity_key", identityKey)
|
||||
put("device_id", identity.deviceId)
|
||||
put("public_key_pem", identity.publicKeyPem)
|
||||
put("private_key_pem", identity.privateKeyPem)
|
||||
put("created_at_ms", identity.createdAtMs)
|
||||
put("updated_at_ms", updatedAtMs)
|
||||
}
|
||||
db.insertWithOnConflict("device_identities", null, values, SQLiteDatabase.CONFLICT_REPLACE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun readDeviceAuthToken(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
): OpenClawSQLiteDeviceAuthTokenRow? {
|
||||
if (!databaseFile.exists()) return null
|
||||
return openDatabase().use { db ->
|
||||
db
|
||||
.rawQuery(
|
||||
"""
|
||||
SELECT device_id, role, token, scopes_json, updated_at_ms
|
||||
FROM device_auth_tokens
|
||||
WHERE device_id = ? AND role = ?
|
||||
""".trimIndent(),
|
||||
arrayOf(deviceId, role),
|
||||
).use { cursor ->
|
||||
if (!cursor.moveToFirst()) return@use null
|
||||
OpenClawSQLiteDeviceAuthTokenRow(
|
||||
deviceId = cursor.getString(0),
|
||||
role = cursor.getString(1),
|
||||
token = cursor.getString(2),
|
||||
scopesJson = cursor.getString(3),
|
||||
updatedAtMs = cursor.getLong(4),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun readLatestDeviceAuthDeviceId(): String? {
|
||||
if (!databaseFile.exists()) return null
|
||||
return openDatabase().use { db ->
|
||||
db
|
||||
.rawQuery(
|
||||
"""
|
||||
SELECT device_id
|
||||
FROM device_auth_tokens
|
||||
ORDER BY updated_at_ms DESC, device_id ASC
|
||||
LIMIT 1
|
||||
""".trimIndent(),
|
||||
emptyArray(),
|
||||
).use { cursor ->
|
||||
if (cursor.moveToFirst()) cursor.getString(0) else null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun upsertDeviceAuthToken(row: OpenClawSQLiteDeviceAuthTokenRow) {
|
||||
openDatabase().use { db ->
|
||||
db.inWriteTransaction {
|
||||
val values =
|
||||
ContentValues().apply {
|
||||
put("device_id", row.deviceId)
|
||||
put("role", row.role)
|
||||
put("token", row.token)
|
||||
put("scopes_json", row.scopesJson)
|
||||
put("updated_at_ms", row.updatedAtMs)
|
||||
}
|
||||
db.insertWithOnConflict("device_auth_tokens", null, values, SQLiteDatabase.CONFLICT_REPLACE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun deleteDeviceAuthToken(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
) {
|
||||
openDatabase().use { db ->
|
||||
db.inWriteTransaction {
|
||||
db.delete("device_auth_tokens", "device_id = ? AND role = ?", arrayOf(deviceId, role))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun deleteAllDeviceAuthTokens() {
|
||||
openDatabase().use { db ->
|
||||
db.inWriteTransaction {
|
||||
db.delete("device_auth_tokens", null, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun readRecentNotificationPackages(limit: Int = 64): List<String> {
|
||||
if (!databaseFile.exists()) return emptyList()
|
||||
return openDatabase().use { db ->
|
||||
db
|
||||
.rawQuery(
|
||||
"""
|
||||
SELECT package_name
|
||||
FROM android_notification_recent_packages
|
||||
ORDER BY sort_order ASC, package_name ASC
|
||||
LIMIT ?
|
||||
""".trimIndent(),
|
||||
arrayOf(limit.coerceAtLeast(0).toString()),
|
||||
).use { cursor ->
|
||||
val packages = mutableListOf<String>()
|
||||
while (cursor.moveToNext()) {
|
||||
packages += cursor.getString(0)
|
||||
}
|
||||
packages
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun replaceRecentNotificationPackages(
|
||||
packageNames: List<String>,
|
||||
limit: Int = 64,
|
||||
updatedAtMs: Long = System.currentTimeMillis(),
|
||||
) {
|
||||
val normalized =
|
||||
packageNames
|
||||
.asSequence()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.distinct()
|
||||
.take(limit.coerceAtLeast(0))
|
||||
.toList()
|
||||
openDatabase().use { db ->
|
||||
db.inWriteTransaction {
|
||||
db.delete("android_notification_recent_packages", null, null)
|
||||
normalized.forEachIndexed { index, packageName ->
|
||||
val values =
|
||||
ContentValues().apply {
|
||||
put("package_name", packageName)
|
||||
put("sort_order", index)
|
||||
put("updated_at_ms", updatedAtMs)
|
||||
}
|
||||
db.insertWithOnConflict(
|
||||
"android_notification_recent_packages",
|
||||
null,
|
||||
values,
|
||||
SQLiteDatabase.CONFLICT_REPLACE,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openDatabase(): SQLiteDatabase {
|
||||
databaseFile.parentFile?.mkdirs()
|
||||
val db =
|
||||
SQLiteDatabase.openDatabase(
|
||||
databaseFile.absolutePath,
|
||||
null,
|
||||
SQLiteDatabase.OPEN_READWRITE or SQLiteDatabase.CREATE_IF_NECESSARY,
|
||||
)
|
||||
configure(db)
|
||||
return db
|
||||
}
|
||||
|
||||
private fun configure(db: SQLiteDatabase) {
|
||||
db.enableWriteAheadLogging()
|
||||
executePragma(db, "PRAGMA synchronous = NORMAL")
|
||||
executePragma(db, "PRAGMA busy_timeout = 30000")
|
||||
executePragma(db, "PRAGMA foreign_keys = ON")
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS device_identities (
|
||||
identity_key TEXT NOT NULL PRIMARY KEY,
|
||||
device_id TEXT NOT NULL,
|
||||
public_key_pem TEXT NOT NULL,
|
||||
private_key_pem TEXT NOT NULL,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
)
|
||||
""".trimIndent(),
|
||||
)
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_device_identities_device
|
||||
ON device_identities(device_id, updated_at_ms DESC)
|
||||
""".trimIndent(),
|
||||
)
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS device_auth_tokens (
|
||||
device_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
token TEXT NOT NULL,
|
||||
scopes_json TEXT NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
PRIMARY KEY (device_id, role)
|
||||
)
|
||||
""".trimIndent(),
|
||||
)
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_device_auth_tokens_updated
|
||||
ON device_auth_tokens(updated_at_ms DESC, device_id, role)
|
||||
""".trimIndent(),
|
||||
)
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS android_notification_recent_packages (
|
||||
package_name TEXT NOT NULL PRIMARY KEY,
|
||||
sort_order INTEGER NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
)
|
||||
""".trimIndent(),
|
||||
)
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_android_notification_recent_packages_order
|
||||
ON android_notification_recent_packages(sort_order, package_name)
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun executePragma(
|
||||
db: SQLiteDatabase,
|
||||
sql: String,
|
||||
) {
|
||||
db.rawQuery(sql, null).use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
// Some PRAGMA assignments return their new value; reading it closes the cursor cleanly.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun SQLiteDatabase.inWriteTransaction(body: () -> Unit) {
|
||||
beginTransaction()
|
||||
try {
|
||||
body()
|
||||
setTransactionSuccessful()
|
||||
} finally {
|
||||
endTransaction()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package ai.openclaw.app.node
|
||||
import ai.openclaw.app.BuildConfig
|
||||
import ai.openclaw.app.CameraHudKind
|
||||
import ai.openclaw.app.gateway.GatewaySession
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -18,6 +19,7 @@ internal const val CAMERA_CLIP_MAX_RAW_BYTES: Long = 18L * 1024L * 1024L
|
||||
internal fun isCameraClipWithinPayloadLimit(rawBytes: Long): Boolean = rawBytes in 0L..CAMERA_CLIP_MAX_RAW_BYTES
|
||||
|
||||
class CameraHandler(
|
||||
private val appContext: Context,
|
||||
private val camera: CameraCaptureManager,
|
||||
private val externalAudioCaptureActive: MutableStateFlow<Boolean>,
|
||||
private val showCameraHud: (message: String, kind: CameraHudKind, autoHideMs: Long?) -> Unit,
|
||||
@@ -52,12 +54,16 @@ class CameraHandler(
|
||||
}
|
||||
|
||||
suspend fun handleSnap(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
val logFile = if (BuildConfig.DEBUG) java.io.File(appContext.cacheDir, "camera_debug.log") else null
|
||||
|
||||
fun camLog(msg: String) {
|
||||
if (!BuildConfig.DEBUG) return
|
||||
val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.US).format(java.util.Date())
|
||||
android.util.Log.w("openclaw", "camera.snap[$ts]: $msg")
|
||||
logFile?.appendText("[$ts] $msg\n")
|
||||
android.util.Log.w("openclaw", "camera.snap: $msg")
|
||||
}
|
||||
try {
|
||||
logFile?.writeText("") // clear
|
||||
camLog("starting, params=$paramsJson")
|
||||
camLog("calling showCameraHud")
|
||||
showCameraHud("Taking photo…", CameraHudKind.Photo, null)
|
||||
@@ -87,14 +93,18 @@ class CameraHandler(
|
||||
}
|
||||
|
||||
suspend fun handleClip(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
val clipLogFile = if (BuildConfig.DEBUG) java.io.File(appContext.cacheDir, "camera_debug.log") else null
|
||||
|
||||
fun clipLog(msg: String) {
|
||||
if (!BuildConfig.DEBUG) return
|
||||
val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.US).format(java.util.Date())
|
||||
android.util.Log.w("openclaw", "camera.clip[$ts]: $msg")
|
||||
clipLogFile?.appendText("[CLIP $ts] $msg\n")
|
||||
android.util.Log.w("openclaw", "camera.clip: $msg")
|
||||
}
|
||||
val includeAudio = parseIncludeAudio(paramsJson) ?: true
|
||||
if (includeAudio) externalAudioCaptureActive.value = true
|
||||
try {
|
||||
clipLogFile?.writeText("") // clear
|
||||
clipLog("starting, params=$paramsJson includeAudio=$includeAudio")
|
||||
clipLog("calling showCameraHud")
|
||||
showCameraHud("Recording…", CameraHudKind.Recording, null)
|
||||
|
||||
@@ -3,16 +3,13 @@ package ai.openclaw.app.node
|
||||
import ai.openclaw.app.BuildConfig
|
||||
import ai.openclaw.app.gateway.DeviceIdentityStore
|
||||
import ai.openclaw.app.gateway.GatewaySession
|
||||
import android.content.Context
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import java.io.InputStream
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
private const val LOGCAT_PATH = "/system/bin/logcat"
|
||||
private const val LOGCAT_TIMEOUT_MS = 4_000L
|
||||
private const val LOGCAT_MAX_CHARS = 128_000
|
||||
|
||||
class DebugHandler(
|
||||
private val appContext: Context,
|
||||
private val identityStore: DeviceIdentityStore,
|
||||
) {
|
||||
fun handleEd25519(): GatewaySession.InvokeResult {
|
||||
@@ -84,14 +81,24 @@ class DebugHandler(
|
||||
val pid = android.os.Process.myPid()
|
||||
val rt = Runtime.getRuntime()
|
||||
val info = "v6 pid=$pid thread=${Thread.currentThread().name} free=${rt.freeMemory() / 1024}K total=${rt.totalMemory() / 1024}K max=${rt.maxMemory() / 1024}K uptime=${android.os.SystemClock.elapsedRealtime() / 1000}s sdk=${android.os.Build.VERSION.SDK_INT} device=${android.os.Build.MODEL}\n"
|
||||
// Run logcat on current dispatcher thread; output is bounded by -t and never staged to disk.
|
||||
// Run logcat on current dispatcher thread (no withContext) with file redirect
|
||||
val logResult =
|
||||
try {
|
||||
val tmpFile = java.io.File(appContext.cacheDir, "debug_logs.txt")
|
||||
if (tmpFile.exists()) tmpFile.delete()
|
||||
val pb = ProcessBuilder(LOGCAT_PATH, "-d", "-t", "200", "--pid=$pid")
|
||||
pb.redirectOutput(tmpFile)
|
||||
pb.redirectErrorStream(true)
|
||||
val proc = pb.start()
|
||||
val (finished, raw) = collectProcessOutput(proc, LOGCAT_TIMEOUT_MS, LOGCAT_MAX_CHARS)
|
||||
val normalizedRaw = raw.ifBlank { "(no output, finished=$finished)" }
|
||||
val finished = proc.waitFor(4, java.util.concurrent.TimeUnit.SECONDS)
|
||||
if (!finished) proc.destroyForcibly()
|
||||
val raw =
|
||||
if (tmpFile.exists() && tmpFile.length() > 0) {
|
||||
tmpFile.readText().take(128000)
|
||||
} else {
|
||||
"(no output, finished=$finished, exists=${tmpFile.exists()})"
|
||||
}
|
||||
tmpFile.delete()
|
||||
val spamPatterns =
|
||||
listOf(
|
||||
"setRequestedFrameRate",
|
||||
@@ -112,7 +119,7 @@ class DebugHandler(
|
||||
"IncorrectContextUseViolation",
|
||||
)
|
||||
val sb = StringBuilder()
|
||||
for (line in normalizedRaw.lineSequence()) {
|
||||
for (line in raw.lineSequence()) {
|
||||
if (line.isBlank()) continue
|
||||
if (spamPatterns.any { line.contains(it) }) continue
|
||||
if (sb.length + line.length > 16000) {
|
||||
@@ -122,55 +129,18 @@ class DebugHandler(
|
||||
if (sb.isNotEmpty()) sb.append('\n')
|
||||
sb.append(line)
|
||||
}
|
||||
sb.toString().ifEmpty { "(all ${normalizedRaw.lines().size} lines filtered as spam)" }
|
||||
sb.toString().ifEmpty { "(all ${raw.lines().size} lines filtered as spam)" }
|
||||
} catch (e: Throwable) {
|
||||
"(logcat error: ${e::class.java.simpleName}: ${e.message})"
|
||||
}
|
||||
return GatewaySession.InvokeResult.ok("""{"logs":${JsonPrimitive(info + logResult)}}""")
|
||||
// Also include camera debug log if it exists
|
||||
val camLogFile = java.io.File(appContext.cacheDir, "camera_debug.log")
|
||||
val camLog =
|
||||
if (camLogFile.exists() && camLogFile.length() > 0) {
|
||||
"\n--- camera_debug.log ---\n" + camLogFile.readText().take(4000)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
return GatewaySession.InvokeResult.ok("""{"logs":${JsonPrimitive(info + logResult + camLog)}}""")
|
||||
}
|
||||
}
|
||||
|
||||
internal fun collectProcessOutput(
|
||||
process: Process,
|
||||
timeoutMs: Long,
|
||||
maxChars: Int,
|
||||
): Pair<Boolean, String> {
|
||||
val output = AtomicReference("")
|
||||
val failure = AtomicReference<Throwable?>(null)
|
||||
val reader =
|
||||
Thread({
|
||||
try {
|
||||
output.set(readBoundedText(process.inputStream, maxChars))
|
||||
} catch (error: Throwable) {
|
||||
failure.set(error)
|
||||
}
|
||||
}, "openclaw-debug-output-reader")
|
||||
reader.isDaemon = true
|
||||
reader.start()
|
||||
|
||||
val finished = process.waitFor(timeoutMs, TimeUnit.MILLISECONDS)
|
||||
if (!finished) {
|
||||
process.destroyForcibly()
|
||||
}
|
||||
reader.join(1_000)
|
||||
failure.get()?.let { throw it }
|
||||
return finished to output.get()
|
||||
}
|
||||
|
||||
private fun readBoundedText(
|
||||
stream: InputStream,
|
||||
maxChars: Int,
|
||||
): String =
|
||||
stream.bufferedReader().use { reader ->
|
||||
val out = StringBuilder(minOf(maxChars, 8192))
|
||||
val buffer = CharArray(4096)
|
||||
while (true) {
|
||||
val read = reader.read(buffer)
|
||||
if (read < 0) break
|
||||
val remaining = maxChars - out.length
|
||||
if (remaining > 0) {
|
||||
out.append(buffer, 0, minOf(read, remaining))
|
||||
}
|
||||
}
|
||||
out.toString()
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package ai.openclaw.app.node
|
||||
import ai.openclaw.app.NotificationBurstLimiter
|
||||
import ai.openclaw.app.SecurePrefs
|
||||
import ai.openclaw.app.allowsPackage
|
||||
import ai.openclaw.app.gateway.OpenClawSQLiteStateStore
|
||||
import ai.openclaw.app.isWithinQuietHours
|
||||
import android.app.Notification
|
||||
import android.app.NotificationManager
|
||||
@@ -13,6 +12,7 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.service.notification.NotificationListenerService
|
||||
import android.service.notification.StatusBarNotification
|
||||
import androidx.core.content.edit
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
@@ -278,9 +278,8 @@ class DeviceNotificationListenerService : NotificationListenerService() {
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val notificationsPrefsPrefix = "notifications."
|
||||
private const val recentPackagesPref = notificationsPrefsPrefix + "forwarding.recentPackages"
|
||||
private const val legacyRecentPackagesPref = notificationsPrefsPrefix + "recentPackages"
|
||||
private const val recentPackagesPref = "notifications.forwarding.recentPackages"
|
||||
private const val legacyRecentPackagesPref = "notifications.recentPackages"
|
||||
private const val recentPackagesLimit = 64
|
||||
|
||||
@Volatile private var activeService: DeviceNotificationListenerService? = null
|
||||
@@ -293,45 +292,31 @@ class DeviceNotificationListenerService : NotificationListenerService() {
|
||||
nodeEventSink = sink
|
||||
}
|
||||
|
||||
private fun recentPackagesPrefs(context: Context) =
|
||||
context.applicationContext
|
||||
.getSharedPreferences("openclaw.secure", Context.MODE_PRIVATE)
|
||||
private fun recentPackagesPrefs(context: Context) = context.applicationContext.getSharedPreferences("openclaw.secure", Context.MODE_PRIVATE)
|
||||
|
||||
private fun migrateLegacyRecentPackagesIfNeeded(
|
||||
context: Context,
|
||||
stateStore: OpenClawSQLiteStateStore,
|
||||
): List<String> {
|
||||
private fun migrateLegacyRecentPackagesIfNeeded(context: Context) {
|
||||
val prefs = recentPackagesPrefs(context)
|
||||
val raw =
|
||||
prefs.getString(recentPackagesPref, null)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: prefs.getString(legacyRecentPackagesPref, null)?.trim().orEmpty()
|
||||
val packages =
|
||||
raw
|
||||
.split(',')
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.distinct()
|
||||
.take(recentPackagesLimit)
|
||||
if (packages.isNotEmpty()) {
|
||||
stateStore.replaceRecentNotificationPackages(packages, recentPackagesLimit)
|
||||
val hasNew = prefs.contains(recentPackagesPref)
|
||||
val legacy = prefs.getString(legacyRecentPackagesPref, null)?.trim().orEmpty()
|
||||
if (!hasNew && legacy.isNotEmpty()) {
|
||||
prefs.edit {
|
||||
putString(recentPackagesPref, legacy)
|
||||
remove(legacyRecentPackagesPref)
|
||||
}
|
||||
} else if (hasNew && prefs.contains(legacyRecentPackagesPref)) {
|
||||
prefs.edit { remove(legacyRecentPackagesPref) }
|
||||
}
|
||||
if (prefs.contains(recentPackagesPref) || prefs.contains(legacyRecentPackagesPref)) {
|
||||
prefs
|
||||
.edit()
|
||||
.remove(recentPackagesPref)
|
||||
.remove(legacyRecentPackagesPref)
|
||||
.apply()
|
||||
}
|
||||
return packages
|
||||
}
|
||||
|
||||
fun recentPackages(context: Context): List<String> {
|
||||
val stateStore = OpenClawSQLiteStateStore(context)
|
||||
val stored = stateStore.readRecentNotificationPackages(recentPackagesLimit)
|
||||
if (stored.isNotEmpty()) {
|
||||
return stored
|
||||
}
|
||||
return migrateLegacyRecentPackagesIfNeeded(context, stateStore)
|
||||
migrateLegacyRecentPackagesIfNeeded(context)
|
||||
val prefs = recentPackagesPrefs(context)
|
||||
val stored = prefs.getString(recentPackagesPref, null).orEmpty()
|
||||
return stored
|
||||
.split(',')
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.distinct()
|
||||
}
|
||||
|
||||
fun isAccessEnabled(context: Context): Boolean {
|
||||
@@ -381,13 +366,18 @@ class DeviceNotificationListenerService : NotificationListenerService() {
|
||||
val service = activeService ?: return
|
||||
val normalized = packageName?.trim().orEmpty()
|
||||
if (normalized.isEmpty() || normalized == service.packageName) return
|
||||
migrateLegacyRecentPackagesIfNeeded(service.applicationContext)
|
||||
val prefs = recentPackagesPrefs(service.applicationContext)
|
||||
val existing =
|
||||
recentPackages(service.applicationContext)
|
||||
.filter { it != normalized }
|
||||
prefs
|
||||
.getString(recentPackagesPref, null)
|
||||
.orEmpty()
|
||||
.split(',')
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() && it != normalized }
|
||||
.take(recentPackagesLimit - 1)
|
||||
val updated = listOf(normalized) + existing
|
||||
OpenClawSQLiteStateStore(service.applicationContext)
|
||||
.replaceRecentNotificationPackages(updated, recentPackagesLimit)
|
||||
prefs.edit { putString(recentPackagesPref, updated.joinToString(",")) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MobileColors – semantic color tokens with light + dark variants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
internal data class MobileColors(
|
||||
val surface: Color,
|
||||
val surfaceStrong: Color,
|
||||
@@ -104,6 +108,9 @@ internal object MobileColorsAccessor {
|
||||
@Composable get() = LocalMobileColors.current
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backward-compatible top-level accessors (composable getters)
|
||||
// ---------------------------------------------------------------------------
|
||||
// These allow existing call sites to keep using `mobileSurface`, `mobileText`, etc.
|
||||
// without converting every file at once. Each resolves to the themed value.
|
||||
|
||||
@@ -142,6 +149,10 @@ internal val mobileBackgroundGradient: Brush
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Typography tokens (theme-independent)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
internal val mobileFontFamily =
|
||||
FontFamily(
|
||||
Font(resId = R.font.manrope_400_regular, weight = FontWeight.Normal),
|
||||
|
||||
@@ -270,7 +270,7 @@ private fun providerPriority(provider: String): Int =
|
||||
"google" -> 2
|
||||
"openrouter" -> 3
|
||||
"ollama", "ollama-local" -> 4
|
||||
"codex" -> 5
|
||||
"codex", "openai-codex" -> 5
|
||||
else -> 100
|
||||
}
|
||||
|
||||
|
||||
@@ -130,10 +130,9 @@ class GatewayBootstrapAuthTest {
|
||||
android.content.Context.MODE_PRIVATE,
|
||||
)
|
||||
val prefs = SecurePrefs(app, securePrefsOverride = securePrefs)
|
||||
val authStore = DeviceAuthStore(app, legacyPrefsOverride = prefs)
|
||||
val runtime = NodeRuntime(app, prefs, deviceAuthStore = authStore)
|
||||
val runtime = NodeRuntime(app, prefs)
|
||||
val deviceId = DeviceIdentityStore(app).loadOrCreate().deviceId
|
||||
authStore.saveToken(deviceId, "operator", "bootstrap-operator-token")
|
||||
DeviceAuthStore(prefs).saveToken(deviceId, "operator", "bootstrap-operator-token")
|
||||
|
||||
writeField(runtime, "operatorStatusText", "Connecting…")
|
||||
invokeMaybeStartOperatorSessionAfterNodeConnect(
|
||||
@@ -193,7 +192,6 @@ class GatewayBootstrapAuthTest {
|
||||
app,
|
||||
prefs,
|
||||
tlsFingerprintProbe = { _, _ -> GatewayTlsProbeResult(fingerprintSha256 = "fp:1") },
|
||||
deviceAuthStore = DeviceAuthStore(app, legacyPrefsOverride = prefs),
|
||||
)
|
||||
val endpoint = GatewayEndpoint.manual(host = "gateway.example", port = 18789)
|
||||
val explicitAuth =
|
||||
@@ -328,9 +326,9 @@ class GatewayBootstrapAuthTest {
|
||||
android.content.Context.MODE_PRIVATE,
|
||||
)
|
||||
val prefs = SecurePrefs(app, securePrefsOverride = securePrefs)
|
||||
val authStore = DeviceAuthStore(app, legacyPrefsOverride = prefs)
|
||||
val runtime = NodeRuntime(app, prefs, deviceAuthStore = authStore)
|
||||
val runtime = NodeRuntime(app, prefs)
|
||||
val deviceId = DeviceIdentityStore(app).loadOrCreate().deviceId
|
||||
val authStore = DeviceAuthStore(prefs)
|
||||
prefs.setGatewayToken("stale-shared-token")
|
||||
prefs.setGatewayBootstrapToken("stale-bootstrap-token")
|
||||
prefs.setGatewayPassword("stale-password")
|
||||
|
||||
@@ -4,28 +4,27 @@ import ai.openclaw.app.SecurePrefs
|
||||
import android.content.Context
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class DeviceAuthStoreTest {
|
||||
@Before
|
||||
fun resetState() {
|
||||
File(RuntimeEnvironment.getApplication().filesDir, "openclaw").deleteRecursively()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun saveTokenPersistsNormalizedScopesMetadataInSQLite() {
|
||||
fun saveTokenPersistsNormalizedScopesMetadata() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val store = DeviceAuthStore(app, legacyPrefsOverride = legacyPrefs(app))
|
||||
val securePrefs =
|
||||
app.getSharedPreferences(
|
||||
"openclaw.node.secure.test.${UUID.randomUUID()}",
|
||||
Context.MODE_PRIVATE,
|
||||
)
|
||||
val prefs = SecurePrefs(app, securePrefsOverride = securePrefs)
|
||||
val store = DeviceAuthStore(prefs)
|
||||
|
||||
store.saveToken(
|
||||
deviceId = " Device-1 ",
|
||||
@@ -40,255 +39,25 @@ class DeviceAuthStoreTest {
|
||||
assertEquals("operator", entry?.role)
|
||||
assertEquals(listOf("operator.read", "operator.write"), entry?.scopes)
|
||||
assertTrue((entry?.updatedAtMs ?: 0L) > 0L)
|
||||
val row = OpenClawSQLiteStateStore(app).readDeviceAuthToken("device-1", "operator")
|
||||
assertNotNull(row)
|
||||
assertEquals("__openclaw_secure_prefs__", row?.token)
|
||||
assertEquals("""["operator.read","operator.write"]""", row?.scopesJson)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearTokenUpdatesSQLiteStore() {
|
||||
fun loadEntryReadsLegacyTokenWithoutMetadata() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val store = DeviceAuthStore(app, legacyPrefsOverride = legacyPrefs(app))
|
||||
store.saveToken("device-1", "operator", "operator-token", scopes = listOf("operator.read"))
|
||||
|
||||
store.clearToken("device-1", "operator")
|
||||
|
||||
assertNull(store.loadEntry("device-1", "operator"))
|
||||
assertNull(OpenClawSQLiteStateStore(app).readDeviceAuthToken("device-1", "operator"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadEntryMigratesLegacySecurePrefsToken() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val prefs = legacyPrefs(app)
|
||||
prefs.putString("gateway.deviceToken.device-1.operator", " operator-token ")
|
||||
prefs.putString(
|
||||
"gateway.deviceTokenMeta.device-1.operator",
|
||||
"""{"scopes":["operator.write"," operator.read ","operator.write"],"updatedAtMs":1700000000000}""",
|
||||
)
|
||||
|
||||
val entry = DeviceAuthStore(app, legacyPrefsOverride = prefs).loadEntry(" Device-1 ", " Operator ")
|
||||
|
||||
assertNotNull(entry)
|
||||
assertEquals("operator-token", entry?.token)
|
||||
assertEquals("operator", entry?.role)
|
||||
assertEquals(listOf("operator.read", "operator.write"), entry?.scopes)
|
||||
assertEquals(1700000000000L, entry?.updatedAtMs)
|
||||
assertEquals("operator-token", prefs.getString("gateway.deviceToken.device-1.operator"))
|
||||
assertNull(prefs.getString("gateway.deviceTokenMeta.device-1.operator"))
|
||||
assertEquals(
|
||||
"__openclaw_secure_prefs__",
|
||||
OpenClawSQLiteStateStore(app).readDeviceAuthToken("device-1", "operator")?.token,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadEntryMigratesAllLegacyRolesBeforeSQLiteRowsExist() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val prefs = legacyPrefs(app)
|
||||
prefs.putString("gateway.deviceToken.device-1.operator", " operator-token ")
|
||||
prefs.putString(
|
||||
"gateway.deviceTokenMeta.device-1.operator",
|
||||
"""{"scopes":["operator.write"],"updatedAtMs":1700000000000}""",
|
||||
)
|
||||
prefs.putString("gateway.deviceToken.device-1.node", " node-token ")
|
||||
prefs.putString(
|
||||
"gateway.deviceTokenMeta.device-1.node",
|
||||
"""{"scopes":["node.connect"],"updatedAtMs":1700000000001}""",
|
||||
)
|
||||
val store = DeviceAuthStore(app, legacyPrefsOverride = prefs)
|
||||
|
||||
val operator = store.loadEntry("device-1", "operator")
|
||||
val node = store.loadEntry("device-1", "node")
|
||||
|
||||
assertEquals("operator-token", operator?.token)
|
||||
assertEquals(listOf("operator.write"), operator?.scopes)
|
||||
assertEquals("node-token", node?.token)
|
||||
assertEquals(listOf("node.connect"), node?.scopes)
|
||||
assertEquals(
|
||||
"__openclaw_secure_prefs__",
|
||||
OpenClawSQLiteStateStore(app).readDeviceAuthToken("device-1", "operator")?.token,
|
||||
)
|
||||
assertEquals(
|
||||
"__openclaw_secure_prefs__",
|
||||
OpenClawSQLiteStateStore(app).readDeviceAuthToken("device-1", "node")?.token,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadEntryDoesNotResurrectLegacyRoleAfterSQLiteRowsExist() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val prefs = legacyPrefs(app)
|
||||
val store = DeviceAuthStore(app, legacyPrefsOverride = prefs)
|
||||
|
||||
store.saveToken("device-1", "operator", "operator-token", scopes = listOf("operator.write"))
|
||||
prefs.putString("gateway.deviceToken.device-1.admin", " stale-admin-token ")
|
||||
prefs.putString(
|
||||
"gateway.deviceTokenMeta.device-1.admin",
|
||||
"""{"scopes":["admin"],"updatedAtMs":1700000000000}""",
|
||||
)
|
||||
|
||||
store.saveToken("device-1", "operator", "operator-token-2", scopes = listOf("operator.write"))
|
||||
|
||||
assertNull(store.loadEntry("device-1", "admin"))
|
||||
assertNull(OpenClawSQLiteStateStore(app).readDeviceAuthToken("device-1", "admin"))
|
||||
assertNull(prefs.getString("gateway.deviceToken.device-1.admin"))
|
||||
assertNull(prefs.getString("gateway.deviceTokenMeta.device-1.admin"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun saveTokenMigratesSameDeviceLegacyRolesBeforeFirstSqliteWrite() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val prefs = legacyPrefs(app)
|
||||
prefs.putString("gateway.deviceToken.device-1.operator", " old-operator-token ")
|
||||
prefs.putString("gateway.deviceToken.device-1.node", " node-token ")
|
||||
prefs.putString(
|
||||
"gateway.deviceTokenMeta.device-1.node",
|
||||
"""{"scopes":["node.connect"],"updatedAtMs":1700000000001}""",
|
||||
)
|
||||
val store = DeviceAuthStore(app, legacyPrefsOverride = prefs)
|
||||
|
||||
store.saveToken("device-1", "operator", "operator-token", scopes = listOf("operator.write"))
|
||||
|
||||
assertEquals("operator-token", store.loadEntry("device-1", "operator")?.token)
|
||||
assertEquals("node-token", store.loadEntry("device-1", "node")?.token)
|
||||
assertEquals(
|
||||
"__openclaw_secure_prefs__",
|
||||
OpenClawSQLiteStateStore(app).readDeviceAuthToken("device-1", "node")?.token,
|
||||
)
|
||||
assertNull(prefs.getString("gateway.deviceTokenMeta.device-1.node"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadEntryReturnsLegacySecurePrefsTokenWhenSQLiteMigrationFails() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val prefs = legacyPrefs(app)
|
||||
val metadata = """{"scopes":["operator.read"],"updatedAtMs":1700000000000}"""
|
||||
prefs.putString("gateway.deviceToken.device-1.operator", " operator-token ")
|
||||
prefs.putString("gateway.deviceTokenMeta.device-1.operator", metadata)
|
||||
|
||||
val store =
|
||||
DeviceAuthStore.createForTesting(
|
||||
context = app,
|
||||
legacyPrefsOverride = prefs,
|
||||
stateStoreOverride = ThrowingDeviceAuthStateStore(),
|
||||
val securePrefs =
|
||||
app.getSharedPreferences(
|
||||
"openclaw.node.secure.test.${UUID.randomUUID()}",
|
||||
Context.MODE_PRIVATE,
|
||||
)
|
||||
val prefs = SecurePrefs(app, securePrefsOverride = securePrefs)
|
||||
prefs.putString("gateway.deviceToken.device-1.operator", "legacy-token")
|
||||
val store = DeviceAuthStore(prefs)
|
||||
|
||||
val entry = store.loadEntry("device-1", "operator")
|
||||
|
||||
assertEquals("operator-token", entry?.token)
|
||||
assertEquals(listOf("operator.read"), entry?.scopes)
|
||||
assertEquals(1700000000000L, entry?.updatedAtMs)
|
||||
assertEquals(" operator-token ", prefs.getString("gateway.deviceToken.device-1.operator"))
|
||||
assertEquals(metadata, prefs.getString("gateway.deviceTokenMeta.device-1.operator"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadEntryMovesPlaintextSqliteTokenBackToSecurePrefs() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val prefs = legacyPrefs(app)
|
||||
OpenClawSQLiteStateStore(app).upsertDeviceAuthToken(
|
||||
OpenClawSQLiteDeviceAuthTokenRow(
|
||||
deviceId = "device-1",
|
||||
role = "operator",
|
||||
token = "operator-token",
|
||||
scopesJson = """["operator.read"]""",
|
||||
updatedAtMs = 1700000000000,
|
||||
),
|
||||
)
|
||||
|
||||
val entry = DeviceAuthStore(app, legacyPrefsOverride = prefs).loadEntry("device-1", "operator")
|
||||
|
||||
assertEquals("operator-token", entry?.token)
|
||||
assertEquals("operator-token", prefs.getString("gateway.deviceToken.device-1.operator"))
|
||||
assertEquals(
|
||||
"__openclaw_secure_prefs__",
|
||||
OpenClawSQLiteStateStore(app).readDeviceAuthToken("device-1", "operator")?.token,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun saveTokenForDifferentDevicePurgesStaleLegacySecurePrefsTokens() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val prefs = legacyPrefs(app)
|
||||
prefs.putString("gateway.deviceToken.device-1.operator", " stale-token ")
|
||||
prefs.putString(
|
||||
"gateway.deviceTokenMeta.device-1.operator",
|
||||
"""{"scopes":["operator.read"],"updatedAtMs":1700000000000}""",
|
||||
)
|
||||
val store = DeviceAuthStore(app, legacyPrefsOverride = prefs)
|
||||
|
||||
store.saveToken("device-2", "operator", "fresh-token", scopes = listOf("operator.write"))
|
||||
|
||||
assertNull(store.loadEntry("device-1", "operator"))
|
||||
assertNull(prefs.getString("gateway.deviceToken.device-1.operator"))
|
||||
assertNull(prefs.getString("gateway.deviceTokenMeta.device-1.operator"))
|
||||
assertEquals("fresh-token", store.loadEntry("device-2", "operator")?.token)
|
||||
assertEquals("fresh-token", prefs.getString("gateway.deviceToken.device-2.operator"))
|
||||
assertEquals(
|
||||
"__openclaw_secure_prefs__",
|
||||
OpenClawSQLiteStateStore(app).readDeviceAuthToken("device-2", "operator")?.token,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun saveTokenPrunesForeignLegacyTokensWithoutOrphaningCurrentSqliteRows() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val prefs = legacyPrefs(app)
|
||||
prefs.putString("gateway.deviceToken.device-1.operator", " operator-token ")
|
||||
prefs.putString("gateway.deviceToken.device-1.node", " node-token ")
|
||||
prefs.putString("gateway.deviceToken.device-2.operator", " stale-token ")
|
||||
val sqlite = OpenClawSQLiteStateStore(app)
|
||||
sqlite.upsertDeviceAuthToken(
|
||||
OpenClawSQLiteDeviceAuthTokenRow(
|
||||
deviceId = "device-1",
|
||||
role = "operator",
|
||||
token = "__openclaw_secure_prefs__",
|
||||
scopesJson = """["operator.read"]""",
|
||||
updatedAtMs = 1700000000000,
|
||||
),
|
||||
)
|
||||
sqlite.upsertDeviceAuthToken(
|
||||
OpenClawSQLiteDeviceAuthTokenRow(
|
||||
deviceId = "device-1",
|
||||
role = "node",
|
||||
token = "__openclaw_secure_prefs__",
|
||||
scopesJson = """["node.connect"]""",
|
||||
updatedAtMs = 1700000000001,
|
||||
),
|
||||
)
|
||||
val store = DeviceAuthStore(app, legacyPrefsOverride = prefs)
|
||||
|
||||
store.saveToken("device-1", "operator", "operator-token-2", scopes = listOf("operator.write"))
|
||||
|
||||
assertEquals("operator-token-2", store.loadEntry("device-1", "operator")?.token)
|
||||
assertEquals("node-token", store.loadEntry("device-1", "node")?.token)
|
||||
assertNull(prefs.getString("gateway.deviceToken.device-2.operator"))
|
||||
}
|
||||
|
||||
private fun legacyPrefs(context: Context): SecurePrefs {
|
||||
val prefs = context.getSharedPreferences("openclaw.node.secure.test", Context.MODE_PRIVATE)
|
||||
prefs.edit().clear().commit()
|
||||
return SecurePrefs(context, securePrefsOverride = prefs)
|
||||
}
|
||||
|
||||
private class ThrowingDeviceAuthStateStore : DeviceAuthStateStore {
|
||||
override fun readDeviceAuthToken(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
): OpenClawSQLiteDeviceAuthTokenRow? = null
|
||||
|
||||
override fun readLatestDeviceAuthDeviceId(): String? = null
|
||||
|
||||
override fun upsertDeviceAuthToken(row: OpenClawSQLiteDeviceAuthTokenRow) {
|
||||
error("sqlite unavailable")
|
||||
}
|
||||
|
||||
override fun deleteDeviceAuthToken(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
) = Unit
|
||||
|
||||
override fun deleteAllDeviceAuthTokens() = Unit
|
||||
assertNotNull(entry)
|
||||
assertEquals("legacy-token", entry?.token)
|
||||
assertEquals("operator", entry?.role)
|
||||
assertEquals(emptyList<String>(), entry?.scopes)
|
||||
assertEquals(0L, entry?.updatedAtMs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
package ai.openclaw.app.gateway
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import java.io.File
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class DeviceIdentityStoreTest {
|
||||
@Before
|
||||
fun resetState() {
|
||||
File(RuntimeEnvironment.getApplication().filesDir, "openclaw").deleteRecursively()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadOrCreatePersistsIdentityInSQLiteWithoutJsonSidecars() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val store = DeviceIdentityStore(app)
|
||||
|
||||
val first = store.loadOrCreate()
|
||||
val roundTripStore = DeviceIdentityStore(app)
|
||||
val second = roundTripStore.loadOrCreate()
|
||||
|
||||
assertEquals(first.deviceId, second.deviceId)
|
||||
assertEquals(first.publicKeyRawBase64, second.publicKeyRawBase64)
|
||||
val signature = roundTripStore.signPayload("payload", second)
|
||||
assertNotNull(signature)
|
||||
assertTrue(roundTripStore.verifySelfSignature("payload", signature ?: "", second))
|
||||
assertFalse(File(app.filesDir, "openclaw/identity/device.json").exists())
|
||||
assertTrue(File(app.filesDir, "openclaw/state/openclaw.sqlite").exists())
|
||||
val persisted = readIdentityRow()
|
||||
assertNotNull(persisted)
|
||||
assertTrue(persisted?.contains("-----BEGIN PUBLIC KEY-----") == true)
|
||||
assertTrue(persisted?.contains(privateKeyMarker("BEGIN")) == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadOrCreateReadsTypeScriptPemIdentitySchemaFromSQLite() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val publicKeyPem =
|
||||
"""
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEAA6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=
|
||||
-----END PUBLIC KEY-----
|
||||
""".trimIndent()
|
||||
val privateKeyPem =
|
||||
pemBlock(
|
||||
"PRIVATE" + " KEY",
|
||||
"MC4CAQAwBQYDK2VwBCIEIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f",
|
||||
)
|
||||
OpenClawSQLiteStateStore(app).writeDeviceIdentity(
|
||||
OpenClawSQLiteDeviceIdentityRow(
|
||||
deviceId = "56475aa75463474c0285df5dbf2bcab73da651358839e9b77481b2eab107708c",
|
||||
publicKeyPem = publicKeyPem,
|
||||
privateKeyPem = privateKeyPem,
|
||||
createdAtMs = 1_700_000_000_000L,
|
||||
),
|
||||
)
|
||||
|
||||
val identity = DeviceIdentityStore(app).loadOrCreate()
|
||||
|
||||
assertEquals("56475aa75463474c0285df5dbf2bcab73da651358839e9b77481b2eab107708c", identity.deviceId)
|
||||
assertEquals("A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=", identity.publicKeyRawBase64)
|
||||
assertEquals("MC4CAQAwBQYDK2VwBCIEIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f", identity.privateKeyPkcs8Base64)
|
||||
assertEquals(1_700_000_000_000L, identity.createdAtMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadOrCreateRejectsSQLiteIdentityWhenPrivateKeyDoesNotMatchPublicKey() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val mismatchedPrivate = DeviceIdentityStore(app).loadOrCreate().privateKeyPkcs8Base64
|
||||
File(app.filesDir, "openclaw").deleteRecursively()
|
||||
|
||||
OpenClawSQLiteStateStore(app).writeDeviceIdentity(
|
||||
OpenClawSQLiteDeviceIdentityRow(
|
||||
deviceId = "56475aa75463474c0285df5dbf2bcab73da651358839e9b77481b2eab107708c",
|
||||
publicKeyPem =
|
||||
"""
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEAA6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=
|
||||
-----END PUBLIC KEY-----
|
||||
""".trimIndent(),
|
||||
privateKeyPem = pemBlock("PRIVATE" + " KEY", mismatchedPrivate),
|
||||
createdAtMs = 1_700_000_000_000L,
|
||||
),
|
||||
)
|
||||
|
||||
try {
|
||||
DeviceIdentityStore(app).loadOrCreate()
|
||||
fail("Expected mismatched SQLite identity keypair to block startup")
|
||||
} catch (error: IllegalStateException) {
|
||||
assertTrue(error.message?.contains("Run openclaw doctor --fix") == true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadOrCreateMigratesLegacyJsonIdentityInApp() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val seed = DeviceIdentityStore(app).loadOrCreate()
|
||||
File(app.filesDir, "openclaw").deleteRecursively()
|
||||
val legacy = File(app.filesDir, "openclaw/identity/device.json")
|
||||
legacy.parentFile?.mkdirs()
|
||||
legacy.writeText(
|
||||
"""
|
||||
{
|
||||
"deviceId": "stale",
|
||||
"publicKeyRawBase64": "${seed.publicKeyRawBase64}",
|
||||
"privateKeyPkcs8Base64": "${seed.privateKeyPkcs8Base64}",
|
||||
"createdAtMs": ${seed.createdAtMs}
|
||||
}
|
||||
""".trimIndent(),
|
||||
Charsets.UTF_8,
|
||||
)
|
||||
|
||||
val migrated = DeviceIdentityStore(app).loadOrCreate()
|
||||
|
||||
assertEquals(seed.deviceId, migrated.deviceId)
|
||||
assertEquals(seed.publicKeyRawBase64, migrated.publicKeyRawBase64)
|
||||
assertFalse(legacy.exists())
|
||||
assertTrue(File(app.filesDir, "openclaw/state/openclaw.sqlite").exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidLegacyJsonIdentityFailsClosedInsteadOfRotatingIdentity() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val legacy = File(app.filesDir, "openclaw/identity/device.json")
|
||||
legacy.parentFile?.mkdirs()
|
||||
legacy.writeText("""{"deviceId":"legacy"}""", Charsets.UTF_8)
|
||||
|
||||
try {
|
||||
DeviceIdentityStore(app).loadOrCreate()
|
||||
fail("Expected invalid legacy JSON identity to block startup")
|
||||
} catch (error: IllegalStateException) {
|
||||
assertTrue(error.message?.contains("Run openclaw doctor --fix") == true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun readIdentityRow(): String? {
|
||||
val dbFile = File(RuntimeEnvironment.getApplication().filesDir, "openclaw/state/openclaw.sqlite")
|
||||
return SQLiteDatabase
|
||||
.openDatabase(dbFile.absolutePath, null, SQLiteDatabase.OPEN_READONLY)
|
||||
.use { db ->
|
||||
db
|
||||
.rawQuery(
|
||||
"SELECT public_key_pem, private_key_pem FROM device_identities WHERE identity_key = ?",
|
||||
arrayOf("default"),
|
||||
).use { cursor ->
|
||||
if (cursor.moveToFirst()) "${cursor.getString(0)}\n${cursor.getString(1)}" else null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun privateKeyMarker(boundary: String): String = "-----$boundary ${"PRIVATE" + " KEY"}-----"
|
||||
|
||||
private fun pemBlock(
|
||||
label: String,
|
||||
body: String,
|
||||
) = "-----BEGIN $label-----\n$body\n-----END $label-----"
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package ai.openclaw.app.node
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class DebugHandlerTest {
|
||||
@Test
|
||||
fun collectProcessOutputDrainsLargeStdoutBeforeWaiting() {
|
||||
val process =
|
||||
ProcessBuilder("sh", "-c", "yes openclaw-log-line | head -n 20000")
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
|
||||
val (finished, output) = collectProcessOutput(process, timeoutMs = 4_000, maxChars = 128_000)
|
||||
|
||||
assertTrue("expected process to finish without timing out", finished)
|
||||
assertEquals(128_000, output.length)
|
||||
assertTrue(output.startsWith("openclaw-log-line"))
|
||||
}
|
||||
}
|
||||
@@ -3,66 +3,74 @@ package ai.openclaw.app.node
|
||||
import ai.openclaw.app.NotificationBurstLimiter
|
||||
import ai.openclaw.app.NotificationForwardingPolicy
|
||||
import ai.openclaw.app.NotificationPackageFilterMode
|
||||
import ai.openclaw.app.gateway.OpenClawSQLiteStateStore
|
||||
import ai.openclaw.app.isWithinQuietHours
|
||||
import android.content.Context
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import java.io.File
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class DeviceNotificationListenerServiceTest {
|
||||
@Before
|
||||
fun resetState() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
File(context.filesDir, "openclaw").deleteRecursively()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recentPackages_readsSqliteRows() {
|
||||
fun recentPackages_migratesLegacyPreferenceKey() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
OpenClawSQLiteStateStore(context).replaceRecentNotificationPackages(
|
||||
listOf("com.example.one", "com.example.two"),
|
||||
)
|
||||
val prefs = context.getSharedPreferences("openclaw.secure", Context.MODE_PRIVATE)
|
||||
prefs
|
||||
.edit()
|
||||
.clear()
|
||||
.putString("notifications.recentPackages", "com.example.one, com.example.two")
|
||||
.commit()
|
||||
|
||||
val packages = DeviceNotificationListenerService.recentPackages(context)
|
||||
|
||||
assertEquals(listOf("com.example.one", "com.example.two"), packages)
|
||||
assertEquals(
|
||||
"com.example.one, com.example.two",
|
||||
prefs.getString("notifications.forwarding.recentPackages", null),
|
||||
)
|
||||
assertFalse(prefs.contains("notifications.recentPackages"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recentPackages_migratesLegacySecurePrefsRows() {
|
||||
fun recentPackages_cleansUpLegacyKeyWhenNewKeyAlreadyExists() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
context
|
||||
.getSharedPreferences("openclaw.secure", android.content.Context.MODE_PRIVATE)
|
||||
val prefs = context.getSharedPreferences("openclaw.secure", Context.MODE_PRIVATE)
|
||||
prefs
|
||||
.edit()
|
||||
.putString("notifications." + "recentPackages", " com.example.legacy,com.example.other,com.example.legacy ")
|
||||
.apply()
|
||||
.clear()
|
||||
.putString("notifications.forwarding.recentPackages", "com.example.new")
|
||||
.putString("notifications.recentPackages", "com.example.legacy")
|
||||
.commit()
|
||||
|
||||
val packages = DeviceNotificationListenerService.recentPackages(context)
|
||||
|
||||
assertEquals(listOf("com.example.legacy", "com.example.other"), packages)
|
||||
assertEquals(
|
||||
listOf("com.example.legacy", "com.example.other"),
|
||||
OpenClawSQLiteStateStore(context).readRecentNotificationPackages(),
|
||||
)
|
||||
assertEquals(listOf("com.example.new"), packages)
|
||||
assertNull(prefs.getString("notifications.recentPackages", null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recentPackages_trimsDedupesAndPreservesRecencyOrder() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
OpenClawSQLiteStateStore(context).replaceRecentNotificationPackages(
|
||||
listOf(" com.example.recent ", "", "com.example.other", "com.example.recent", "com.example.third"),
|
||||
)
|
||||
val prefs = context.getSharedPreferences("openclaw.secure", Context.MODE_PRIVATE)
|
||||
prefs
|
||||
.edit()
|
||||
.clear()
|
||||
.putString(
|
||||
"notifications.forwarding.recentPackages",
|
||||
" com.example.recent , ,com.example.other,com.example.recent, com.example.third ",
|
||||
).commit()
|
||||
|
||||
val packages = DeviceNotificationListenerService.recentPackages(context)
|
||||
|
||||
assertEquals(listOf("com.example.recent", "com.example.other", "com.example.third"), packages)
|
||||
assertEquals(
|
||||
listOf("com.example.recent", "com.example.other", "com.example.third"),
|
||||
packages,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -286,7 +286,7 @@ class InvokeDispatcherTest {
|
||||
getNodeCanvasHostUrl = { null },
|
||||
getOperatorCanvasHostUrl = { null },
|
||||
),
|
||||
debugHandler = DebugHandler(DeviceIdentityStore(appContext)),
|
||||
debugHandler = DebugHandler(appContext, DeviceIdentityStore(appContext)),
|
||||
callLogHandler = CallLogHandler.forTesting(appContext, InvokeDispatcherFakeCallLogDataSource()),
|
||||
isForeground = { true },
|
||||
cameraEnabled = { cameraEnabled },
|
||||
@@ -308,6 +308,7 @@ class InvokeDispatcherTest {
|
||||
|
||||
private fun newCameraHandler(appContext: Context): CameraHandler =
|
||||
CameraHandler(
|
||||
appContext = appContext,
|
||||
camera = CameraCaptureManager(appContext),
|
||||
externalAudioCaptureActive = MutableStateFlow(false),
|
||||
showCameraHud = { _, _, _ -> },
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
# OpenClaw iOS Changelog
|
||||
|
||||
## 2026.5.30 - 2026-05-30
|
||||
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
- Added hosted push relay defaults, realtime Talk playback, and safer WebSocket ping handling for mobile sessions.
|
||||
- Updated App Store screenshots to cover Gateway pairing, Command, Chat, Talk, Agent, and Settings flows.
|
||||
- Highlighted realtime Talk relay, Gateway connection status, node capabilities, push wake, and privacy controls.
|
||||
|
||||
## 2026.5.28 - 2026-05-28
|
||||
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// Source of truth: apps/ios/version.json
|
||||
// Generated by scripts/ios-sync-versioning.ts.
|
||||
|
||||
OPENCLAW_IOS_VERSION = 2026.5.30
|
||||
OPENCLAW_MARKETING_VERSION = 2026.5.30
|
||||
OPENCLAW_IOS_VERSION = 2026.5.28
|
||||
OPENCLAW_MARKETING_VERSION = 2026.5.28
|
||||
OPENCLAW_BUILD_VERSION = 1
|
||||
|
||||
#include? "../build/Version.xcconfig"
|
||||
|
||||
@@ -73,10 +73,9 @@ Release behavior:
|
||||
- Changing the root gateway version does not change the iOS app version until you explicitly pin from the gateway.
|
||||
- See `apps/ios/VERSIONING.md` for the full workflow.
|
||||
|
||||
Relay behavior for beta builds:
|
||||
Required env for beta builds:
|
||||
|
||||
- Beta builds default to `https://ios-push-relay.openclaw.ai`.
|
||||
- Optional custom relay override: `OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com`
|
||||
- `OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com`
|
||||
This must be a plain `https://host[:port][/path]` base URL without whitespace, query params, fragments, or xcconfig metacharacters.
|
||||
|
||||
Archive without upload:
|
||||
@@ -119,7 +118,7 @@ scripts/ios-asc-keychain-setup.sh \
|
||||
|
||||
This should create `apps/ios/fastlane/.env` with the non-secret ASC variables while the private key stays in Keychain.
|
||||
|
||||
3. Optional: set a custom official/TestFlight relay URL for the build. If unset, the beta flow uses `https://ios-push-relay.openclaw.ai`.
|
||||
3. Set the official/TestFlight relay URL for the build:
|
||||
|
||||
```bash
|
||||
export OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com
|
||||
|
||||
@@ -1,652 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TalkProTab: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
|
||||
@AppStorage(TalkSpeechLocale.storageKey) private var talkSpeechLocale: String = TalkSpeechLocale.automaticID
|
||||
@AppStorage(TalkDefaults.speakerphoneEnabledKey) private var talkSpeakerphoneEnabled: Bool =
|
||||
TalkDefaults.speakerphoneEnabledByDefault
|
||||
@AppStorage("talk.background.enabled") private var talkBackgroundEnabled: Bool = false
|
||||
@State private var showPermissionPrompt = false
|
||||
var openSettings: () -> Void
|
||||
|
||||
private var state: TalkProState {
|
||||
TalkProState(
|
||||
gatewayConnected: self.gatewayConnected,
|
||||
isEnabled: self.appModel.talkMode.isEnabled || self.talkEnabled,
|
||||
statusText: self.appModel.talkMode.statusText,
|
||||
isListening: self.appModel.talkMode.isListening,
|
||||
isSpeaking: self.appModel.talkMode.isSpeaking,
|
||||
isUserSpeechDetected: self.appModel.talkMode.isUserSpeechDetected,
|
||||
permissionState: self.appModel.talkMode.gatewayTalkPermissionState)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
CommandControlBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
self.header
|
||||
self.voiceHeroCard
|
||||
self.conversationCard
|
||||
self.voiceModeCard
|
||||
self.controlsCard
|
||||
}
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 18)
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
.sheet(isPresented: self.$showPermissionPrompt) {
|
||||
NavigationStack {
|
||||
TalkPermissionPromptView(
|
||||
style: .sheet,
|
||||
onPermissionReady: {
|
||||
self.showPermissionPrompt = false
|
||||
self.startTalk()
|
||||
})
|
||||
.padding()
|
||||
.navigationTitle("Enable Talk")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Not Now") {
|
||||
self.showPermissionPrompt = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
.openClawSheetChrome()
|
||||
}
|
||||
.onAppear { self.alignPersistedTalkState() }
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .center, spacing: 11) {
|
||||
OpenClawProMark(size: 31, shadowRadius: 9)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Talk")
|
||||
.font(.system(size: 27, weight: .bold, design: .rounded))
|
||||
Text(self.headerSubtitle)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
self.statusChip
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var statusChip: some View {
|
||||
HStack(spacing: 5) {
|
||||
Circle()
|
||||
.fill(self.state.color)
|
||||
.frame(width: 7, height: 7)
|
||||
Text(self.state.chipText)
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(self.state.color)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
.background {
|
||||
Capsule(style: .continuous)
|
||||
.fill(self.state.color.opacity(0.11))
|
||||
.overlay {
|
||||
Capsule(style: .continuous)
|
||||
.strokeBorder(self.state.color.opacity(0.22), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var voiceHeroCard: some View {
|
||||
CommandPanel(tint: self.state.color, isProminent: true, padding: 16) {
|
||||
VStack(alignment: .center, spacing: 16) {
|
||||
TalkProOrb(
|
||||
mode: self.state.waveformMode(micLevel: self.appModel.talkMode.micLevel),
|
||||
color: self.state.color,
|
||||
systemImage: self.state.icon)
|
||||
.frame(height: 188)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
VStack(spacing: 5) {
|
||||
Text(self.state.title)
|
||||
.font(.title3.weight(.bold))
|
||||
.multilineTextAlignment(.center)
|
||||
Text(self.heroSubtitle)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Button(action: self.handlePrimaryAction) {
|
||||
Label(self.state.primaryButtonTitle, systemImage: self.state.primaryButtonIcon)
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 50)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(self.state.primaryButtonFill)
|
||||
.shadow(color: self.state.color.opacity(0.28), radius: 18, y: 8)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(self.state.primaryAction == .waiting)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var conversationCard: some View {
|
||||
CommandPanel(padding: 0) {
|
||||
VStack(spacing: 0) {
|
||||
self.cardHeader(title: "Conversation", value: self.state.chipText, color: self.state.color)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 11)
|
||||
.padding(.bottom, 3)
|
||||
self.infoRow(icon: "person.crop.circle.fill", title: "Agent", value: self.appModel.activeAgentName)
|
||||
Divider().padding(.leading, 54)
|
||||
self.infoRow(
|
||||
icon: "bubble.left.and.text.bubble.right.fill",
|
||||
title: "Session",
|
||||
value: self.appModel.chatSessionKey)
|
||||
Divider().padding(.leading, 54)
|
||||
self.infoRow(icon: self.state.icon, title: "Runtime", value: self.appModel.talkMode.statusText)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var voiceModeCard: some View {
|
||||
CommandPanel(padding: 0) {
|
||||
VStack(spacing: 0) {
|
||||
self.cardHeader(
|
||||
title: "Voice mode",
|
||||
value: "Settings ›",
|
||||
color: OpenClawBrand.accent,
|
||||
action: self.openSettings)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 11)
|
||||
.padding(.bottom, 3)
|
||||
self.infoRow(icon: "waveform", title: "Mode", value: self.appModel.talkMode.gatewayTalkVoiceModeTitle)
|
||||
Divider().padding(.leading, 54)
|
||||
self.infoRow(icon: "antenna.radiowaves.left.and.right", title: "Transport", value: self.transportText)
|
||||
Divider().padding(.leading, 54)
|
||||
self.infoRow(icon: "key.fill", title: "Permission", value: self.permissionText)
|
||||
Divider().padding(.leading, 54)
|
||||
self.infoRow(icon: "globe", title: "Speech language", value: self.speechLocaleText)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var controlsCard: some View {
|
||||
CommandPanel(padding: 0) {
|
||||
VStack(spacing: 0) {
|
||||
self.cardHeader(title: "Controls", value: nil, color: .secondary)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 11)
|
||||
.padding(.bottom, 3)
|
||||
Toggle("Speakerphone", isOn: self.$talkSpeakerphoneEnabled)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
Divider().padding(.leading, 14)
|
||||
Toggle("Background listening", isOn: self.$talkBackgroundEnabled)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
Divider().padding(.leading, 14)
|
||||
Button(action: self.openSettings) {
|
||||
HStack {
|
||||
Label("Voice & Talk settings", systemImage: "slider.horizontal.3")
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private func cardHeader(
|
||||
title: String,
|
||||
value: String?,
|
||||
color: Color,
|
||||
action: (() -> Void)? = nil) -> some View
|
||||
{
|
||||
HStack(spacing: 8) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.bold))
|
||||
Spacer(minLength: 8)
|
||||
if let value {
|
||||
if let action {
|
||||
Button(value, action: action)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(color)
|
||||
} else {
|
||||
Text(value)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func infoRow(icon: String, title: String, value: String) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: icon)
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(self.state.color)
|
||||
.frame(width: 30, height: 30)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(self.state.color.opacity(0.11))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "—" : value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.78)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 9)
|
||||
}
|
||||
|
||||
private var gatewayConnected: Bool {
|
||||
GatewayStatusBuilder.build(appModel: self.appModel) == .connected
|
||||
}
|
||||
|
||||
private var headerSubtitle: String {
|
||||
let mode = self.appModel.talkMode.gatewayTalkVoiceModeTitle.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let agent = self.appModel.activeAgentName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if mode.isEmpty || mode == "Not loaded" { return agent.isEmpty ? "Realtime voice" : agent }
|
||||
if agent.isEmpty { return mode }
|
||||
return "\(agent) • \(mode)"
|
||||
}
|
||||
|
||||
private var heroSubtitle: String {
|
||||
if self.state
|
||||
.prefersPermissionCopy { return "Gateway approval is required before this phone can capture voice." }
|
||||
if !self.gatewayConnected { return "Connect to your gateway to start a voice conversation." }
|
||||
let subtitle = (self.appModel.talkMode.gatewayTalkVoiceModeSubtitle ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !subtitle.isEmpty { return subtitle }
|
||||
return "Routes voice to \(self.appModel.activeAgentName)."
|
||||
}
|
||||
|
||||
private var transportText: String {
|
||||
let provider = self.appModel.talkMode.gatewayTalkProviderLabel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let transport = self.appModel.talkMode.gatewayTalkTransportLabel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if provider.isEmpty || provider == "Not loaded" { return transport.isEmpty ? "Not loaded" : transport }
|
||||
if transport.isEmpty || transport == "Not loaded" { return provider }
|
||||
return "\(provider) • \(transport)"
|
||||
}
|
||||
|
||||
private var permissionText: String {
|
||||
if let failure = self.appModel.talkMode.gatewayTalkPermissionState.failureMessage {
|
||||
return failure
|
||||
}
|
||||
return self.appModel.talkMode.gatewayTalkPermissionState.statusLabel
|
||||
}
|
||||
|
||||
private var speechLocaleText: String {
|
||||
if self.talkSpeechLocale == TalkSpeechLocale.automaticID { return "Automatic" }
|
||||
return self.talkSpeechLocale
|
||||
}
|
||||
|
||||
private func alignPersistedTalkState() {
|
||||
if self.appModel.talkMode.gatewayTalkPermissionState.requiresTalkPermissionAction,
|
||||
self.talkEnabled || self.appModel.talkMode.isEnabled
|
||||
{
|
||||
self.stopTalk()
|
||||
} else if self.talkEnabled != self.appModel.talkMode.isEnabled {
|
||||
self.appModel.setTalkEnabled(self.talkEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePrimaryAction() {
|
||||
switch self.state.primaryAction {
|
||||
case .start:
|
||||
self.startTalk()
|
||||
case .stop:
|
||||
self.stopTalk()
|
||||
case .enablePermission:
|
||||
self.stopTalk()
|
||||
self.showPermissionPrompt = true
|
||||
case .openSettings:
|
||||
self.openSettings()
|
||||
case .waiting:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func startTalk() {
|
||||
self.talkEnabled = true
|
||||
self.appModel.setTalkEnabled(true)
|
||||
}
|
||||
|
||||
private func stopTalk() {
|
||||
self.talkEnabled = false
|
||||
self.appModel.setTalkEnabled(false)
|
||||
}
|
||||
}
|
||||
|
||||
enum TalkProPrimaryAction: Equatable {
|
||||
case start
|
||||
case stop
|
||||
case enablePermission
|
||||
case openSettings
|
||||
case waiting
|
||||
}
|
||||
|
||||
enum TalkProWaveformMode: Equatable {
|
||||
case level(Double)
|
||||
case inputSpeech
|
||||
case speaking
|
||||
case indeterminate
|
||||
case still
|
||||
}
|
||||
|
||||
struct TalkProState: Equatable {
|
||||
let gatewayConnected: Bool
|
||||
let isEnabled: Bool
|
||||
let statusText: String
|
||||
let isListening: Bool
|
||||
let isSpeaking: Bool
|
||||
let isUserSpeechDetected: Bool
|
||||
let permissionState: TalkGatewayPermissionState
|
||||
|
||||
private var normalizedStatus: String {
|
||||
self.statusText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
}
|
||||
|
||||
var title: String {
|
||||
if !self.gatewayConnected { return "Gateway offline" }
|
||||
switch self.permissionState {
|
||||
case .missingScope, .requestFailed:
|
||||
return "Gateway permission required"
|
||||
case .requestingUpgrade:
|
||||
return "Requesting approval"
|
||||
case .upgradeRequested:
|
||||
return "Approval requested"
|
||||
case .apiKeyMissing:
|
||||
return "Voice API key missing"
|
||||
case .loadFailed:
|
||||
return "Voice config failed"
|
||||
default:
|
||||
break
|
||||
}
|
||||
if self.isSpeaking { return "Speaking" }
|
||||
if self.isListening { return "Listening" }
|
||||
if self.normalizedStatus.contains("connecting") { return "Connecting" }
|
||||
if self.normalizedStatus.contains("thinking") { return "Asking OpenClaw" }
|
||||
if self.isEnabled { return "Ready to talk" }
|
||||
return "Talk is off"
|
||||
}
|
||||
|
||||
var chipText: String {
|
||||
if !self.gatewayConnected { return "Offline" }
|
||||
switch self.permissionState {
|
||||
case .missingScope, .requestFailed:
|
||||
return "Needs approval"
|
||||
case .requestingUpgrade, .upgradeRequested:
|
||||
return "Pending"
|
||||
case .apiKeyMissing:
|
||||
return "API key"
|
||||
case .loadFailed:
|
||||
return "Config"
|
||||
default:
|
||||
break
|
||||
}
|
||||
if self.isSpeaking { return "Speaking" }
|
||||
if self.isListening { return "Listening" }
|
||||
if self.isEnabled { return "Ready" }
|
||||
return "Off"
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
if !self.gatewayConnected { return "wifi.slash" }
|
||||
switch self.permissionState {
|
||||
case .missingScope, .requestFailed:
|
||||
return "key.fill"
|
||||
case .requestingUpgrade:
|
||||
return "paperplane.fill"
|
||||
case .upgradeRequested:
|
||||
return "hourglass"
|
||||
case .apiKeyMissing, .loadFailed:
|
||||
return "exclamationmark.triangle.fill"
|
||||
default:
|
||||
break
|
||||
}
|
||||
if self.isSpeaking { return "speaker.wave.2.fill" }
|
||||
if self.isListening { return "mic.fill" }
|
||||
if self.normalizedStatus.contains("thinking") { return "sparkles" }
|
||||
if self.normalizedStatus.contains("connecting") { return "dot.radiowaves.left.and.right" }
|
||||
return "waveform"
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
if !self.gatewayConnected { return .secondary }
|
||||
switch self.permissionState {
|
||||
case .requestFailed, .loadFailed:
|
||||
return OpenClawBrand.danger
|
||||
case .missingScope, .requestingUpgrade, .upgradeRequested, .apiKeyMissing:
|
||||
return OpenClawBrand.warn
|
||||
default:
|
||||
return self.isEnabled ? OpenClawBrand.ok : OpenClawBrand.accentHot
|
||||
}
|
||||
}
|
||||
|
||||
var primaryAction: TalkProPrimaryAction {
|
||||
if !self.gatewayConnected { return .openSettings }
|
||||
switch self.permissionState {
|
||||
case .missingScope, .requestFailed:
|
||||
return .enablePermission
|
||||
case .requestingUpgrade, .upgradeRequested:
|
||||
return .waiting
|
||||
case .apiKeyMissing, .loadFailed:
|
||||
return .openSettings
|
||||
default:
|
||||
return self.isEnabled ? .stop : .start
|
||||
}
|
||||
}
|
||||
|
||||
var primaryButtonTitle: String {
|
||||
switch self.primaryAction {
|
||||
case .start: "Start Talk"
|
||||
case .stop: "Stop Talk"
|
||||
case .enablePermission: "Enable Talk"
|
||||
case .openSettings: self.gatewayConnected ? "Open Voice Settings" : "Open Gateway Settings"
|
||||
case .waiting: "Waiting for Approval"
|
||||
}
|
||||
}
|
||||
|
||||
var primaryButtonIcon: String {
|
||||
switch self.primaryAction {
|
||||
case .start: "play.fill"
|
||||
case .stop: "stop.fill"
|
||||
case .enablePermission: "key.fill"
|
||||
case .openSettings: "gearshape.fill"
|
||||
case .waiting: "hourglass"
|
||||
}
|
||||
}
|
||||
|
||||
var primaryButtonFill: AnyShapeStyle {
|
||||
switch self.primaryAction {
|
||||
case .stop:
|
||||
AnyShapeStyle(OpenClawBrand.danger)
|
||||
case .waiting:
|
||||
AnyShapeStyle(OpenClawBrand.warn.opacity(0.72))
|
||||
default:
|
||||
AnyShapeStyle(LinearGradient(
|
||||
colors: [self.color.opacity(0.95), OpenClawBrand.accent],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing))
|
||||
}
|
||||
}
|
||||
|
||||
var prefersPermissionCopy: Bool {
|
||||
switch self.permissionState {
|
||||
case .missingScope, .requestingUpgrade, .upgradeRequested, .requestFailed:
|
||||
true
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
func waveformMode(micLevel: Double) -> TalkProWaveformMode {
|
||||
if !self.gatewayConnected { return .still }
|
||||
switch self.permissionState {
|
||||
case .requestingUpgrade, .upgradeRequested:
|
||||
return .indeterminate
|
||||
case .missingScope, .requestFailed, .apiKeyMissing, .loadFailed:
|
||||
return .still
|
||||
default:
|
||||
break
|
||||
}
|
||||
if self.isSpeaking { return .speaking }
|
||||
if self.isListening, self.isUserSpeechDetected { return .inputSpeech }
|
||||
if self.isListening { return .level(micLevel) }
|
||||
if self.normalizedStatus.contains("connecting") || self.normalizedStatus.contains("thinking") {
|
||||
return .indeterminate
|
||||
}
|
||||
return self.isEnabled ? .indeterminate : .still
|
||||
}
|
||||
}
|
||||
|
||||
private struct TalkProOrb: View {
|
||||
let mode: TalkProWaveformMode
|
||||
let color: Color
|
||||
let systemImage: String
|
||||
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
var body: some View {
|
||||
TimelineView(.periodic(from: .now, by: 1.0 / 24.0)) { timeline in
|
||||
ZStack {
|
||||
ForEach(0..<3, id: \.self) { ring in
|
||||
Circle()
|
||||
.strokeBorder(self.color.opacity(self.ringOpacity(ring)), lineWidth: 1.4)
|
||||
.scaleEffect(self.ringScale(ring, date: timeline.date))
|
||||
}
|
||||
Circle()
|
||||
.fill(self.color.opacity(0.13))
|
||||
.frame(width: 128, height: 128)
|
||||
.overlay {
|
||||
Circle()
|
||||
.strokeBorder(self.color.opacity(0.30), lineWidth: 1)
|
||||
}
|
||||
TalkProWaveform(mode: self.mode, tint: self.color, barCount: 18)
|
||||
.frame(width: 116, height: 52)
|
||||
.opacity(self.systemImage == "waveform" || self.systemImage == "mic.fill" ? 1 : 0.34)
|
||||
Image(systemName: self.systemImage)
|
||||
.font(.system(size: 34, weight: .bold))
|
||||
.foregroundStyle(self.color)
|
||||
.opacity(self.systemImage == "waveform" || self.systemImage == "mic.fill" ? 0.20 : 1)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private func ringScale(_ ring: Int, date: Date) -> CGFloat {
|
||||
guard !self.reduceMotion else { return CGFloat(1.0 + (Double(ring) * 0.12)) }
|
||||
let base = 0.88 + (Double(ring) * 0.18)
|
||||
let speed = self.mode == .still ? 0.8 : 1.8
|
||||
let phase = date.timeIntervalSinceReferenceDate * speed + Double(ring) * 0.9
|
||||
return CGFloat(base + (sin(phase) * 0.035))
|
||||
}
|
||||
|
||||
private func ringOpacity(_ ring: Int) -> Double {
|
||||
switch self.mode {
|
||||
case .still:
|
||||
0.10 - (Double(ring) * 0.018)
|
||||
default:
|
||||
0.24 - (Double(ring) * 0.045)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TalkProWaveform: View {
|
||||
let mode: TalkProWaveformMode
|
||||
let tint: Color
|
||||
let barCount: Int
|
||||
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
var body: some View {
|
||||
TimelineView(.periodic(from: .now, by: 1.0 / 24.0)) { timeline in
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
ForEach(0..<self.barCount, id: \.self) { index in
|
||||
Capsule(style: .continuous)
|
||||
.fill(self.tint.opacity(self.opacity(for: index)))
|
||||
.frame(width: 4, height: self.height(for: index, date: timeline.date))
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private func height(for index: Int, date: Date) -> CGFloat {
|
||||
let minimum = 6.0
|
||||
let maximum = 48.0
|
||||
return CGFloat(minimum + ((maximum - minimum) * self.amplitude(for: index, date: date)))
|
||||
}
|
||||
|
||||
private func opacity(for index: Int) -> Double {
|
||||
switch self.mode {
|
||||
case .still:
|
||||
index == self.barCount / 2 ? 0.64 : 0.30
|
||||
default:
|
||||
0.82
|
||||
}
|
||||
}
|
||||
|
||||
private func amplitude(for index: Int, date: Date) -> Double {
|
||||
if self.reduceMotion {
|
||||
switch self.mode {
|
||||
case let .level(level): return min(max(level, 0.10), 1.0)
|
||||
case .inputSpeech: return 0.72
|
||||
case .speaking: return 0.62
|
||||
case .indeterminate: return 0.34
|
||||
case .still: return 0.18
|
||||
}
|
||||
}
|
||||
|
||||
let t = date.timeIntervalSinceReferenceDate
|
||||
let phase = Double(index) * 0.52
|
||||
switch self.mode {
|
||||
case let .level(level):
|
||||
let clamped = min(max(level, 0), 1)
|
||||
let shaped = 0.12 + (0.88 * clamped)
|
||||
let variation = 0.72 + (0.28 * sin((t * 12.0) + phase))
|
||||
return min(max(shaped * variation, 0.10), 1.0)
|
||||
case .inputSpeech:
|
||||
let primary = 0.5 + (0.5 * sin((t * 14.0) + phase))
|
||||
let secondary = 0.5 + (0.5 * sin((t * 5.0) + (phase * 1.35)))
|
||||
return min(max(0.16 + (0.60 * primary) + (0.24 * secondary), 0.14), 1.0)
|
||||
case .speaking:
|
||||
let wave = 0.5 + (0.5 * sin((t * 7.5) + phase))
|
||||
let secondary = 0.5 + (0.5 * sin((t * 3.0) + (phase * 0.7)))
|
||||
return min(max(0.18 + (0.58 * wave) + (0.24 * secondary), 0.12), 1.0)
|
||||
case .indeterminate:
|
||||
let center = (sin((t * 3.2) + phase) + 1) / 2
|
||||
return 0.16 + (0.42 * center)
|
||||
case .still:
|
||||
return index == self.barCount / 2 ? 0.32 : 0.16
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2635,21 +2635,8 @@ extension NodeAppModel {
|
||||
struct SessionRow: Decodable {
|
||||
var key: String
|
||||
var updatedAt: Double?
|
||||
var deliveryContext: DeliveryContext?
|
||||
var lastChannel: String?
|
||||
var lastTo: String?
|
||||
|
||||
var deliveryChannel: String? {
|
||||
self.deliveryContext?.channel ?? self.lastChannel
|
||||
}
|
||||
|
||||
var deliveryTo: String? {
|
||||
self.deliveryContext?.to ?? self.lastTo
|
||||
}
|
||||
}
|
||||
struct DeliveryContext: Decodable {
|
||||
var channel: String?
|
||||
var to: String?
|
||||
}
|
||||
struct SessionsListResult: Decodable {
|
||||
var sessions: [SessionRow]
|
||||
@@ -2672,13 +2659,11 @@ extension NodeAppModel {
|
||||
let currentKey = self.mainSessionKey
|
||||
let sorted = decoded.sessions.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) }
|
||||
let exactMatch = sorted.first { row in
|
||||
row.key == currentKey
|
||||
&& normalize(row.deliveryChannel) != nil
|
||||
&& normalize(row.deliveryTo) != nil
|
||||
row.key == currentKey && normalize(row.lastChannel) != nil && normalize(row.lastTo) != nil
|
||||
}
|
||||
let selected = exactMatch
|
||||
let channel = normalize(selected?.deliveryChannel)
|
||||
let to = normalize(selected?.deliveryTo)
|
||||
let channel = normalize(selected?.lastChannel)
|
||||
let to = normalize(selected?.lastTo)
|
||||
|
||||
await MainActor.run {
|
||||
self.shareDeliveryChannel = channel
|
||||
|
||||
@@ -17,7 +17,6 @@ private struct RelayGatewayPushRegistrationPayload: Encodable {
|
||||
var topic: String
|
||||
var environment: String
|
||||
var distribution: String
|
||||
var relayOrigin: String
|
||||
var tokenDebugSuffix: String?
|
||||
}
|
||||
|
||||
@@ -108,7 +107,6 @@ actor PushRegistrationManager {
|
||||
topic: topic,
|
||||
environment: self.buildConfig.apnsEnvironment.rawValue,
|
||||
distribution: self.buildConfig.distribution.rawValue,
|
||||
relayOrigin: relayOrigin,
|
||||
tokenDebugSuffix: stored.tokenDebugSuffix))
|
||||
}
|
||||
|
||||
@@ -140,7 +138,6 @@ actor PushRegistrationManager {
|
||||
topic: topic,
|
||||
environment: self.buildConfig.apnsEnvironment.rawValue,
|
||||
distribution: self.buildConfig.distribution.rawValue,
|
||||
relayOrigin: relayOrigin,
|
||||
tokenDebugSuffix: registrationState.tokenDebugSuffix))
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ struct RootTabs: View {
|
||||
private enum AppTab: Hashable {
|
||||
case control
|
||||
case chat
|
||||
case talk
|
||||
case agent
|
||||
case settings
|
||||
}
|
||||
@@ -54,8 +53,6 @@ struct RootTabs: View {
|
||||
switch arguments[valueIndex].lowercased() {
|
||||
case "chat":
|
||||
return .chat
|
||||
case "talk", "voice":
|
||||
return .talk
|
||||
case "agent", "agents":
|
||||
return .agent
|
||||
case "settings":
|
||||
@@ -148,14 +145,6 @@ struct RootTabs: View {
|
||||
.tabItem { Label("Chat", systemImage: "bubble.left.fill") }
|
||||
.tag(AppTab.chat)
|
||||
|
||||
TalkProTab(openSettings: { self.selectedTab = .settings })
|
||||
.tabItem {
|
||||
Label(
|
||||
"Talk",
|
||||
systemImage: self.appModel.talkMode.isEnabled ? "waveform.circle.fill" : "waveform.circle")
|
||||
}
|
||||
.tag(AppTab.talk)
|
||||
|
||||
AgentProTab()
|
||||
.tabItem { Label("Agent", systemImage: "person.2.fill") }
|
||||
.tag(AppTab.agent)
|
||||
|
||||
@@ -17,7 +17,7 @@ private func makeRealtimeAudioTapBlock(
|
||||
inputSampleRate: inputSampleRate,
|
||||
targetSampleRate: targetSampleRate)
|
||||
guard !encoded.isEmpty else { return }
|
||||
let timestampMs = (ProcessInfo.processInfo.systemUptime * 1000).rounded()
|
||||
let timestampMs = ProcessInfo.processInfo.systemUptime * 1000
|
||||
let rms = RealtimeTalkRelaySession.rmsLevel(buffer: buffer)
|
||||
onAudio(encoded, timestampMs, rms)
|
||||
}
|
||||
@@ -125,24 +125,15 @@ final class RealtimeTalkRelaySession {
|
||||
private var eventTask: Task<Void, Never>?
|
||||
private var outputTask: Task<Void, Never>?
|
||||
private var outputContinuation: AsyncThrowingStream<Data, Error>.Continuation?
|
||||
private var outputIdleTask: Task<Void, Never>?
|
||||
private var outputSessionId = 0
|
||||
private var pendingOutputChunks: [Data] = []
|
||||
private var pendingOutputDone = false
|
||||
private var audioSender: RealtimeAudioSender?
|
||||
private var isClosed = false
|
||||
private var isOutputPlaying = false
|
||||
private var outputStartedAtMs: Double?
|
||||
private var outputPlaybackExpectedEndMs: Double = 0
|
||||
private var lastBargeInAtMs: Double = 0
|
||||
private var micLogFrameCount = 0
|
||||
private var micLogByteCount = 0
|
||||
private var micLogMaxRms: Float = 0
|
||||
private var lastMicLogAtMs: Double = 0
|
||||
private var suppressedEchoFrameCount = 0
|
||||
private var suppressedEchoByteCount = 0
|
||||
private var suppressedEchoMaxRms: Float = 0
|
||||
private var lastSuppressedEchoLogAtMs: Double = 0
|
||||
private var outputAudioChunkCount = 0
|
||||
private var outputAudioByteCount = 0
|
||||
|
||||
@@ -177,6 +168,7 @@ final class RealtimeTalkRelaySession {
|
||||
let eventStream = await self.gateway.subscribeServerEvents(bufferingNewest: 200)
|
||||
self.startEventPump(stream: eventStream)
|
||||
self.configureAudioContract(result.audio)
|
||||
self.startOutputPlayback()
|
||||
try self.startMicrophonePump()
|
||||
self.onStatus("Listening (Realtime)")
|
||||
} catch {
|
||||
@@ -227,6 +219,7 @@ final class RealtimeTalkRelaySession {
|
||||
|
||||
func cancelOutput(reason: String = "user") {
|
||||
self.stopOutputPlayback()
|
||||
self.startOutputPlayback()
|
||||
guard let relaySessionId else { return }
|
||||
Task { [gateway] in
|
||||
let payload: [String: Any] = [
|
||||
@@ -313,18 +306,12 @@ final class RealtimeTalkRelaySession {
|
||||
let data = Data(base64Encoded: base64)
|
||||
else { return }
|
||||
self.recordOutputAudioChunk(byteCount: data.count)
|
||||
self.markOutputAudioStarted(byteCount: data.count, nowMs: ProcessInfo.processInfo.systemUptime * 1000)
|
||||
self.markOutputAudioStarted(nowMs: ProcessInfo.processInfo.systemUptime * 1000)
|
||||
self.onSpeakingChanged(true)
|
||||
if self.outputContinuation == nil, self.outputTask != nil {
|
||||
self.pendingOutputChunks.append(data)
|
||||
return
|
||||
}
|
||||
self.ensureOutputPlaybackStarted()
|
||||
self.outputContinuation?.yield(data)
|
||||
case "audioDone":
|
||||
self.finishOutputPlaybackStream()
|
||||
case "clear":
|
||||
self.stopOutputPlayback()
|
||||
self.startOutputPlayback()
|
||||
case "transcript":
|
||||
self.handleTranscriptEvent(payload)
|
||||
case "toolCall":
|
||||
@@ -350,16 +337,11 @@ final class RealtimeTalkRelaySession {
|
||||
"talk realtime audio: chunks=\(self.outputAudioChunkCount) bytes=\(self.outputAudioByteCount)")
|
||||
}
|
||||
|
||||
private func markOutputAudioStarted(byteCount: Int, nowMs: Double) {
|
||||
private func markOutputAudioStarted(nowMs: Double) {
|
||||
if !self.isOutputPlaying {
|
||||
self.outputStartedAtMs = nowMs
|
||||
self.outputPlaybackExpectedEndMs = nowMs
|
||||
}
|
||||
self.isOutputPlaying = true
|
||||
let bytesPerSecond = max(1, self.outputSampleRateHz * Double(MemoryLayout<Int16>.size))
|
||||
let chunkDurationMs = (Double(byteCount) / bytesPerSecond) * 1000
|
||||
self.outputPlaybackExpectedEndMs = max(nowMs, self.outputPlaybackExpectedEndMs) + chunkDurationMs
|
||||
self.scheduleOutputPlaybackIdle(expectedEndMs: self.outputPlaybackExpectedEndMs)
|
||||
}
|
||||
|
||||
private func handleInputLevelDuringOutput(_ rms: Float, timestampMs: Double) {
|
||||
@@ -555,25 +537,14 @@ final class RealtimeTalkRelaySession {
|
||||
{ [weak self, audioSender = self.audioSender] encoded, timestampMs, rms in
|
||||
guard let audioSender else { return }
|
||||
Task {
|
||||
let shouldSend = await MainActor.run { [weak self] in
|
||||
guard let self, !self.isClosed else { return false }
|
||||
self.recordMicrophoneFrame(byteCount: encoded.count, rms: rms, timestampMs: timestampMs)
|
||||
self.refreshOutputPlaybackState(timestampMs: timestampMs)
|
||||
if self.isOutputPlaying {
|
||||
if self.shouldSuppressMicrophoneDuringOutput() {
|
||||
self.recordSuppressedOutputEchoFrame(
|
||||
byteCount: encoded.count,
|
||||
rms: rms,
|
||||
timestampMs: timestampMs)
|
||||
return false
|
||||
}
|
||||
if rms >= Self.bargeInRmsThreshold {
|
||||
self.handleInputLevelDuringOutput(rms, timestampMs: timestampMs)
|
||||
}
|
||||
}
|
||||
return true
|
||||
await MainActor.run { [weak self] in
|
||||
self?.recordMicrophoneFrame(byteCount: encoded.count, rms: rms, timestampMs: timestampMs)
|
||||
}
|
||||
if rms >= Self.bargeInRmsThreshold {
|
||||
await MainActor.run { [weak self] in
|
||||
self?.handleInputLevelDuringOutput(rms, timestampMs: timestampMs)
|
||||
}
|
||||
}
|
||||
guard shouldSend else { return }
|
||||
guard let message = await audioSender.send(encoded, timestampMs: timestampMs) else { return }
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self, !self.isClosed else { return }
|
||||
@@ -590,13 +561,6 @@ final class RealtimeTalkRelaySession {
|
||||
try self.audioEngine.start()
|
||||
}
|
||||
|
||||
private func shouldSuppressMicrophoneDuringOutput() -> Bool {
|
||||
let outputs = AVAudioSession.sharedInstance().currentRoute.outputs
|
||||
// Built-in speaker output bleeds into the microphone even in voiceChat mode; keep the
|
||||
// realtime provider from treating its own speech as user input. Headsets keep barge-in.
|
||||
return outputs.contains { $0.portType == .builtInSpeaker }
|
||||
}
|
||||
|
||||
private func recordMicrophoneFrame(byteCount: Int, rms: Float, timestampMs: Double) {
|
||||
guard !self.isClosed else { return }
|
||||
self.micLogFrameCount += 1
|
||||
@@ -612,31 +576,13 @@ final class RealtimeTalkRelaySession {
|
||||
self.micLogMaxRms = 0
|
||||
}
|
||||
|
||||
private func recordSuppressedOutputEchoFrame(byteCount: Int, rms: Float, timestampMs: Double) {
|
||||
self.suppressedEchoFrameCount += 1
|
||||
self.suppressedEchoByteCount += byteCount
|
||||
self.suppressedEchoMaxRms = max(self.suppressedEchoMaxRms, rms)
|
||||
guard timestampMs - self.lastSuppressedEchoLogAtMs >= 1000 else { return }
|
||||
self.lastSuppressedEchoLogAtMs = timestampMs
|
||||
let maxRms = String(format: "%.4f", Double(self.suppressedEchoMaxRms))
|
||||
GatewayDiagnostics.log(
|
||||
"talk realtime mic suppressed during output: "
|
||||
+ "buffers=\(self.suppressedEchoFrameCount) "
|
||||
+ "bytes=\(self.suppressedEchoByteCount) maxRms=\(maxRms)")
|
||||
self.suppressedEchoFrameCount = 0
|
||||
self.suppressedEchoByteCount = 0
|
||||
self.suppressedEchoMaxRms = 0
|
||||
}
|
||||
|
||||
private func stopMicrophonePump() {
|
||||
self.audioEngine.inputNode.removeTap(onBus: 0)
|
||||
self.audioEngine.stop()
|
||||
}
|
||||
|
||||
private func ensureOutputPlaybackStarted() {
|
||||
guard self.outputContinuation == nil, self.outputTask == nil else { return }
|
||||
self.outputSessionId += 1
|
||||
let sessionId = self.outputSessionId
|
||||
private func startOutputPlayback() {
|
||||
self.stopOutputPlayback()
|
||||
let stream = AsyncThrowingStream<Data, Error> { continuation in
|
||||
self.outputContinuation = continuation
|
||||
}
|
||||
@@ -644,95 +590,28 @@ final class RealtimeTalkRelaySession {
|
||||
guard let self else { return }
|
||||
let result = await self.pcmPlayer.play(stream: stream, sampleRate: self.outputSampleRateHz)
|
||||
await MainActor.run {
|
||||
guard self.outputSessionId == sessionId else { return }
|
||||
self.outputTask = nil
|
||||
self.outputContinuation = nil
|
||||
if !result.finished, let interruptedAt = result.interruptedAt {
|
||||
self.logger.info("realtime output interrupted at \(interruptedAt, privacy: .public)s")
|
||||
}
|
||||
self.markOutputPlaybackFinished()
|
||||
self.startPendingOutputPlaybackIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func finishOutputPlaybackStream() {
|
||||
guard let continuation = self.outputContinuation else {
|
||||
if self.outputTask != nil, !self.pendingOutputChunks.isEmpty {
|
||||
self.pendingOutputDone = true
|
||||
}
|
||||
return
|
||||
}
|
||||
continuation.finish()
|
||||
self.outputContinuation = nil
|
||||
}
|
||||
|
||||
private func startPendingOutputPlaybackIfNeeded() {
|
||||
guard !self.pendingOutputChunks.isEmpty else {
|
||||
self.pendingOutputDone = false
|
||||
return
|
||||
}
|
||||
let chunks = self.pendingOutputChunks
|
||||
let shouldFinish = self.pendingOutputDone
|
||||
self.pendingOutputChunks = []
|
||||
self.pendingOutputDone = false
|
||||
self.ensureOutputPlaybackStarted()
|
||||
for chunk in chunks {
|
||||
self.markOutputAudioStarted(byteCount: chunk.count, nowMs: ProcessInfo.processInfo.systemUptime * 1000)
|
||||
self.onSpeakingChanged(true)
|
||||
self.outputContinuation?.yield(chunk)
|
||||
}
|
||||
if shouldFinish {
|
||||
self.finishOutputPlaybackStream()
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleOutputPlaybackIdle(expectedEndMs: Double) {
|
||||
self.outputIdleTask?.cancel()
|
||||
let nowMs = ProcessInfo.processInfo.systemUptime * 1000
|
||||
let idleDelayMs = max(350, expectedEndMs - nowMs + 500)
|
||||
self.outputIdleTask = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: UInt64(idleDelayMs * 1_000_000))
|
||||
guard !Task.isCancelled else { return }
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self, !self.isClosed else { return }
|
||||
let nowMs = ProcessInfo.processInfo.systemUptime * 1000
|
||||
self.refreshOutputPlaybackState(timestampMs: nowMs, cancelIdleTask: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshOutputPlaybackState(timestampMs: Double, cancelIdleTask: Bool = true) {
|
||||
guard self.isOutputPlaying else { return }
|
||||
guard timestampMs >= self.outputPlaybackExpectedEndMs + 500 else { return }
|
||||
self.markOutputPlaybackFinished(cancelIdleTask: cancelIdleTask)
|
||||
}
|
||||
|
||||
private func markOutputPlaybackFinished(cancelIdleTask: Bool = true) {
|
||||
if cancelIdleTask {
|
||||
self.outputIdleTask?.cancel()
|
||||
self.outputIdleTask = nil
|
||||
}
|
||||
private func markOutputPlaybackFinished() {
|
||||
self.isOutputPlaying = false
|
||||
self.outputStartedAtMs = nil
|
||||
self.outputPlaybackExpectedEndMs = 0
|
||||
self.onSpeakingChanged(false)
|
||||
}
|
||||
|
||||
private func stopOutputPlayback() {
|
||||
self.outputSessionId += 1
|
||||
self.outputContinuation?.finish()
|
||||
self.outputContinuation = nil
|
||||
self.outputTask?.cancel()
|
||||
self.outputTask = nil
|
||||
self.outputIdleTask?.cancel()
|
||||
self.outputIdleTask = nil
|
||||
self.pendingOutputChunks = []
|
||||
self.pendingOutputDone = false
|
||||
_ = self.pcmPlayer.stop()
|
||||
self.isOutputPlaying = false
|
||||
self.outputStartedAtMs = nil
|
||||
self.outputPlaybackExpectedEndMs = 0
|
||||
self.onSpeakingChanged(false)
|
||||
}
|
||||
|
||||
@@ -805,7 +684,7 @@ final class RealtimeTalkRelaySession {
|
||||
|
||||
extension RealtimeTalkRelaySession {
|
||||
func _test_markOutputAudioStarted(nowMs: Double) {
|
||||
self.markOutputAudioStarted(byteCount: 4800, nowMs: nowMs)
|
||||
self.markOutputAudioStarted(nowMs: nowMs)
|
||||
}
|
||||
|
||||
func _test_markOutputPlaybackFinished() {
|
||||
|
||||
@@ -1141,7 +1141,7 @@ final class TalkModeManager: NSObject {
|
||||
})
|
||||
self.realtimeRelaySession = relaySession
|
||||
do {
|
||||
try Self.configureRealtimeAudioSession()
|
||||
try Self.configureAudioSession()
|
||||
try await relaySession.start()
|
||||
guard self.realtimeRelaySession === relaySession, self.isEnabled else {
|
||||
relaySession.stop()
|
||||
|
||||
@@ -13,7 +13,6 @@ Sources/Design/AgentProNodesDestination.swift
|
||||
Sources/Design/AgentProTab.swift
|
||||
Sources/Design/ChatProTab.swift
|
||||
Sources/Design/CommandCenterTab.swift
|
||||
Sources/Design/TalkProTab.swift
|
||||
Sources/Design/OpenClawProComponents.swift
|
||||
Sources/Design/OpenClawProScreens.swift
|
||||
Sources/Design/SettingsProTab.swift
|
||||
|
||||
@@ -29,14 +29,6 @@ def clear_empty_env_var(key)
|
||||
ENV.delete(key) unless env_present?(ENV[key])
|
||||
end
|
||||
|
||||
def screenshot_upload_requested?
|
||||
ENV["DELIVER_SCREENSHOTS"] == "1"
|
||||
end
|
||||
|
||||
def screenshot_paths
|
||||
Dir[File.join(__dir__, "screenshots", "**", "*.png")]
|
||||
end
|
||||
|
||||
def maybe_decode_hex_keychain_secret(value)
|
||||
return value unless env_present?(value)
|
||||
|
||||
@@ -322,7 +314,6 @@ platform :ios do
|
||||
desc "Upload App Store metadata (and optionally screenshots)"
|
||||
lane :metadata do
|
||||
sync_ios_versioning!
|
||||
version_metadata = read_ios_version_metadata
|
||||
api_key = asc_api_key
|
||||
clear_empty_env_var("APP_STORE_CONNECT_API_KEY_PATH")
|
||||
app_identifier = ENV["ASC_APP_IDENTIFIER"]
|
||||
@@ -330,21 +321,11 @@ platform :ios do
|
||||
app_identifier = nil unless env_present?(app_identifier)
|
||||
app_id = nil unless env_present?(app_id)
|
||||
|
||||
if screenshot_upload_requested? && screenshot_paths.empty?
|
||||
UI.user_error!("DELIVER_SCREENSHOTS=1 but no PNG screenshots were found under apps/ios/fastlane/screenshots.")
|
||||
end
|
||||
|
||||
deliver_options = {
|
||||
api_key: api_key,
|
||||
force: true,
|
||||
app_version: version_metadata[:short_version],
|
||||
copyright: "2026 OpenClaw",
|
||||
primary_category: "PRODUCTIVITY",
|
||||
secondary_category: "UTILITIES",
|
||||
skip_screenshots: !screenshot_upload_requested?,
|
||||
skip_screenshots: ENV["DELIVER_SCREENSHOTS"] != "1",
|
||||
skip_metadata: ENV["DELIVER_METADATA"] != "1",
|
||||
skip_binary_upload: true,
|
||||
overwrite_screenshots: screenshot_upload_requested?,
|
||||
run_precheck_before_submit: false
|
||||
}
|
||||
deliver_options[:app_identifier] = app_identifier if app_identifier
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
OpenClaw is a personal AI assistant you run on your own devices.
|
||||
|
||||
Pair this iPhone app with your OpenClaw Gateway to use your phone as a secure node for chat, voice, approvals, sharing, and device-aware automation.
|
||||
Pair this iPhone app with your OpenClaw Gateway to connect your phone as a secure node for voice, camera, and device automation.
|
||||
|
||||
What you can do:
|
||||
- Pair with your private OpenClaw Gateway by QR code or setup code
|
||||
- Chat with your assistant from iPhone
|
||||
- Use realtime Talk mode and push-to-talk
|
||||
- Review Gateway action approvals from your phone
|
||||
- Use voice wake and push-to-talk
|
||||
- Capture photos and short clips on request
|
||||
- Record screen snippets for troubleshooting and workflows
|
||||
- Share text, links, and media directly from iOS into OpenClaw
|
||||
- Enable device capabilities such as camera, screen, location, photos, contacts, calendar, and reminders when you choose
|
||||
- Receive push wakes and node status updates for connected workflows
|
||||
- Run location-aware and device-aware automations
|
||||
|
||||
OpenClaw is local-first: you control your gateway, keys, configuration, and permissions. Device access is managed by iOS permissions and can be enabled only for the capabilities you want to use.
|
||||
OpenClaw is local-first: you control your gateway, keys, and configuration.
|
||||
|
||||
Getting started:
|
||||
1) Set up your OpenClaw Gateway
|
||||
2) Open the iOS app and pair with your gateway
|
||||
3) Start using chat, Talk mode, approvals, and automations from your phone
|
||||
3) Start using commands and automations from your phone
|
||||
|
||||
@@ -1 +1 @@
|
||||
openclaw,ai assistant,local ai,iphone ai,voice assistant,automation,gateway,chat,agent
|
||||
openclaw,ai assistant,local ai,voice assistant,automation,gateway,chat,agent,node
|
||||
|
||||
@@ -1 +1 @@
|
||||
Pair your iPhone with your OpenClaw Gateway for chat, realtime voice, approvals, device capabilities, and private automation.
|
||||
Run OpenClaw from your iPhone: pair with your own gateway, trigger automations, and use voice, camera, and share actions.
|
||||
|
||||
@@ -1,5 +1 @@
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
- Added hosted push relay defaults, realtime Talk playback, and safer WebSocket ping handling for mobile sessions.
|
||||
- Updated App Store screenshots to cover Gateway pairing, Command, Chat, Talk, Agent, and Settings flows.
|
||||
- Highlighted realtime Talk relay, Gateway connection status, node capabilities, push wake, and privacy controls.
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "2026.5.30"
|
||||
"version": "2026.5.28"
|
||||
}
|
||||
|
||||
@@ -170,8 +170,6 @@ final class AppState {
|
||||
}
|
||||
}
|
||||
|
||||
var voiceWakeMeterActive = false
|
||||
|
||||
var talkEnabled: Bool {
|
||||
didSet {
|
||||
self.ifNotPreview {
|
||||
|
||||
@@ -44,5 +44,6 @@ let deepLinkKeyKey = "openclaw.deepLinkKey"
|
||||
let cliInstallPromptedVersionKey = "openclaw.cliInstallPromptedVersion"
|
||||
let heartbeatsEnabledKey = "openclaw.heartbeatsEnabled"
|
||||
let debugPaneEnabledKey = "openclaw.debugPaneEnabled"
|
||||
let debugFileLogEnabledKey = "openclaw.debug.fileLogEnabled"
|
||||
let appLogLevelKey = "openclaw.debug.appLogLevel"
|
||||
let voiceWakeSupported: Bool = ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user