mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 22:11:38 +08:00
Compare commits
4 Commits
codex/refa
...
docs/conte
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
716fbdad5d | ||
|
|
4f158ef917 | ||
|
|
8075e5e0b9 | ||
|
|
24b19b8624 |
380
.agent/workflows/update_clawdbot.md
Normal file
380
.agent/workflows/update_clawdbot.md
Normal file
@@ -0,0 +1,380 @@
|
||||
---
|
||||
description: Update Clawdbot from upstream when branch has diverged (ahead/behind)
|
||||
---
|
||||
|
||||
# Clawdbot Upstream Sync Workflow
|
||||
|
||||
Use this workflow when your fork has diverged from upstream (e.g., "18 commits ahead, 29 commits behind").
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
# Check divergence status
|
||||
git fetch upstream && git rev-list --left-right --count main...upstream/main
|
||||
|
||||
# Full sync (rebase preferred)
|
||||
git fetch upstream && git rebase upstream/main && pnpm install && pnpm build && ./scripts/restart-mac.sh
|
||||
|
||||
# Check for Swift 6.2 issues after sync
|
||||
grep -r "FileManager\.default\|Thread\.isMainThread" src/ apps/ --include="*.swift"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Assess Divergence
|
||||
|
||||
```bash
|
||||
git fetch upstream
|
||||
git log --oneline --left-right main...upstream/main | head -20
|
||||
```
|
||||
|
||||
This shows:
|
||||
|
||||
- `<` = your local commits (ahead)
|
||||
- `>` = upstream commits you're missing (behind)
|
||||
|
||||
**Decision point:**
|
||||
|
||||
- Few local commits, many upstream → **Rebase** (cleaner history)
|
||||
- Many local commits or shared branch → **Merge** (preserves history)
|
||||
|
||||
---
|
||||
|
||||
## Step 2A: Rebase Strategy (Preferred)
|
||||
|
||||
Replays your commits on top of upstream. Results in linear history.
|
||||
|
||||
```bash
|
||||
# Ensure working tree is clean
|
||||
git status
|
||||
|
||||
# Rebase onto upstream
|
||||
git rebase upstream/main
|
||||
```
|
||||
|
||||
### Handling Rebase Conflicts
|
||||
|
||||
```bash
|
||||
# When conflicts occur:
|
||||
# 1. Fix conflicts in the listed files
|
||||
# 2. Stage resolved files
|
||||
git add <resolved-files>
|
||||
|
||||
# 3. Continue rebase
|
||||
git rebase --continue
|
||||
|
||||
# If a commit is no longer needed (already in upstream):
|
||||
git rebase --skip
|
||||
|
||||
# To abort and return to original state:
|
||||
git rebase --abort
|
||||
```
|
||||
|
||||
### Common Conflict Patterns
|
||||
|
||||
| File | Resolution |
|
||||
| ---------------- | ------------------------------------------------ |
|
||||
| `package.json` | Take upstream deps, keep local scripts if needed |
|
||||
| `pnpm-lock.yaml` | Accept upstream, regenerate with `pnpm install` |
|
||||
| `*.patch` files | Usually take upstream version |
|
||||
| Source files | Merge logic carefully, prefer upstream structure |
|
||||
|
||||
---
|
||||
|
||||
## Step 2B: Merge Strategy (Alternative)
|
||||
|
||||
Preserves all history with a merge commit.
|
||||
|
||||
```bash
|
||||
git merge upstream/main --no-edit
|
||||
```
|
||||
|
||||
Resolve conflicts same as rebase, then:
|
||||
|
||||
```bash
|
||||
git add <resolved-files>
|
||||
git commit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Rebuild Everything
|
||||
|
||||
After sync completes:
|
||||
|
||||
```bash
|
||||
# Install dependencies (regenerates lock if needed)
|
||||
pnpm install
|
||||
|
||||
# Build TypeScript
|
||||
pnpm build
|
||||
|
||||
# Build UI assets
|
||||
pnpm ui:build
|
||||
|
||||
# Run diagnostics
|
||||
pnpm clawdbot doctor
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Rebuild macOS App
|
||||
|
||||
```bash
|
||||
# Full rebuild, sign, and launch
|
||||
./scripts/restart-mac.sh
|
||||
|
||||
# Or just package without restart
|
||||
pnpm mac:package
|
||||
```
|
||||
|
||||
### Install to /Applications
|
||||
|
||||
```bash
|
||||
# Kill running app
|
||||
pkill -x "Clawdbot" || true
|
||||
|
||||
# Move old version
|
||||
mv /Applications/Clawdbot.app /tmp/Clawdbot-backup.app
|
||||
|
||||
# Install new build
|
||||
cp -R dist/Clawdbot.app /Applications/
|
||||
|
||||
# Launch
|
||||
open /Applications/Clawdbot.app
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4A: Verify macOS App & Agent
|
||||
|
||||
After rebuilding the macOS app, always verify it works correctly:
|
||||
|
||||
```bash
|
||||
# Check gateway health
|
||||
pnpm clawdbot health
|
||||
|
||||
# Verify no zombie processes
|
||||
ps aux | grep -E "(clawdbot|gateway)" | grep -v grep
|
||||
|
||||
# Test agent functionality by sending a verification message
|
||||
pnpm clawdbot agent --message "Verification: macOS app rebuild successful - agent is responding." --session-id YOUR_TELEGRAM_SESSION_ID
|
||||
|
||||
# Confirm the message was received on Telegram
|
||||
# (Check your Telegram chat with the bot)
|
||||
```
|
||||
|
||||
**Important:** Always wait for the Telegram verification message before proceeding. If the agent doesn't respond, troubleshoot the gateway or model configuration before pushing.
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Handle Swift/macOS Build Issues (Common After Upstream Sync)
|
||||
|
||||
Upstream updates may introduce Swift 6.2 / macOS 26 SDK incompatibilities. Use analyze-mode for systematic debugging:
|
||||
|
||||
### Analyze-Mode Investigation
|
||||
|
||||
```bash
|
||||
# Gather context with parallel agents
|
||||
morph-mcp_warpgrep_codebase_search search_string="Find deprecated FileManager.default and Thread.isMainThread usages in Swift files" repo_path="/Volumes/Main SSD/Developer/clawdis"
|
||||
morph-mcp_warpgrep_codebase_search search_string="Locate Peekaboo submodule and macOS app Swift files with concurrency issues" repo_path="/Volumes/Main SSD/Developer/clawdis"
|
||||
```
|
||||
|
||||
### Common Swift 6.2 Fixes
|
||||
|
||||
**FileManager.default Deprecation:**
|
||||
|
||||
```bash
|
||||
# Search for deprecated usage
|
||||
grep -r "FileManager\.default" src/ apps/ --include="*.swift"
|
||||
|
||||
# Replace with proper initialization
|
||||
# OLD: FileManager.default
|
||||
# NEW: FileManager()
|
||||
```
|
||||
|
||||
**Thread.isMainThread Deprecation:**
|
||||
|
||||
```bash
|
||||
# Search for deprecated usage
|
||||
grep -r "Thread\.isMainThread" src/ apps/ --include="*.swift"
|
||||
|
||||
# Replace with modern concurrency check
|
||||
# OLD: Thread.isMainThread
|
||||
# NEW: await MainActor.run { ... } or DispatchQueue.main.sync { ... }
|
||||
```
|
||||
|
||||
### Peekaboo Submodule Fixes
|
||||
|
||||
```bash
|
||||
# Check Peekaboo for concurrency issues
|
||||
cd src/canvas-host/a2ui
|
||||
grep -r "Thread\.isMainThread\|FileManager\.default" . --include="*.swift"
|
||||
|
||||
# Fix and rebuild submodule
|
||||
cd /Volumes/Main SSD/Developer/clawdis
|
||||
pnpm canvas:a2ui:bundle
|
||||
```
|
||||
|
||||
### macOS App Concurrency Fixes
|
||||
|
||||
```bash
|
||||
# Check macOS app for issues
|
||||
grep -r "Thread\.isMainThread\|FileManager\.default" apps/macos/ --include="*.swift"
|
||||
|
||||
# Clean and rebuild after fixes
|
||||
cd apps/macos && rm -rf .build .swiftpm
|
||||
./scripts/restart-mac.sh
|
||||
```
|
||||
|
||||
### Model Configuration Updates
|
||||
|
||||
If upstream introduced new model configurations:
|
||||
|
||||
```bash
|
||||
# Check for OpenRouter API key requirements
|
||||
grep -r "openrouter\|OPENROUTER" src/ --include="*.ts" --include="*.js"
|
||||
|
||||
# Update clawdbot.json with fallback chains
|
||||
# Add model fallback configurations as needed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Verify & Push
|
||||
|
||||
```bash
|
||||
# Verify everything works
|
||||
pnpm clawdbot health
|
||||
pnpm test
|
||||
|
||||
# Push (force required after rebase)
|
||||
git push origin main --force-with-lease
|
||||
|
||||
# Or regular push after merge
|
||||
git push origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Build Fails After Sync
|
||||
|
||||
```bash
|
||||
# Clean and rebuild
|
||||
rm -rf node_modules dist
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### Type Errors (Bun/Node Incompatibility)
|
||||
|
||||
Common issue: `fetch.preconnect` type mismatch. Fix by using `FetchLike` type instead of `typeof fetch`.
|
||||
|
||||
### macOS App Crashes on Launch
|
||||
|
||||
Usually resource bundle mismatch. Full rebuild required:
|
||||
|
||||
```bash
|
||||
cd apps/macos && rm -rf .build .swiftpm
|
||||
./scripts/restart-mac.sh
|
||||
```
|
||||
|
||||
### Patch Failures
|
||||
|
||||
```bash
|
||||
# Check patch status
|
||||
pnpm install 2>&1 | grep -i patch
|
||||
|
||||
# If patches fail, they may need updating for new dep versions
|
||||
# Check patches/ directory against package.json patchedDependencies
|
||||
```
|
||||
|
||||
### Swift 6.2 / macOS 26 SDK Build Failures
|
||||
|
||||
**Symptoms:** Build fails with deprecation warnings about `FileManager.default` or `Thread.isMainThread`
|
||||
|
||||
**Search-Mode Investigation:**
|
||||
|
||||
```bash
|
||||
# Exhaustive search for deprecated APIs
|
||||
morph-mcp_warpgrep_codebase_search search_string="Find all Swift files using deprecated FileManager.default or Thread.isMainThread" repo_path="/Volumes/Main SSD/Developer/clawdis"
|
||||
```
|
||||
|
||||
**Quick Fix Commands:**
|
||||
|
||||
```bash
|
||||
# Find all affected files
|
||||
find . -name "*.swift" -exec grep -l "FileManager\.default\|Thread\.isMainThread" {} \;
|
||||
|
||||
# Replace FileManager.default with FileManager()
|
||||
find . -name "*.swift" -exec sed -i '' 's/FileManager\.default/FileManager()/g' {} \;
|
||||
|
||||
# For Thread.isMainThread, need manual review of each usage
|
||||
grep -rn "Thread\.isMainThread" --include="*.swift" .
|
||||
```
|
||||
|
||||
**Rebuild After Fixes:**
|
||||
|
||||
```bash
|
||||
# Clean all build artifacts
|
||||
rm -rf apps/macos/.build apps/macos/.swiftpm
|
||||
rm -rf src/canvas-host/a2ui/.build
|
||||
|
||||
# Rebuild Peekaboo bundle
|
||||
pnpm canvas:a2ui:bundle
|
||||
|
||||
# Full macOS rebuild
|
||||
./scripts/restart-mac.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Automation Script
|
||||
|
||||
Save as `scripts/sync-upstream.sh`:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "==> Fetching upstream..."
|
||||
git fetch upstream
|
||||
|
||||
echo "==> Current divergence:"
|
||||
git rev-list --left-right --count main...upstream/main
|
||||
|
||||
echo "==> Rebasing onto upstream/main..."
|
||||
git rebase upstream/main
|
||||
|
||||
echo "==> Installing dependencies..."
|
||||
pnpm install
|
||||
|
||||
echo "==> Building..."
|
||||
pnpm build
|
||||
pnpm ui:build
|
||||
|
||||
echo "==> Running doctor..."
|
||||
pnpm clawdbot doctor
|
||||
|
||||
echo "==> Rebuilding macOS app..."
|
||||
./scripts/restart-mac.sh
|
||||
|
||||
echo "==> Verifying gateway health..."
|
||||
pnpm clawdbot health
|
||||
|
||||
echo "==> Checking for Swift 6.2 compatibility issues..."
|
||||
if grep -r "FileManager\.default\|Thread\.isMainThread" src/ apps/ --include="*.swift" --quiet; then
|
||||
echo "⚠️ Found potential Swift 6.2 deprecated API usage"
|
||||
echo " Run manual fixes or use analyze-mode investigation"
|
||||
else
|
||||
echo "✅ No obvious Swift deprecation issues found"
|
||||
fi
|
||||
|
||||
echo "==> Testing agent functionality..."
|
||||
# Note: Update YOUR_TELEGRAM_SESSION_ID with actual session ID
|
||||
pnpm clawdbot agent --message "Verification: Upstream sync and macOS rebuild completed successfully." --session-id YOUR_TELEGRAM_SESSION_ID || echo "Warning: Agent test failed - check Telegram for verification message"
|
||||
|
||||
echo "==> Done! Check Telegram for verification message, then run 'git push --force-with-lease' when ready."
|
||||
```
|
||||
@@ -1,87 +0,0 @@
|
||||
---
|
||||
name: openclaw-ghsa-maintainer
|
||||
description: Maintainer workflow for OpenClaw GitHub Security Advisories (GHSA). Use when Codex needs to inspect, patch, validate, or publish a repo advisory, verify private-fork state, prepare advisory Markdown or JSON payloads safely, handle GHSA API-specific publish constraints, or confirm advisory publish success.
|
||||
---
|
||||
|
||||
# OpenClaw GHSA Maintainer
|
||||
|
||||
Use this skill for repo security advisory workflow only. Keep general release work in `openclaw-release-maintainer`.
|
||||
|
||||
## Respect advisory guardrails
|
||||
|
||||
- Before reviewing or publishing a repo advisory, read `SECURITY.md`.
|
||||
- Ask permission before any publish action.
|
||||
- Treat this skill as GHSA-only. Do not use it for stable or beta release work.
|
||||
|
||||
## Fetch and inspect advisory state
|
||||
|
||||
Fetch the current advisory and the latest published npm version:
|
||||
|
||||
```bash
|
||||
gh api /repos/openclaw/openclaw/security-advisories/<GHSA>
|
||||
npm view openclaw version --userconfig "$(mktemp)"
|
||||
```
|
||||
|
||||
Use the fetch output to confirm the advisory state, linked private fork, and vulnerability payload shape before patching.
|
||||
|
||||
## Verify private fork PRs are closed
|
||||
|
||||
Before publishing, verify that the advisory's private fork has no open PRs:
|
||||
|
||||
```bash
|
||||
fork=$(gh api /repos/openclaw/openclaw/security-advisories/<GHSA> | jq -r .private_fork.full_name)
|
||||
gh pr list -R "$fork" --state open
|
||||
```
|
||||
|
||||
The PR list must be empty before publish.
|
||||
|
||||
## Prepare advisory Markdown and JSON safely
|
||||
|
||||
- Write advisory Markdown via heredoc to a temp file. Do not use escaped `\n` strings.
|
||||
- Build PATCH payload JSON with `jq`, not hand-escaped shell JSON.
|
||||
|
||||
Example pattern:
|
||||
|
||||
```bash
|
||||
cat > /tmp/ghsa.desc.md <<'EOF'
|
||||
<markdown description>
|
||||
EOF
|
||||
|
||||
jq -n --rawfile desc /tmp/ghsa.desc.md \
|
||||
'{summary,severity,description:$desc,vulnerabilities:[...]}' \
|
||||
> /tmp/ghsa.patch.json
|
||||
```
|
||||
|
||||
## Apply PATCH calls in the correct sequence
|
||||
|
||||
- Do not set `severity` and `cvss_vector_string` in the same PATCH call.
|
||||
- Use separate calls when the advisory requires both fields.
|
||||
- Publish by PATCHing the advisory and setting `"state":"published"`. There is no separate `/publish` endpoint.
|
||||
|
||||
Example shape:
|
||||
|
||||
```bash
|
||||
gh api -X PATCH /repos/openclaw/openclaw/security-advisories/<GHSA> \
|
||||
--input /tmp/ghsa.patch.json
|
||||
```
|
||||
|
||||
## Publish and verify success
|
||||
|
||||
After publish, re-fetch the advisory and confirm:
|
||||
|
||||
- `state=published`
|
||||
- `published_at` is set
|
||||
- the description does not contain literal escaped `\\n`
|
||||
|
||||
Verification pattern:
|
||||
|
||||
```bash
|
||||
gh api /repos/openclaw/openclaw/security-advisories/<GHSA>
|
||||
jq -r .description < /tmp/ghsa.refetch.json | rg '\\\\n'
|
||||
```
|
||||
|
||||
## Common GHSA footguns
|
||||
|
||||
- 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.
|
||||
@@ -1,103 +0,0 @@
|
||||
---
|
||||
name: openclaw-parallels-smoke
|
||||
description: End-to-end Parallels smoke, upgrade, and rerun workflow for OpenClaw across macOS, Windows, and Linux guests. Use when Codex needs to run, rerun, debug, or interpret VM-based install, onboarding, gateway smoke tests, latest-release-to-main upgrade checks, fresh snapshot retests, or optional Discord roundtrip verification under Parallels.
|
||||
---
|
||||
|
||||
# OpenClaw Parallels Smoke
|
||||
|
||||
Use this skill for Parallels guest workflows and smoke interpretation. Do not load it for normal repo work.
|
||||
|
||||
## Global rules
|
||||
|
||||
- Use the snapshot most closely matching the requested fresh baseline.
|
||||
- Gateway verification in smoke runs should use `openclaw gateway status --deep --require-rpc` unless the stable version being checked does not support it yet.
|
||||
- Stable `2026.3.12` pre-upgrade diagnostics may require a plain `gateway status --deep` fallback.
|
||||
- Treat `precheck=latest-ref-fail` on that stable pre-upgrade lane as baseline, not automatically a regression.
|
||||
- Pass `--json` for machine-readable summaries.
|
||||
- Per-phase logs land under `/tmp/openclaw-parallels-*`.
|
||||
- Do not run local and gateway agent turns in parallel on the same fresh workspace or session.
|
||||
- If `main` is moving under active multi-agent work, prefer a detached worktree pinned to one commit for long Parallels suites. The smoke scripts now verify the packed tgz commit instead of live `git rev-parse HEAD`, but a pinned worktree still avoids noisy rebuild/version drift during reruns.
|
||||
- For `prlctl exec`, pass the VM name before `--current-user` (`prlctl exec "$VM" --current-user ...`), not the other way around.
|
||||
- If the workflow installs OpenClaw from a repo checkout instead of the site installer/npm release, finish by installing a real guest CLI shim and verifying it in a fresh guest shell. `pnpm openclaw ...` inside the repo is not enough for handoff parity.
|
||||
- On macOS guests, prefer a user-global install plus a stable PATH-visible shim:
|
||||
- install with `NPM_CONFIG_PREFIX="$HOME/.npm-global" npm install -g .`
|
||||
- make sure `~/.local/bin/openclaw` exists or `~/.npm-global/bin` is on PATH
|
||||
- verify from a brand-new guest shell with `which openclaw` and `openclaw --version`
|
||||
|
||||
## npm install then update
|
||||
|
||||
- Preferred entrypoint: `pnpm test:parallels:npm-update`
|
||||
- Flow: fresh snapshot -> install npm package baseline -> smoke -> install current main tgz on the same guest -> smoke again.
|
||||
- Same-guest update verification should set the default model explicitly to `openai/gpt-5.4` before the agent turn and use a fresh explicit `--session-id` so old session model state does not leak into the check.
|
||||
- The aggregate npm-update wrapper must resolve the Linux VM with the same Ubuntu fallback policy as `parallels-linux-smoke.sh` before both fresh and update lanes. Treat any Ubuntu guest with major version `>= 24` as acceptable when the exact default VM is missing, preferring the closest version match. On Peter's current host today, missing `Ubuntu 24.04.3 ARM64` should fall back to `Ubuntu 25.10`.
|
||||
- On macOS same-guest update checks, restart the gateway after the npm upgrade before `gateway status` / `agent`; launchd can otherwise report a loaded service while the old process has exited and the fresh process is not RPC-ready yet.
|
||||
- On Windows same-guest update checks, restart the gateway after the npm upgrade before `gateway status` / `agent`; in-place global npm updates can otherwise leave stale hashed `dist/*` module imports alive in the running service.
|
||||
- For Windows same-guest update checks, prefer the done-file/log-drain PowerShell runner pattern over one long-lived `prlctl exec ... powershell -EncodedCommand ...` transport. The guest can finish successfully while the outer `prlctl exec` still hangs.
|
||||
- The Windows same-guest update helper should write stage markers to its log before long steps like tgz download and `npm install -g` so the outer progress monitor does not sit on `waiting for first log line` during healthy but quiet installs.
|
||||
- Linux same-guest update verification should also export `HOME=/root`, pass `OPENAI_API_KEY` via `prlctl exec ... /usr/bin/env`, and use `openclaw agent --local`; the fresh Linux baseline does not rely on persisted gateway credentials.
|
||||
- The npm-update wrapper now prints per-lane progress from the nested log files. If a lane still looks stuck, inspect the nested logs in `runDir` first (`macos-fresh.log`, `windows-fresh.log`, `linux-fresh.log`, `macos-update.log`, `windows-update.log`, `linux-update.log`) instead of assuming the outer wrapper hung.
|
||||
- If the wrapper fails a lane, read the auto-dumped tail first, then the full nested lane log under `/tmp/openclaw-parallels-npm-update.*`.
|
||||
|
||||
## CLI invocation footgun
|
||||
|
||||
- The Parallels smoke shell scripts should tolerate a literal bare `--` arg so `pnpm test:parallels:* -- --json` and similar forwarded invocations work without needing to call `bash scripts/e2e/...` directly.
|
||||
|
||||
## macOS flow
|
||||
|
||||
- Preferred entrypoint: `pnpm test:parallels:macos`
|
||||
- Default to the snapshot closest to `macOS 26.3.1 latest`.
|
||||
- On Peter's Tahoe VM, `fresh-latest-march-2026` can hang in `prlctl snapshot-switch`; if restore times out there, rerun with `--snapshot-hint 'macOS 26.3.1 latest'` before blaming auth or the harness.
|
||||
- `parallels-macos-smoke.sh` now retries `snapshot-switch` once after force-stopping a stuck running/suspended guest. If Tahoe still times out after that recovery path, then treat it as a real Parallels/host issue and rerun manually.
|
||||
- The macOS smoke should include a dashboard load phase after gateway health: resolve the tokenized URL with `openclaw dashboard --no-open`, verify the served HTML contains the Control UI title/root shell, then open Safari and require an established localhost TCP connection from Safari to the gateway port.
|
||||
- If a packaged install regresses with `500` on `/`, `/healthz`, or `__openclaw/control-ui-config.json` after `fresh.install-main` or `upgrade.install-main`, suspect bundled plugin runtime deps resolving from the package root `node_modules` rather than `dist/extensions/*/node_modules`. Repro quickly with a real `npm pack`/global install lane before blaming dashboard auth or Safari.
|
||||
- `prlctl exec` is fine for deterministic repo commands, but use the guest Terminal or `prlctl enter` when installer parity or shell-sensitive behavior matters.
|
||||
- Multi-word `openclaw agent --message ...` checks should go through a guest shell wrapper (`guest_current_user_sh` / `guest_current_user_cli` or `/bin/sh -lc ...`), not raw `prlctl exec ... node openclaw.mjs ...`, or the message can be split into extra argv tokens and Commander reports `too many arguments for 'agent'`.
|
||||
- When ref-mode onboarding stores `OPENAI_API_KEY` as an env secret ref, the post-onboard agent verification should also export `OPENAI_API_KEY` for the guest command. The gateway can still reject with pairing-required and fall back to embedded execution, and that fallback needs the env-backed credential available in the shell.
|
||||
- On the fresh Tahoe snapshot, `brew` exists but `node` may be missing from PATH in noninteractive exec. Use `/opt/homebrew/bin/node` when needed.
|
||||
- Fresh host-served tgz installs should install as guest root with `HOME=/var/root`, then run onboarding as the desktop user via `prlctl exec --current-user`.
|
||||
- Root-installed tgz smoke can log plugin blocks for world-writable `extensions/*`; do not treat that as an onboarding or gateway failure unless plugin loading is the task.
|
||||
|
||||
## Windows flow
|
||||
|
||||
- Preferred entrypoint: `pnpm test:parallels:windows`
|
||||
- Use the snapshot closest to `pre-openclaw-native-e2e-2026-03-12`.
|
||||
- Always use `prlctl exec --current-user`; plain `prlctl exec` lands in `NT AUTHORITY\\SYSTEM`.
|
||||
- Prefer explicit `npm.cmd` and `openclaw.cmd`.
|
||||
- Use PowerShell only as the transport with `-ExecutionPolicy Bypass`, then call the `.cmd` shims from inside it.
|
||||
- Multi-word `openclaw agent --message ...` checks should call `& $openclaw ...` inside PowerShell, not `Start-Process ... -ArgumentList` against `openclaw.cmd`, or Commander can see split argv and throw `too many arguments for 'agent'`.
|
||||
- Windows installer/tgz phases now retry once after guest-ready recheck; keep new Windows smoke steps idempotent so a transport-flake retry is safe.
|
||||
- If a Windows retry sees the VM become `suspended` or `stopped`, resume/start it before the next `prlctl exec`; otherwise the second attempt just repeats the same `rc=255`.
|
||||
- Windows global `npm install -g` phases can stay quiet for a minute or more even when healthy; inspect the phase log before calling it hung, and only treat it as a regression once the retry wrapper or timeout trips.
|
||||
- Fresh Windows tgz install phases should also use the background PowerShell runner plus done-file/log-drain pattern; do not rely on one long-lived `prlctl exec ... powershell ... npm install -g` transport for package installs.
|
||||
- Fresh Windows ref-mode onboard should use the same background PowerShell runner plus done-file/log-drain pattern as the npm-update helper, including startup materialization checks, host-side timeouts on short poll `prlctl exec` calls, and retry-on-poll-failure behavior for transient transport flakes.
|
||||
- Fresh Windows daemon-health reachability should use a hello-only gateway probe and a longer per-probe timeout than the default local attach path; full health RPCs are too eager during initial startup on current main.
|
||||
- Fresh Windows ref-mode agent verification should set `OPENAI_API_KEY` in the PowerShell environment before invoking `openclaw.cmd agent`, for the same pairing-required fallback reason as macOS.
|
||||
- The standalone Windows upgrade smoke lane should stop the managed gateway after `upgrade.install-main` and before `upgrade.onboard-ref`. Restarting before onboard can leave the old process alive on the pre-onboard token while onboard rewrites `~/.openclaw/openclaw.json`, which then fails `gateway-health` with `unauthorized: gateway token mismatch`.
|
||||
- If standalone Windows upgrade fails with a gateway token mismatch but `pnpm test:parallels:npm-update` passes, trust the mismatch as a standalone ref-onboard ordering bug first; the npm-update helper does not re-run ref-mode onboard on the same guest.
|
||||
- Keep onboarding and status output ASCII-clean in logs; fancy punctuation becomes mojibake in current capture paths.
|
||||
- If you hit an older run with `rc=255` plus an empty `fresh.install-main.log` or `upgrade.install-main.log`, treat it as a likely `prlctl exec` transport drop after guest start-up, not immediate proof of an npm/package failure.
|
||||
|
||||
## Linux flow
|
||||
|
||||
- Preferred entrypoint: `pnpm test:parallels:linux`
|
||||
- Use the snapshot closest to fresh `Ubuntu 24.04.3 ARM64`.
|
||||
- If that exact VM is missing on the host, any Ubuntu guest with major version `>= 24` is acceptable; prefer the closest versioned Ubuntu guest with a fresh poweroff snapshot. On Peter's host today, that is `Ubuntu 25.10`.
|
||||
- Use plain `prlctl exec`; `--current-user` is not the right transport on this snapshot.
|
||||
- Fresh snapshots may be missing `curl`, and `apt-get update` can fail on clock skew. Bootstrap with `apt-get -o Acquire::Check-Date=false update` and install `curl ca-certificates`.
|
||||
- Fresh `main` tgz smoke still needs the latest-release installer first because the snapshot has no Node or npm before bootstrap.
|
||||
- This snapshot does not have a usable `systemd --user` session; managed daemon install is unsupported.
|
||||
- The Linux smoke now falls back to a manual `setsid openclaw gateway run --bind loopback --port 18789 --force` launch with `HOME=/root` and the provider secret exported, then verifies `gateway status --deep --require-rpc` when available.
|
||||
- The Linux manual gateway launch should wait for `gateway status --deep --require-rpc` inside the `gateway-start` phase; otherwise the first status probe can race the background bind and fail a healthy lane.
|
||||
- If Linux gateway bring-up fails, inspect `/tmp/openclaw-parallels-linux-gateway.log` in the guest phase logs first; the common failure mode is a missing provider secret in the launched gateway environment.
|
||||
|
||||
## Discord roundtrip
|
||||
|
||||
- Discord roundtrip is optional and should be enabled with:
|
||||
- `--discord-token-env`
|
||||
- `--discord-guild-id`
|
||||
- `--discord-channel-id`
|
||||
- Keep the Discord token only in a host env var.
|
||||
- Use installed `openclaw message send/read`, not `node openclaw.mjs message ...`.
|
||||
- Set `channels.discord.guilds` as one JSON object, not dotted config paths with snowflakes.
|
||||
- Avoid long `prlctl enter` or expect-driven Discord config scripts; prefer `prlctl exec --current-user /bin/sh -lc ...` with short commands.
|
||||
- For a narrower macOS-only Discord proof run, the existing `parallels-discord-roundtrip` skill is the deep-dive companion.
|
||||
@@ -1,75 +0,0 @@
|
||||
---
|
||||
name: openclaw-pr-maintainer
|
||||
description: Maintainer workflow for reviewing, triaging, preparing, closing, or landing OpenClaw pull requests and related issues. Use when Codex needs to validate bug-fix claims, search for related issues or PRs, apply or recommend close/reason labels, prepare GitHub comments safely, check review-thread follow-up, or perform maintainer-style PR decision making before merge or closure.
|
||||
---
|
||||
|
||||
# OpenClaw PR Maintainer
|
||||
|
||||
Use this skill for maintainer-facing GitHub workflow, not for ordinary code changes.
|
||||
|
||||
## Apply close and triage labels correctly
|
||||
|
||||
- If an issue or PR matches an auto-close reason, apply the label and let `.github/workflows/auto-response.yml` handle the comment/close/lock flow.
|
||||
- Do not manually close plus manually comment for these reasons.
|
||||
- `r:*` labels can be used on both issues and PRs.
|
||||
- Current reasons:
|
||||
- `r: skill`
|
||||
- `r: support`
|
||||
- `r: no-ci-pr`
|
||||
- `r: too-many-prs`
|
||||
- `r: testflight`
|
||||
- `r: third-party-extension`
|
||||
- `r: moltbook`
|
||||
- `r: spam`
|
||||
- `invalid`
|
||||
- `dirty` for PRs only
|
||||
|
||||
## Enforce the bug-fix evidence bar
|
||||
|
||||
- Never merge a bug-fix PR based only on issue text, PR text, or AI rationale.
|
||||
- Before landing, require:
|
||||
1. symptom evidence such as a repro, logs, or a failing test
|
||||
2. a verified root cause in code with file/line
|
||||
3. a fix that touches the implicated code path
|
||||
4. a regression test when feasible, or explicit manual verification plus a reason no test was added
|
||||
- If the claim is unsubstantiated or likely wrong, request evidence or changes instead of merging.
|
||||
- If the linked issue appears outdated or incorrect, correct triage first. Do not merge a speculative fix.
|
||||
|
||||
## Handle GitHub text safely
|
||||
|
||||
- For issue comments and PR comments, use literal multiline strings or `-F - <<'EOF'` for real newlines. Never embed `\n`.
|
||||
- Do not use `gh issue/pr comment -b "..."` when the body contains backticks or shell characters. Prefer a single-quoted heredoc.
|
||||
- Do not wrap issue or PR refs like `#24643` in backticks when you want auto-linking.
|
||||
- PR landing comments should include clickable full commit links for landed and source SHAs when present.
|
||||
|
||||
## Search broadly before deciding
|
||||
|
||||
- Prefer targeted keyword search before proposing new work or closing something as duplicate.
|
||||
- Use `--repo openclaw/openclaw` with `--match title,body` first.
|
||||
- Add `--match comments` when triaging follow-up discussion.
|
||||
- Do not stop at the first 500 results when the task requires a full search.
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
gh search prs --repo openclaw/openclaw --match title,body --limit 50 -- "auto-update"
|
||||
gh search issues --repo openclaw/openclaw --match title,body --limit 50 -- "auto-update"
|
||||
gh search issues --repo openclaw/openclaw --match title,body --limit 50 \
|
||||
--json number,title,state,url,updatedAt -- "auto update" \
|
||||
--jq '.[] | "\(.number) | \(.state) | \(.title) | \(.url)"'
|
||||
```
|
||||
|
||||
## Follow PR review and landing hygiene
|
||||
|
||||
- If bot review conversations exist on your PR, address them and resolve them yourself once fixed.
|
||||
- Leave a review conversation unresolved only when reviewer or maintainer judgment is still needed.
|
||||
- When landing or merging any PR, follow the global `/landpr` process.
|
||||
- Use `scripts/committer "<msg>" <file...>` for scoped commits instead of manual `git add` and `git commit`.
|
||||
- Keep commit messages concise and action-oriented.
|
||||
- Group related changes; avoid bundling unrelated refactors.
|
||||
- Use `.github/pull_request_template.md` for PR submissions and `.github/ISSUE_TEMPLATE/` for issues.
|
||||
|
||||
## Extra safety
|
||||
|
||||
- If a close or reopen action would affect more than 5 PRs, ask for explicit confirmation with the exact count and target query first.
|
||||
- `sync` means: if the tree is dirty, commit all changes with a sensible Conventional Commit message, then `git pull --rebase`, then `git push`. Stop if rebase conflicts cannot be resolved safely.
|
||||
@@ -1,267 +0,0 @@
|
||||
---
|
||||
name: openclaw-release-maintainer
|
||||
description: Maintainer workflow for OpenClaw releases, prereleases, changelog release notes, and publish validation. Use when Codex needs to prepare or verify stable or beta release steps, align version naming, assemble release notes, check release auth requirements, or validate publish-time commands and artifacts.
|
||||
---
|
||||
|
||||
# OpenClaw Release Maintainer
|
||||
|
||||
Use this skill for release and publish-time workflow. Keep ordinary development changes and GHSA-specific advisory work outside this skill.
|
||||
|
||||
## Respect release guardrails
|
||||
|
||||
- Do not change version numbers without explicit operator approval.
|
||||
- Ask permission before any npm publish or release step.
|
||||
- This skill should be sufficient to drive the normal release flow end-to-end.
|
||||
- Use the private maintainer release docs for credentials, recovery steps, and mac signing/notary specifics, and use `docs/reference/RELEASING.md` for public policy.
|
||||
- Core `openclaw` publish is manual `workflow_dispatch`; creating or pushing a tag does not publish by itself.
|
||||
|
||||
## Keep release channel naming aligned
|
||||
|
||||
- `stable`: tagged releases only, published to npm `beta` by default; operators may target npm `latest` explicitly or promote later
|
||||
- `beta`: prerelease tags like `vYYYY.M.D-beta.N`, with npm dist-tag `beta`
|
||||
- Prefer `-beta.N`; do not mint new `-1` or `-2` beta suffixes
|
||||
- `dev`: moving head on `main`
|
||||
- When using a beta Git tag, publish npm with the matching beta version suffix so the plain version is not consumed or blocked
|
||||
|
||||
## Handle versions and release files consistently
|
||||
|
||||
- Version locations include:
|
||||
- `package.json`
|
||||
- `apps/android/app/build.gradle.kts`
|
||||
- `apps/ios/Sources/Info.plist`
|
||||
- `apps/ios/Tests/Info.plist`
|
||||
- `apps/macos/Sources/OpenClaw/Resources/Info.plist`
|
||||
- `docs/install/updating.md`
|
||||
- Peekaboo Xcode project and plist version fields
|
||||
- Before creating a release tag, make every version location above match the version encoded by that tag.
|
||||
- For fallback correction tags like `vYYYY.M.D-N`, the repo version locations still stay at `YYYY.M.D`.
|
||||
- “Bump version everywhere” means all version locations above except `appcast.xml`.
|
||||
- Release signing and notary credentials live outside the repo in the private maintainer docs.
|
||||
- Every OpenClaw release ships the npm package and macOS app together.
|
||||
- The production Sparkle feed lives at `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`, and the canonical published file is `appcast.xml` on `main` in the `openclaw` repo.
|
||||
- That shared production Sparkle feed is stable-only. Beta mac releases may
|
||||
upload assets to the GitHub prerelease, but they must not replace the shared
|
||||
`appcast.xml` unless a separate beta feed exists.
|
||||
- For fallback correction tags like `vYYYY.M.D-N`, the repo version still stays
|
||||
at `YYYY.M.D`, but the mac release must use a strictly higher numeric
|
||||
`APP_BUILD` / Sparkle build than the original release so existing installs
|
||||
see it as newer.
|
||||
|
||||
## Build changelog-backed release notes
|
||||
|
||||
- Changelog entries should be user-facing, not internal release-process notes.
|
||||
- When cutting a mac release with a beta GitHub prerelease:
|
||||
- tag `vYYYY.M.D-beta.N` from the release commit
|
||||
- create a prerelease titled `openclaw YYYY.M.D-beta.N`
|
||||
- use release notes from the matching `CHANGELOG.md` version section
|
||||
- attach at least the zip and dSYM zip, plus dmg if available
|
||||
- Keep the top version entries in `CHANGELOG.md` sorted by impact:
|
||||
- `### Changes` first
|
||||
- `### Fixes` deduped with user-facing fixes first
|
||||
|
||||
## Run publish-time validation
|
||||
|
||||
Before tagging or publishing, run:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
pnpm ui:build
|
||||
pnpm release:check
|
||||
pnpm test:install:smoke
|
||||
```
|
||||
|
||||
For a non-root smoke path:
|
||||
|
||||
```bash
|
||||
OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke
|
||||
```
|
||||
|
||||
After npm publish, run:
|
||||
|
||||
```bash
|
||||
node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
```
|
||||
|
||||
- This verifies the published registry install path in a fresh temp prefix.
|
||||
- For stable correction releases like `YYYY.M.D-N`, it also verifies the
|
||||
upgrade path from `YYYY.M.D` to `YYYY.M.D-N` so a correction publish cannot
|
||||
silently leave existing global installs on the old base stable payload.
|
||||
|
||||
## Check all relevant release builds
|
||||
|
||||
- Always validate the OpenClaw npm release path before creating the tag.
|
||||
- Default release checks:
|
||||
- `pnpm check`
|
||||
- `pnpm build`
|
||||
- `pnpm ui:build`
|
||||
- `pnpm release:check`
|
||||
- `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke`
|
||||
- Check all release-related build surfaces touched by the release, not only the npm package.
|
||||
- Include mac release readiness in preflight by running the public validation
|
||||
workflow in `openclaw/openclaw` and the real mac preflight in
|
||||
`openclaw/releases-private` for every release.
|
||||
- Treat the `appcast.xml` update on `main` as part of mac release readiness, not an optional follow-up.
|
||||
- The workflows remain tag-based. The agent is responsible for making sure
|
||||
preflight runs complete successfully before any publish run starts.
|
||||
- Any fix after preflight means a new commit. Delete and recreate the tag and
|
||||
matching GitHub release from the fixed commit, then rerun preflight from
|
||||
scratch before publishing.
|
||||
- For stable mac releases, generate the signed `appcast.xml` before uploading
|
||||
public release assets so the updater feed cannot lag the published binaries.
|
||||
- Serialize stable appcast-producing runs across tags so two releases do not
|
||||
generate replacement `appcast.xml` files from the same stale seed.
|
||||
- For stable releases, confirm the latest beta already passed the broader release workflows before cutting stable.
|
||||
- If any required build, packaging step, or release workflow is red, do not say the release is ready.
|
||||
|
||||
## Use the right auth flow
|
||||
|
||||
- OpenClaw publish uses GitHub trusted publishing.
|
||||
- Stable npm promotion from `beta` to `latest` is an explicit mode on
|
||||
`.github/workflows/openclaw-npm-release.yml`, but it still needs a valid
|
||||
`NPM_TOKEN` because `npm dist-tag` management is separate from trusted
|
||||
publishing.
|
||||
- The publish run must be started manually with `workflow_dispatch`.
|
||||
- The npm workflow and the private mac publish workflow accept
|
||||
`preflight_only=true` to run validation/build/package steps without uploading
|
||||
public release assets.
|
||||
- Real npm publish requires a prior successful npm preflight run id so the
|
||||
publish job promotes the prepared tarball instead of rebuilding it.
|
||||
- Real private mac publish requires a prior successful private mac preflight
|
||||
run id so the publish job promotes the prepared artifacts instead of
|
||||
rebuilding or renotarizing them again.
|
||||
- The private mac workflow also accepts `smoke_test_only=true` for branch-safe
|
||||
workflow smoke tests that use ad-hoc signing, skip notarization, skip shared
|
||||
appcast generation, and do not prove release readiness.
|
||||
- `preflight_only=true` on the npm workflow is also the right way to validate an
|
||||
existing tag after publish; it should keep running the build checks even when
|
||||
the npm version is already published.
|
||||
- Validation-only runs may be dispatched from a branch when you are testing a
|
||||
workflow change before merge.
|
||||
- `.github/workflows/macos-release.yml` in `openclaw/openclaw` is now a
|
||||
public validation-only handoff. It validates the tag/release state and points
|
||||
operators to the private repo. It still rebuilds the JS outputs needed for
|
||||
release validation, but it does not sign, notarize, or publish macOS
|
||||
artifacts.
|
||||
- `openclaw/releases-private/.github/workflows/openclaw-macos-validate.yml`
|
||||
is the required private mac validation lane for `swift test`; keep it green
|
||||
before any real mac publish run starts.
|
||||
- Real mac preflight and real mac publish both use
|
||||
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml`.
|
||||
- The private mac validation lane runs on GitHub's standard macOS runner.
|
||||
- The private mac preflight path runs on GitHub's xlarge macOS runner and uses
|
||||
a SwiftPM cache because the build/sign/notarize/package path is CPU-heavy.
|
||||
- Private mac preflight uploads notarized build artifacts as workflow artifacts
|
||||
instead of uploading public GitHub release assets.
|
||||
- Private smoke-test runs upload ad-hoc, non-notarized build artifacts as
|
||||
workflow artifacts and intentionally skip stable `appcast.xml` generation.
|
||||
- npm preflight, public mac validation, private mac validation, and private mac
|
||||
preflight must all pass before any real publish run starts.
|
||||
- Real publish runs must be dispatched from `main`; branch-dispatched publish
|
||||
attempts should fail before the protected environment is reached.
|
||||
- The release workflows stay tag-based; rely on the documented release sequence
|
||||
rather than workflow-level SHA pinning.
|
||||
- The `npm-release` environment must be approved by `@openclaw/openclaw-release-managers` before publish continues.
|
||||
- Mac publish uses
|
||||
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml` for
|
||||
private mac preflight artifact preparation and real publish artifact
|
||||
promotion.
|
||||
- Real private mac publish uploads the packaged `.zip`, `.dmg`, and
|
||||
`.dSYM.zip` assets to the existing GitHub release in `openclaw/openclaw`
|
||||
automatically when `OPENCLAW_PUBLIC_REPO_RELEASE_TOKEN` is present in the
|
||||
private repo `mac-release` environment.
|
||||
- For stable releases, the agent must also download the signed
|
||||
`macos-appcast-<tag>` artifact from the successful private mac workflow and
|
||||
then update `appcast.xml` on `main`.
|
||||
- For beta mac releases, do not update the shared production `appcast.xml`
|
||||
unless a separate beta Sparkle feed exists.
|
||||
- The private repo targets a dedicated `mac-release` environment. If the GitHub
|
||||
plan does not yet support required reviewers there, do not assume the
|
||||
environment alone is the approval boundary; rely on private repo access and
|
||||
CODEOWNERS until those settings can be enabled.
|
||||
- Do not use `NPM_TOKEN` or the plugin OTP flow for OpenClaw releases.
|
||||
- `@openclaw/*` plugin publishes use a separate maintainer-only flow.
|
||||
- Only publish plugins that already exist on npm; bundled disk-tree-only plugins stay unpublished.
|
||||
|
||||
## Fallback local mac publish
|
||||
|
||||
- Keep the original local macOS publish workflow available as a fallback in case
|
||||
CI/CD mac publishing is unavailable or broken.
|
||||
- Preserve the existing maintainer workflow Peter uses: run it on a real Mac
|
||||
with local signing, notary, and Sparkle credentials already configured.
|
||||
- Follow the private maintainer macOS runbook for the local steps:
|
||||
`scripts/package-mac-dist.sh` to build, sign, notarize, and package the app;
|
||||
manual GitHub release asset upload; then `scripts/make_appcast.sh` plus the
|
||||
`appcast.xml` commit to `main`.
|
||||
- `scripts/package-mac-dist.sh` now fails closed for release builds if the
|
||||
bundled app comes out with a debug bundle id, an empty Sparkle feed URL, or a
|
||||
`CFBundleVersion` below the canonical Sparkle build floor for that short
|
||||
version. For correction tags, set a higher explicit `APP_BUILD`.
|
||||
- `scripts/make_appcast.sh` first uses `generate_appcast` from `PATH`, then
|
||||
falls back to the SwiftPM Sparkle tool output under `apps/macos/.build`.
|
||||
- For stable tags, the local fallback may update the shared production
|
||||
`appcast.xml`.
|
||||
- For beta tags, the local fallback still publishes the mac assets but must not
|
||||
update the shared production `appcast.xml` unless a separate beta feed exists.
|
||||
- Treat the local workflow as fallback only. Prefer the CI/CD publish workflow
|
||||
when it is working.
|
||||
- After any stable mac publish, verify all of the following before you call the
|
||||
release finished:
|
||||
- the GitHub release has `.zip`, `.dmg`, and `.dSYM.zip` assets
|
||||
- `appcast.xml` on `main` points at the new stable zip
|
||||
- the packaged app reports the expected short version and a numeric
|
||||
`CFBundleVersion` at or above the canonical Sparkle build floor
|
||||
|
||||
## Run the release sequence
|
||||
|
||||
1. Confirm the operator explicitly wants to cut a release.
|
||||
2. Choose the exact target version and git tag.
|
||||
3. Make every repo version location match that tag before creating it.
|
||||
4. Update `CHANGELOG.md` and assemble the matching GitHub release notes.
|
||||
5. Run the full preflight for all relevant release builds, including mac readiness.
|
||||
6. Confirm the target npm version is not already published.
|
||||
7. Create and push the git tag.
|
||||
8. Create or refresh the matching GitHub release.
|
||||
9. Start `.github/workflows/openclaw-npm-release.yml` with `preflight_only=true`
|
||||
and choose the intended `npm_dist_tag` (`beta` default; `latest` only for
|
||||
an intentional direct stable publish). Wait for it to pass. Save that run id
|
||||
because the real publish requires it to reuse the prepared npm tarball.
|
||||
10. Start `.github/workflows/macos-release.yml` in `openclaw/openclaw` and wait
|
||||
for the public validation-only run to pass.
|
||||
11. Start
|
||||
`openclaw/releases-private/.github/workflows/openclaw-macos-validate.yml`
|
||||
with the same tag and wait for the private mac validation lane to pass.
|
||||
12. Start
|
||||
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml`
|
||||
with `preflight_only=true` and wait for it to pass. Save that run id because
|
||||
the real publish requires it to reuse the notarized mac artifacts.
|
||||
13. If any preflight or validation run fails, fix the issue on a new commit,
|
||||
delete the tag and matching GitHub release, recreate them from the fixed
|
||||
commit, and rerun all relevant preflights from scratch before continuing.
|
||||
Never reuse old preflight results after the commit changes.
|
||||
14. Start `.github/workflows/openclaw-npm-release.yml` with the same tag for
|
||||
the real publish, choose `npm_dist_tag` (`beta` default, `latest` only when
|
||||
you intentionally want direct stable publish), keep it the same as the
|
||||
preflight run, and pass the successful npm `preflight_run_id`.
|
||||
15. Wait for `npm-release` approval from `@openclaw/openclaw-release-managers`.
|
||||
16. If the stable release was published to `beta`, start
|
||||
`.github/workflows/openclaw-npm-release.yml` again after beta validation
|
||||
passes with the same stable tag, `promote_beta_to_latest=true`,
|
||||
`preflight_only=false`, empty `preflight_run_id`, and `npm_dist_tag=beta`,
|
||||
then verify `latest` now points at that version.
|
||||
17. Start
|
||||
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml`
|
||||
for the real publish with the successful private mac `preflight_run_id` and
|
||||
wait for success.
|
||||
18. Verify the successful real private mac run uploaded the `.zip`, `.dmg`,
|
||||
and `.dSYM.zip` artifacts to the existing GitHub release in
|
||||
`openclaw/openclaw`.
|
||||
19. For stable releases, download `macos-appcast-<tag>` from the successful
|
||||
private mac run, update `appcast.xml` on `main`, and verify the feed.
|
||||
20. For beta releases, publish the mac assets but expect no shared production
|
||||
`appcast.xml` artifact and do not update the shared production feed unless a
|
||||
separate beta feed exists.
|
||||
21. After publish, verify npm and the attached release artifacts.
|
||||
|
||||
## GHSA advisory work
|
||||
|
||||
- Use `openclaw-ghsa-maintainer` for GHSA advisory inspection, patch/publish flow, private-fork validation, and GHSA API-specific publish checks.
|
||||
@@ -1,75 +0,0 @@
|
||||
---
|
||||
name: openclaw-test-heap-leaks
|
||||
description: Investigate `pnpm test` memory growth, Vitest worker OOMs, and suspicious RSS increases in OpenClaw using the `scripts/test-parallel.mjs` heap snapshot tooling. Use when Codex needs to reproduce test-lane memory growth, collect repeated `.heapsnapshot` files, compare snapshots from the same worker PID, triage likely transformed-module retention versus likely runtime leaks, and fix or reduce the impact by patching cleanup logic or isolating hotspot tests.
|
||||
---
|
||||
|
||||
# OpenClaw Test Heap Leaks
|
||||
|
||||
Use this skill for test-memory investigations. Do not guess from RSS alone when heap snapshots are available. Treat snapshot-name deltas as triage evidence, not proof, until retainers or dominators support the call.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Reproduce the failing shape first.
|
||||
- Match the real entrypoint if possible. For Linux CI-style unit failures, start with:
|
||||
- `pnpm canvas:a2ui:bundle && OPENCLAW_TEST_MEMORY_TRACE=1 OPENCLAW_TEST_HEAPSNAPSHOT_INTERVAL_MS=60000 OPENCLAW_TEST_HEAPSNAPSHOT_DIR=.tmp/heapsnap OPENCLAW_TEST_WORKERS=2 OPENCLAW_TEST_MAX_OLD_SPACE_SIZE_MB=6144 pnpm test`
|
||||
- Keep `OPENCLAW_TEST_MEMORY_TRACE=1` enabled so the wrapper prints per-file RSS summaries alongside the snapshots.
|
||||
- If the report is about a specific shard or worker budget, preserve that shape.
|
||||
- Before you analyze snapshots, identify the real lane names from `[test-parallel] start ...` lines or `pnpm test --plan`. Do not assume a single `unit-fast` lane; local plans often split into `unit-fast-batch-*`.
|
||||
|
||||
2. Wait for repeated snapshots before concluding anything.
|
||||
- Take at least two intervals from the same lane.
|
||||
- Compare snapshots from the same PID inside the real lane directory such as `.tmp/heapsnap/unit-fast-batch-2/`.
|
||||
- Use `.agents/skills/openclaw-test-heap-leaks/scripts/heapsnapshot-delta.mjs` to compare either two files directly or the earliest/latest pair per PID in one lane directory.
|
||||
- If the helper suggests transformed-module retention, confirm the top entries in DevTools retainers/dominators before calling it solved.
|
||||
|
||||
3. Classify the growth before choosing a fix.
|
||||
- If growth is dominated by Vite/Vitest transformed source strings, `Module`, `system / Context`, bytecode, descriptor arrays, or property maps, treat it as likely retained module graph growth in long-lived workers.
|
||||
- If growth is dominated by app objects, caches, buffers, server handles, timers, mock state, sqlite state, or similar runtime objects, treat it as a likely cleanup or lifecycle leak.
|
||||
- If the names are ambiguous, stop short of a confident label and inspect retainers/dominators in DevTools for the top deltas.
|
||||
|
||||
4. Fix the right layer.
|
||||
- For likely retained transformed-module growth in shared workers:
|
||||
- Prefer timing and hotspot-driven scheduling fixes first. Check whether the file is already represented in `test/fixtures/test-timings.unit.json` and whether `scripts/test-update-memory-hotspots.mjs` should refresh the measured hotspot manifest before hand-editing behavior overrides.
|
||||
- Move hotspot files out of the real shared lane by updating `test/fixtures/test-parallel.behavior.json` only when timing-driven peeling is insufficient.
|
||||
- Prefer `singletonIsolated` for files that are safe alone but inflate shared worker heaps.
|
||||
- If the file should already have been peeled out by timings but is absent from `test/fixtures/test-timings.unit.json`, call that out explicitly. Missing timings are a scheduling blind spot.
|
||||
- For real leaks:
|
||||
- Patch the implicated test or runtime cleanup path.
|
||||
- Look for missing `afterEach`/`afterAll`, module-reset gaps, retained global state, unreleased DB handles, or listeners/timers that survive the file.
|
||||
|
||||
5. Verify with the most direct proof.
|
||||
- Re-run the targeted lane or file with heap snapshots enabled if the suite still finishes in reasonable time.
|
||||
- If snapshot overhead pushes tests over Vitest timeouts, fall back to the same lane without snapshots and confirm the RSS trend or OOM is reduced.
|
||||
- For wrapper-only changes, at minimum verify the expected lanes start and the snapshot files are written.
|
||||
|
||||
## Heuristics
|
||||
|
||||
- Do not call everything a leak. In this repo, large `unit-fast` or `unit-fast-batch-*` growth can be a worker-lifetime problem rather than an application object leak.
|
||||
- `scripts/test-parallel.mjs` and `scripts/test-parallel-memory.mjs` are the primary control points for wrapper diagnostics.
|
||||
- The lane names printed by `[test-parallel] start ...` and `[test-parallel][mem] summary ...` tell you where to focus.
|
||||
- When one or two files account for most of the delta and they are missing from timings, reducing impact by isolating them is usually the first pragmatic fix.
|
||||
- When the same retained object families grow across multiple intervals in the same worker PID, trust the snapshots over intuition, then confirm ambiguous calls with retainer evidence.
|
||||
|
||||
## Snapshot Comparison
|
||||
|
||||
- Direct comparison:
|
||||
- `node .agents/skills/openclaw-test-heap-leaks/scripts/heapsnapshot-delta.mjs before.heapsnapshot after.heapsnapshot`
|
||||
- Auto-select earliest/latest snapshots per PID within one lane:
|
||||
- `node .agents/skills/openclaw-test-heap-leaks/scripts/heapsnapshot-delta.mjs --lane-dir .tmp/heapsnap/unit-fast-batch-2`
|
||||
- Useful flags:
|
||||
- `--top 40`
|
||||
- `--min-kb 32`
|
||||
- `--pid 16133`
|
||||
|
||||
Read the top positive deltas first. Large positive growth in module-transform artifacts suggests lane isolation; large positive growth in runtime objects suggests a real leak. If the names alone do not settle it, open the same snapshot pair in DevTools and inspect retainers/dominators for the top rows before declaring root cause.
|
||||
|
||||
## Output Expectations
|
||||
|
||||
When using this skill, report:
|
||||
|
||||
- The exact reproduce command.
|
||||
- Which lane and PID were compared.
|
||||
- The dominant retained object families from the snapshot delta.
|
||||
- Whether the issue is a likely real leak or likely shared-worker retained module growth, plus whether retainers/dominators confirmed it.
|
||||
- The concrete fix or impact-reduction patch.
|
||||
- What you verified, and what snapshot overhead prevented you from verifying.
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "Test Heap Leaks"
|
||||
short_description: "Investigate test OOMs with heap snapshots"
|
||||
default_prompt: "Use $openclaw-test-heap-leaks to investigate test memory growth with heap snapshots and reduce its impact."
|
||||
@@ -1,553 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function printUsage() {
|
||||
console.error(
|
||||
"Usage: node heapsnapshot-delta.mjs <before.heapsnapshot> <after.heapsnapshot> [--top N] [--min-kb N]",
|
||||
);
|
||||
console.error(
|
||||
" or: node heapsnapshot-delta.mjs --lane-dir <dir> [--pid PID] [--top N] [--min-kb N]",
|
||||
);
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
console.error(message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
top: 30,
|
||||
minKb: 64,
|
||||
laneDir: null,
|
||||
pid: null,
|
||||
files: [],
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--top") {
|
||||
options.top = Number.parseInt(argv[index + 1] ?? "", 10);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--min-kb") {
|
||||
options.minKb = Number.parseInt(argv[index + 1] ?? "", 10);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--lane-dir") {
|
||||
options.laneDir = argv[index + 1] ?? null;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--pid") {
|
||||
options.pid = Number.parseInt(argv[index + 1] ?? "", 10);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
options.files.push(arg);
|
||||
}
|
||||
|
||||
if (!Number.isFinite(options.top) || options.top <= 0) {
|
||||
fail("--top must be a positive integer");
|
||||
}
|
||||
if (!Number.isFinite(options.minKb) || options.minKb < 0) {
|
||||
fail("--min-kb must be a non-negative integer");
|
||||
}
|
||||
if (options.pid !== null && (!Number.isInteger(options.pid) || options.pid <= 0)) {
|
||||
fail("--pid must be a positive integer");
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
class JsonStreamScanner {
|
||||
constructor(filePath) {
|
||||
this.stream = fs.createReadStream(filePath, {
|
||||
encoding: "utf8",
|
||||
highWaterMark: 1024 * 1024,
|
||||
});
|
||||
this.iterator = this.stream[Symbol.asyncIterator]();
|
||||
this.buffer = "";
|
||||
this.offset = 0;
|
||||
this.done = false;
|
||||
}
|
||||
|
||||
compactBuffer() {
|
||||
if (this.offset > 65536) {
|
||||
this.buffer = this.buffer.slice(this.offset);
|
||||
this.offset = 0;
|
||||
}
|
||||
}
|
||||
|
||||
async ensureAvailable(count = 1) {
|
||||
while (!this.done && this.buffer.length - this.offset < count) {
|
||||
const next = await this.iterator.next();
|
||||
if (next.done) {
|
||||
this.done = true;
|
||||
break;
|
||||
}
|
||||
this.buffer += next.value;
|
||||
}
|
||||
}
|
||||
|
||||
async peek() {
|
||||
await this.ensureAvailable(1);
|
||||
return this.buffer[this.offset] ?? null;
|
||||
}
|
||||
|
||||
async next() {
|
||||
await this.ensureAvailable(1);
|
||||
if (this.offset >= this.buffer.length) {
|
||||
return null;
|
||||
}
|
||||
const char = this.buffer[this.offset];
|
||||
this.offset += 1;
|
||||
this.compactBuffer();
|
||||
return char;
|
||||
}
|
||||
|
||||
async skipWhitespace() {
|
||||
while (true) {
|
||||
const char = await this.peek();
|
||||
if (char === null || !/\s/u.test(char)) {
|
||||
return;
|
||||
}
|
||||
await this.next();
|
||||
}
|
||||
}
|
||||
|
||||
async expectChar(expected) {
|
||||
const char = await this.next();
|
||||
if (char !== expected) {
|
||||
fail(`Expected ${expected} but found ${char ?? "<eof>"}`);
|
||||
}
|
||||
}
|
||||
|
||||
async find(sequence) {
|
||||
let matched = 0;
|
||||
while (true) {
|
||||
const char = await this.next();
|
||||
if (char === null) {
|
||||
fail(`Could not find ${sequence}`);
|
||||
}
|
||||
if (char === sequence[matched]) {
|
||||
matched += 1;
|
||||
if (matched === sequence.length) {
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
matched = char === sequence[0] ? 1 : 0;
|
||||
if (matched === sequence.length) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async readBalancedObject() {
|
||||
const start = await this.next();
|
||||
if (start !== "{") {
|
||||
fail(`Expected { but found ${start ?? "<eof>"}`);
|
||||
}
|
||||
let text = "{";
|
||||
let depth = 1;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
while (depth > 0) {
|
||||
const char = await this.next();
|
||||
if (char === null) {
|
||||
fail("Unexpected EOF while reading JSON object");
|
||||
}
|
||||
text += char;
|
||||
if (inString) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (char === "\\") {
|
||||
escaped = true;
|
||||
} else if (char === '"') {
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (char === '"') {
|
||||
inString = true;
|
||||
} else if (char === "{") {
|
||||
depth += 1;
|
||||
} else if (char === "}") {
|
||||
depth -= 1;
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
async parseNumberArray(onValue) {
|
||||
await this.skipWhitespace();
|
||||
await this.expectChar("[");
|
||||
await this.skipWhitespace();
|
||||
if ((await this.peek()) === "]") {
|
||||
await this.next();
|
||||
return;
|
||||
}
|
||||
|
||||
let token = "";
|
||||
let index = 0;
|
||||
const flush = () => {
|
||||
if (token.length === 0) {
|
||||
fail("Unexpected empty number token");
|
||||
}
|
||||
const value = Number.parseInt(token, 10);
|
||||
if (!Number.isFinite(value)) {
|
||||
fail(`Invalid numeric token: ${token}`);
|
||||
}
|
||||
onValue(value, index);
|
||||
index += 1;
|
||||
token = "";
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const char = await this.next();
|
||||
if (char === null) {
|
||||
fail("Unexpected EOF while reading number array");
|
||||
}
|
||||
if (char === "]") {
|
||||
flush();
|
||||
return;
|
||||
}
|
||||
if (char === ",") {
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
if (/\s/u.test(char)) {
|
||||
continue;
|
||||
}
|
||||
token += char;
|
||||
}
|
||||
}
|
||||
|
||||
async readJsonString() {
|
||||
await this.expectChar('"');
|
||||
let value = "";
|
||||
while (true) {
|
||||
const char = await this.next();
|
||||
if (char === null) {
|
||||
fail("Unexpected EOF while reading JSON string");
|
||||
}
|
||||
if (char === '"') {
|
||||
return value;
|
||||
}
|
||||
if (char !== "\\") {
|
||||
value += char;
|
||||
continue;
|
||||
}
|
||||
const escaped = await this.next();
|
||||
if (escaped === null) {
|
||||
fail("Unexpected EOF while reading JSON string escape");
|
||||
}
|
||||
if (escaped === "u") {
|
||||
let hex = "";
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
const hexChar = await this.next();
|
||||
if (hexChar === null) {
|
||||
fail("Unexpected EOF while reading JSON unicode escape");
|
||||
}
|
||||
hex += hexChar;
|
||||
}
|
||||
value += String.fromCharCode(Number.parseInt(hex, 16));
|
||||
continue;
|
||||
}
|
||||
value +=
|
||||
escaped === "b"
|
||||
? "\b"
|
||||
: escaped === "f"
|
||||
? "\f"
|
||||
: escaped === "n"
|
||||
? "\n"
|
||||
: escaped === "r"
|
||||
? "\r"
|
||||
: escaped === "t"
|
||||
? "\t"
|
||||
: escaped;
|
||||
}
|
||||
}
|
||||
|
||||
async parseStringArray(onValue) {
|
||||
await this.skipWhitespace();
|
||||
await this.expectChar("[");
|
||||
await this.skipWhitespace();
|
||||
if ((await this.peek()) === "]") {
|
||||
await this.next();
|
||||
return;
|
||||
}
|
||||
|
||||
let index = 0;
|
||||
while (true) {
|
||||
const value = await this.readJsonString();
|
||||
onValue(value, index);
|
||||
index += 1;
|
||||
await this.skipWhitespace();
|
||||
const separator = await this.next();
|
||||
if (separator === "]") {
|
||||
return;
|
||||
}
|
||||
if (separator !== ",") {
|
||||
fail(`Expected , or ] but found ${separator ?? "<eof>"}`);
|
||||
}
|
||||
await this.skipWhitespace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseHeapFilename(filePath) {
|
||||
const base = path.basename(filePath);
|
||||
const match = base.match(
|
||||
/^Heap\.(?<stamp>\d{8}\.\d{6})\.(?<pid>\d+)\.0\.(?<seq>\d+)\.heapsnapshot$/u,
|
||||
);
|
||||
if (!match?.groups) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
filePath,
|
||||
pid: Number.parseInt(match.groups.pid, 10),
|
||||
stamp: match.groups.stamp,
|
||||
sequence: Number.parseInt(match.groups.seq, 10),
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePair(options) {
|
||||
if (options.laneDir) {
|
||||
const entries = fs
|
||||
.readdirSync(options.laneDir)
|
||||
.map((name) => parseHeapFilename(path.join(options.laneDir, name)))
|
||||
.filter((entry) => entry !== null)
|
||||
.filter((entry) => options.pid === null || entry.pid === options.pid)
|
||||
.toSorted((left, right) => {
|
||||
if (left.pid !== right.pid) {
|
||||
return left.pid - right.pid;
|
||||
}
|
||||
if (left.stamp !== right.stamp) {
|
||||
return left.stamp.localeCompare(right.stamp);
|
||||
}
|
||||
return left.sequence - right.sequence;
|
||||
});
|
||||
|
||||
if (entries.length === 0) {
|
||||
fail(`No matching heap snapshots found in ${options.laneDir}`);
|
||||
}
|
||||
|
||||
const groups = new Map();
|
||||
for (const entry of entries) {
|
||||
const group = groups.get(entry.pid) ?? [];
|
||||
group.push(entry);
|
||||
groups.set(entry.pid, group);
|
||||
}
|
||||
|
||||
const candidates = Array.from(groups.values())
|
||||
.map((group) => ({
|
||||
pid: group[0].pid,
|
||||
before: group[0],
|
||||
after: group.at(-1),
|
||||
count: group.length,
|
||||
}))
|
||||
.filter((entry) => entry.count >= 2);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
fail(`Need at least two snapshots for one PID in ${options.laneDir}`);
|
||||
}
|
||||
|
||||
const chosen =
|
||||
options.pid !== null
|
||||
? (candidates.find((entry) => entry.pid === options.pid) ?? null)
|
||||
: candidates.toSorted((left, right) => right.count - left.count || left.pid - right.pid)[0];
|
||||
|
||||
if (!chosen) {
|
||||
fail(`No PID with at least two snapshots matched in ${options.laneDir}`);
|
||||
}
|
||||
|
||||
return {
|
||||
before: chosen.before.filePath,
|
||||
after: chosen.after.filePath,
|
||||
pid: chosen.pid,
|
||||
snapshotCount: chosen.count,
|
||||
};
|
||||
}
|
||||
|
||||
if (options.files.length !== 2) {
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return {
|
||||
before: options.files[0],
|
||||
after: options.files[1],
|
||||
pid: null,
|
||||
snapshotCount: 2,
|
||||
};
|
||||
}
|
||||
|
||||
async function parseSnapshotMeta(scanner) {
|
||||
await scanner.find('"snapshot":');
|
||||
await scanner.skipWhitespace();
|
||||
const metaObjectText = await scanner.readBalancedObject();
|
||||
const parsed = JSON.parse(metaObjectText);
|
||||
return parsed?.meta ?? null;
|
||||
}
|
||||
|
||||
async function buildSummary(filePath) {
|
||||
const scanner = new JsonStreamScanner(filePath);
|
||||
const meta = await parseSnapshotMeta(scanner);
|
||||
if (!meta) {
|
||||
fail(`Invalid heap snapshot: ${filePath}`);
|
||||
}
|
||||
|
||||
const nodeFieldCount = meta.node_fields.length;
|
||||
const typeNames = meta.node_types[0];
|
||||
const typeIndex = meta.node_fields.indexOf("type");
|
||||
const nameIndex = meta.node_fields.indexOf("name");
|
||||
const selfSizeIndex = meta.node_fields.indexOf("self_size");
|
||||
if (typeIndex === -1 || nameIndex === -1 || selfSizeIndex === -1) {
|
||||
fail(`Unsupported heap snapshot schema: ${filePath}`);
|
||||
}
|
||||
|
||||
const summaryByIndex = new Map();
|
||||
let nodeCount = 0;
|
||||
let currentTypeId = 0;
|
||||
let currentNameId = 0;
|
||||
let currentSelfSize = 0;
|
||||
await scanner.find('"nodes":');
|
||||
await scanner.parseNumberArray((value, index) => {
|
||||
const fieldIndex = index % nodeFieldCount;
|
||||
if (fieldIndex === typeIndex) {
|
||||
currentTypeId = value;
|
||||
return;
|
||||
}
|
||||
if (fieldIndex === nameIndex) {
|
||||
currentNameId = value;
|
||||
return;
|
||||
}
|
||||
if (fieldIndex === selfSizeIndex) {
|
||||
currentSelfSize = value;
|
||||
}
|
||||
if (fieldIndex !== nodeFieldCount - 1) {
|
||||
return;
|
||||
}
|
||||
const key = `${currentTypeId}\t${currentNameId}`;
|
||||
const current = summaryByIndex.get(key) ?? {
|
||||
typeId: currentTypeId,
|
||||
nameId: currentNameId,
|
||||
selfSize: 0,
|
||||
count: 0,
|
||||
};
|
||||
current.selfSize += currentSelfSize;
|
||||
current.count += 1;
|
||||
summaryByIndex.set(key, current);
|
||||
nodeCount += 1;
|
||||
});
|
||||
|
||||
const requiredNameIds = new Set(
|
||||
Array.from(summaryByIndex.values(), (entry) => entry.nameId).filter((value) => value >= 0),
|
||||
);
|
||||
const nameStrings = new Map();
|
||||
await scanner.find('"strings":');
|
||||
await scanner.parseStringArray((value, index) => {
|
||||
if (requiredNameIds.has(index)) {
|
||||
nameStrings.set(index, value);
|
||||
}
|
||||
});
|
||||
|
||||
const summary = new Map();
|
||||
for (const entry of summaryByIndex.values()) {
|
||||
const key = `${typeNames[entry.typeId] ?? "unknown"}\t${nameStrings.get(entry.nameId) ?? ""}`;
|
||||
summary.set(key, {
|
||||
type: typeNames[entry.typeId] ?? "unknown",
|
||||
name: nameStrings.get(entry.nameId) ?? "",
|
||||
selfSize: entry.selfSize,
|
||||
count: entry.count,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
nodeCount,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (Math.abs(bytes) >= 1024 ** 2) {
|
||||
return `${(bytes / 1024 ** 2).toFixed(2)} MiB`;
|
||||
}
|
||||
if (Math.abs(bytes) >= 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)} KiB`;
|
||||
}
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
function formatDelta(bytes) {
|
||||
return `${bytes >= 0 ? "+" : "-"}${formatBytes(Math.abs(bytes))}`;
|
||||
}
|
||||
|
||||
function truncate(text, maxLength) {
|
||||
return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}…`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const pair = resolvePair(options);
|
||||
const before = await buildSummary(pair.before);
|
||||
const after = await buildSummary(pair.after);
|
||||
const minBytes = options.minKb * 1024;
|
||||
|
||||
const rows = [];
|
||||
for (const [key, next] of after.summary) {
|
||||
const previous = before.summary.get(key) ?? { selfSize: 0, count: 0 };
|
||||
const sizeDelta = next.selfSize - previous.selfSize;
|
||||
const countDelta = next.count - previous.count;
|
||||
if (sizeDelta < minBytes) {
|
||||
continue;
|
||||
}
|
||||
rows.push({
|
||||
type: next.type,
|
||||
name: next.name,
|
||||
sizeDelta,
|
||||
countDelta,
|
||||
afterSize: next.selfSize,
|
||||
afterCount: next.count,
|
||||
});
|
||||
}
|
||||
|
||||
rows.sort(
|
||||
(left, right) => right.sizeDelta - left.sizeDelta || right.countDelta - left.countDelta,
|
||||
);
|
||||
|
||||
console.log(`before: ${pair.before}`);
|
||||
console.log(`after: ${pair.after}`);
|
||||
if (pair.pid !== null) {
|
||||
console.log(`pid: ${pair.pid} (${pair.snapshotCount} snapshots found)`);
|
||||
}
|
||||
console.log(
|
||||
`nodes: ${before.nodeCount} -> ${after.nodeCount} (${after.nodeCount - before.nodeCount >= 0 ? "+" : ""}${after.nodeCount - before.nodeCount})`,
|
||||
);
|
||||
console.log(`filter: top=${options.top} min=${options.minKb} KiB`);
|
||||
console.log("");
|
||||
|
||||
if (rows.length === 0) {
|
||||
console.log("No entries exceeded the minimum delta.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const row of rows.slice(0, options.top)) {
|
||||
console.log(
|
||||
[
|
||||
formatDelta(row.sizeDelta).padStart(11),
|
||||
`count ${row.countDelta >= 0 ? "+" : ""}${row.countDelta}`.padStart(10),
|
||||
row.type.padEnd(16),
|
||||
truncate(row.name || "(empty)", 96),
|
||||
].join(" "),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -1,62 +0,0 @@
|
||||
---
|
||||
name: parallels-discord-roundtrip
|
||||
description: Run the macOS Parallels smoke harness with Discord end-to-end roundtrip verification, including guest send, host verification, host reply, and guest readback.
|
||||
---
|
||||
|
||||
# Parallels Discord Roundtrip
|
||||
|
||||
Use when macOS Parallels smoke must prove Discord two-way delivery end to end.
|
||||
|
||||
## Goal
|
||||
|
||||
Cover:
|
||||
|
||||
- install on fresh macOS snapshot
|
||||
- onboard + gateway health
|
||||
- guest `message send` to Discord
|
||||
- host sees that message on Discord
|
||||
- host posts a new Discord message
|
||||
- guest `message read` sees that new message
|
||||
|
||||
## Inputs
|
||||
|
||||
- host env var with Discord bot token
|
||||
- Discord guild ID
|
||||
- Discord channel ID
|
||||
- `OPENAI_API_KEY`
|
||||
|
||||
## Preferred run
|
||||
|
||||
```bash
|
||||
export OPENCLAW_PARALLELS_DISCORD_TOKEN="$(
|
||||
ssh peters-mac-studio-1 'jq -r ".channels.discord.token" ~/.openclaw/openclaw.json' | tr -d '\n'
|
||||
)"
|
||||
|
||||
pnpm test:parallels:macos \
|
||||
--discord-token-env OPENCLAW_PARALLELS_DISCORD_TOKEN \
|
||||
--discord-guild-id 1456350064065904867 \
|
||||
--discord-channel-id 1456744319972282449 \
|
||||
--json
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Snapshot target: closest to `macOS 26.3.1 fresh`.
|
||||
- Snapshot resolver now prefers matching `*-poweroff*` clones when the base hint also matches. That lets the harness reuse disk-only recovery snapshots without passing a longer hint.
|
||||
- If Windows/Linux snapshot restore logs show `PET_QUESTION_SNAPSHOT_STATE_INCOMPATIBLE_CPU`, drop the suspended state once, create a `*-poweroff*` replacement snapshot, and rerun. The smoke scripts now auto-start restored power-off snapshots.
|
||||
- Harness configures Discord inside the guest; no checked-in token/config.
|
||||
- Use the `openclaw` wrapper for guest `message send/read`; `node openclaw.mjs message ...` does not expose the lazy message subcommands the same way.
|
||||
- Write `channels.discord.guilds` in one JSON object (`--strict-json`), not dotted `config set channels.discord.guilds.<snowflake>...` paths; numeric snowflakes get treated like array indexes.
|
||||
- Avoid `prlctl enter` / expect for long Discord setup scripts; it line-wraps/corrupts long commands. Use `prlctl exec --current-user /bin/sh -lc ...` for the Discord config phase.
|
||||
- Full 3-OS sweeps: the shared build lock is safe in parallel, but snapshot restore is still a Parallels bottleneck. Prefer serialized Windows/Linux restore-heavy reruns if the host is already under load.
|
||||
- Harness cleanup deletes the temporary Discord smoke messages at exit.
|
||||
- Per-phase logs: `/tmp/openclaw-parallels-smoke.*`
|
||||
- Machine summary: pass `--json`
|
||||
- If roundtrip flakes, inspect `fresh.discord-roundtrip.log` and `discord-last-readback.json` in the run dir first.
|
||||
|
||||
## Pass criteria
|
||||
|
||||
- fresh lane or upgrade lane requested passes
|
||||
- summary reports `discord=pass` for that lane
|
||||
- guest outbound nonce appears in channel history
|
||||
- host inbound nonce appears in `openclaw message read` output
|
||||
@@ -1,111 +0,0 @@
|
||||
---
|
||||
name: security-triage
|
||||
description: Triage GitHub security advisories for OpenClaw with high-confidence close/keep decisions, exact tag and commit verification, trust-model checks, optional hardening notes, and a final reply ready to post and copy to clipboard.
|
||||
---
|
||||
|
||||
# Security Triage
|
||||
|
||||
Use when reviewing OpenClaw security advisories, drafts, or GHSA reports.
|
||||
|
||||
Goal: high-confidence maintainers' triage without over-closing real issues or shipping unnecessary regressions.
|
||||
|
||||
## Close Bar
|
||||
|
||||
Close only if one of these is true:
|
||||
|
||||
- duplicate of an existing advisory or fixed issue
|
||||
- invalid against shipped behavior
|
||||
- out of scope under `SECURITY.md`
|
||||
- fixed before any affected release/tag
|
||||
|
||||
Do not close only because `main` is fixed. If latest shipped tag or npm release is affected, keep it open until released or published with the right status.
|
||||
|
||||
## Required Reads
|
||||
|
||||
Before answering:
|
||||
|
||||
1. Read `SECURITY.md`.
|
||||
2. Read the GHSA body with `gh api /repos/openclaw/openclaw/security-advisories/<GHSA>`.
|
||||
3. Inspect the exact implicated code paths.
|
||||
4. Verify shipped state:
|
||||
- `git tag --sort=-creatordate | head`
|
||||
- `npm view openclaw version --userconfig "$(mktemp)"`
|
||||
- `git tag --contains <fix-commit>`
|
||||
- if needed: `git show <tag>:path/to/file`
|
||||
5. Search for canonical overlap:
|
||||
- existing published GHSAs
|
||||
- older fixed bugs
|
||||
- same trust-model class already covered in `SECURITY.md`
|
||||
|
||||
## Review Method
|
||||
|
||||
For each advisory, decide:
|
||||
|
||||
- `close`
|
||||
- `keep open`
|
||||
- `keep open but narrow`
|
||||
|
||||
Check in this order:
|
||||
|
||||
1. Trust model
|
||||
- Is the prerequisite already inside trusted host/local/plugin/operator state?
|
||||
- Does `SECURITY.md` explicitly call this class out as out of scope or hardening-only?
|
||||
2. Shipped behavior
|
||||
- Is the bug present in the latest shipped tag or npm release?
|
||||
- Was it fixed before release?
|
||||
3. Exploit path
|
||||
- Does the report show a real boundary bypass, not just prompt injection, local same-user control, or helper-level semantics?
|
||||
- If data only moves between trusted workspace-memory files called out in `SECURITY.md`, do not treat "injection markers" alone as a security bug.
|
||||
- In that case, frame sanitization as optional hardening only if it preserves expected memory workflows.
|
||||
4. Functional tradeoff
|
||||
- If a hardening change would reduce intended user functionality, call that out before proposing it.
|
||||
- Prefer fixes that preserve user workflows over deny-by-default regressions unless the boundary demands it.
|
||||
|
||||
## Response Format
|
||||
|
||||
When preparing a maintainer-ready close reply:
|
||||
|
||||
1. Print the GHSA URL first.
|
||||
2. Then draft a detailed response the maintainer can post.
|
||||
3. Include:
|
||||
- exact reason for close
|
||||
- exact code refs
|
||||
- exact shipped tag / release facts
|
||||
- exact fix commit or canonical duplicate GHSA when applicable
|
||||
- optional hardening note only if worthwhile and functionality-preserving
|
||||
|
||||
Keep tone firm, specific, non-defensive.
|
||||
|
||||
## Clipboard Step
|
||||
|
||||
After drafting the final post body, copy it:
|
||||
|
||||
```bash
|
||||
pbcopy <<'EOF'
|
||||
<final response>
|
||||
EOF
|
||||
```
|
||||
|
||||
Tell the user that the clipboard now contains the proposed response.
|
||||
|
||||
## Useful Commands
|
||||
|
||||
```bash
|
||||
gh api /repos/openclaw/openclaw/security-advisories/<GHSA>
|
||||
gh api /repos/openclaw/openclaw/security-advisories --paginate
|
||||
git tag --sort=-creatordate | head -n 20
|
||||
npm view openclaw version --userconfig "$(mktemp)"
|
||||
git tag --contains <commit>
|
||||
git show <tag>:<path>
|
||||
gh search issues --repo openclaw/openclaw --match title,body,comments -- "<terms>"
|
||||
gh search prs --repo openclaw/openclaw --match title,body,comments -- "<terms>"
|
||||
```
|
||||
|
||||
## Decision Notes
|
||||
|
||||
- “fixed on main, unreleased” is usually not a close.
|
||||
- “needs attacker-controlled trusted local state first” is usually out of scope.
|
||||
- “same-host same-user process can already read/write local state” is usually out of scope.
|
||||
- “trusted workspace memory promotes/reindexes trusted workspace memory” is usually out of scope unless it crosses a documented boundary.
|
||||
- “helper function behaves differently than documented config semantics” is usually invalid.
|
||||
- If only the severity is wrong but the bug is real, keep it open and narrow the impact in the reply.
|
||||
@@ -1,7 +1,7 @@
|
||||
.git
|
||||
.worktrees
|
||||
|
||||
# Sensitive files – scripts/docker/setup.sh writes .env with OPENCLAW_GATEWAY_TOKEN
|
||||
# Sensitive files – docker-setup.sh writes .env with OPENCLAW_GATEWAY_TOKEN
|
||||
# into the project root; keep it out of the build context.
|
||||
.env
|
||||
.env.*
|
||||
@@ -33,8 +33,6 @@ node_modules
|
||||
**/.next
|
||||
coverage
|
||||
**/coverage
|
||||
docs/.generated
|
||||
**/.generated
|
||||
*.log
|
||||
tmp
|
||||
**/tmp
|
||||
|
||||
61
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
61
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -7,10 +7,7 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for filing this report. Keep every answer concise, reproducible, and grounded in observed evidence.
|
||||
Do not speculate or infer beyond the evidence. If a narrative section cannot be answered from the available evidence, respond with exactly `NOT_ENOUGH_INFO`.
|
||||
|
||||
If this is a plugin beta-release blocker, rename the issue title to `Beta blocker: <plugin-name> - <summary>` and apply the `beta-blocker` label after filing.
|
||||
Thanks for filing this report. Keep it concise, reproducible, and evidence-based.
|
||||
- type: dropdown
|
||||
id: bug_type
|
||||
attributes:
|
||||
@@ -22,52 +19,39 @@ body:
|
||||
- Behavior bug (incorrect output/state without crash)
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: beta_blocker
|
||||
attributes:
|
||||
label: Beta release blocker
|
||||
description: >
|
||||
Choose `Yes` only if this blocks plugin compatibility during the current beta release window.
|
||||
Selecting `Yes` does not apply the label automatically. You must also rename the issue title
|
||||
to `Beta blocker: <plugin-name> - <summary>` for the automation to apply the `beta-blocker` label.
|
||||
options:
|
||||
- "No"
|
||||
- "Yes"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
label: Summary
|
||||
description: One-sentence statement of what is broken, based only on observed evidence. If the evidence is insufficient, respond with exactly `NOT_ENOUGH_INFO`.
|
||||
placeholder: After upgrading from 2026.2.10 to 2026.2.17, Telegram thread replies stopped posting; reproduced twice and confirmed by gateway logs.
|
||||
description: One-sentence statement of what is broken.
|
||||
placeholder: After upgrading to <version>, <channel> behavior regressed from <prior version>.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Provide the shortest deterministic repro path supported by direct observation. If the repro path cannot be grounded from the evidence, respond with exactly `NOT_ENOUGH_INFO`.
|
||||
description: Provide the shortest deterministic repro path.
|
||||
placeholder: |
|
||||
1. Start OpenClaw 2026.2.17 with the attached config.
|
||||
2. Send a Telegram thread reply in the affected chat.
|
||||
3. Observe no reply and confirm the attached `reply target not found` log line.
|
||||
1. Configure channel X.
|
||||
2. Send message Y.
|
||||
3. Run command Z.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: State the expected result using a concrete reference such as prior observed behavior, attached docs, or a known-good version. If no grounded reference exists, respond with exactly `NOT_ENOUGH_INFO`.
|
||||
placeholder: In 2026.2.10, the agent posted replies in the same Telegram thread under the same workflow.
|
||||
description: What should happen if the bug does not exist.
|
||||
placeholder: Agent posts a reply in the same thread.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual behavior
|
||||
description: Describe only the observed result, including user-visible errors and cited evidence. If the observed result cannot be grounded from the evidence, respond with exactly `NOT_ENOUGH_INFO`.
|
||||
placeholder: No reply is posted in the thread; the attached gateway log shows `reply target not found` at 14:23:08 UTC.
|
||||
description: What happened instead, including user-visible errors.
|
||||
placeholder: No reply is posted; gateway logs "reply target not found".
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
@@ -108,6 +92,12 @@ body:
|
||||
placeholder: openclaw -> cloudflare-ai-gateway -> minimax
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: config_location
|
||||
attributes:
|
||||
label: Config file / key location
|
||||
description: Optional. Relevant config source or key path if this bug depends on overrides or custom provider setup. Redact secrets.
|
||||
placeholder: ~/.openclaw/openclaw.json ; models.providers.cloudflare-ai-gateway.baseUrl ; ~/.openclaw/agents/<agentId>/agent/models.json
|
||||
- type: textarea
|
||||
id: provider_setup_details
|
||||
attributes:
|
||||
@@ -121,28 +111,27 @@ body:
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs, screenshots, and evidence
|
||||
description: Include the redacted logs, screenshots, recordings, docs, or version comparisons that support the grounded answers above.
|
||||
description: Include redacted logs/screenshots/recordings that prove the behavior.
|
||||
render: shell
|
||||
- type: textarea
|
||||
id: impact
|
||||
attributes:
|
||||
label: Impact and severity
|
||||
description: |
|
||||
Explain who is affected, how severe it is, how often it happens, and the practical consequence using only observed evidence.
|
||||
If any part cannot be grounded from the evidence, respond with exactly `NOT_ENOUGH_INFO`.
|
||||
Explain who is affected, how severe it is, how often it happens, and the practical consequence.
|
||||
Include:
|
||||
- Affected users/systems/channels
|
||||
- Severity (annoying, blocks workflow, data risk, etc.)
|
||||
- Frequency (always/intermittent/edge case)
|
||||
- Consequence (missed messages, failed onboarding, extra cost, etc.)
|
||||
placeholder: |
|
||||
Affected: Telegram group users on 2026.2.17
|
||||
Severity: High (blocks thread replies)
|
||||
Frequency: 4/4 observed attempts
|
||||
Consequence: Agents do not respond in the affected threads
|
||||
Affected: Telegram group users on <version>
|
||||
Severity: High (blocks replies)
|
||||
Frequency: 100% repro
|
||||
Consequence: Agents cannot respond in threads
|
||||
- type: textarea
|
||||
id: additional_information
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Add any remaining grounded context that helps triage but does not fit above. If this is a regression, include the last known good and first known bad versions when observed. If there is not enough evidence, respond with exactly `NOT_ENOUGH_INFO`.
|
||||
placeholder: Last known good version 2026.2.10, first known bad version 2026.2.17, temporary workaround is sending a top-level message instead of a thread reply.
|
||||
description: Add any context that helps triage but does not fit above. If this is a regression, include the last known good and first known bad versions.
|
||||
placeholder: Last known good version <...>, first known bad version <...>, temporary workaround is ...
|
||||
|
||||
18
.github/actions/ensure-base-commit/action.yml
vendored
18
.github/actions/ensure-base-commit/action.yml
vendored
@@ -23,16 +23,6 @@ runs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! [[ "$BASE_SHA" =~ ^[0-9a-fA-F]{7,40}$ ]]; then
|
||||
echo "::error title=ensure-base-commit invalid base sha::Refusing invalid base SHA: $BASE_SHA"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! git check-ref-format --branch "$FETCH_REF" >/dev/null 2>&1; then
|
||||
echo "::error title=ensure-base-commit invalid fetch ref::Refusing invalid fetch ref: $FETCH_REF"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if git rev-parse --verify "$BASE_SHA^{commit}" >/dev/null 2>&1; then
|
||||
echo "Base commit already present: $BASE_SHA"
|
||||
exit 0
|
||||
@@ -40,9 +30,7 @@ runs:
|
||||
|
||||
for deepen_by in 25 100 300; do
|
||||
echo "Base commit missing; deepening $FETCH_REF by $deepen_by."
|
||||
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
|
||||
git fetch --no-tags --deepen="$deepen_by" origin "$FETCH_REF" || true
|
||||
if git rev-parse --verify "$BASE_SHA^{commit}" >/dev/null 2>&1; then
|
||||
echo "Resolved base commit after deepening: $BASE_SHA"
|
||||
exit 0
|
||||
@@ -50,9 +38,7 @@ runs:
|
||||
done
|
||||
|
||||
echo "Base commit still missing; fetching full history for $FETCH_REF."
|
||||
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
|
||||
git fetch --no-tags origin "$FETCH_REF" || true
|
||||
if git rev-parse --verify "$BASE_SHA^{commit}" >/dev/null 2>&1; then
|
||||
echo "Resolved base commit after full ref fetch: $BASE_SHA"
|
||||
exit 0
|
||||
|
||||
22
.github/actions/setup-node-env/action.yml
vendored
22
.github/actions/setup-node-env/action.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: Setup Node environment
|
||||
description: >
|
||||
Install Node 24 by default, pnpm, optionally Bun, and optionally run pnpm
|
||||
install. Requires actions/checkout to run first.
|
||||
Initialize submodules with retry, install Node 24 by default, pnpm, optionally Bun,
|
||||
and optionally run pnpm install. Requires actions/checkout to run first.
|
||||
inputs:
|
||||
node-version:
|
||||
description: Node.js version to install.
|
||||
@@ -14,7 +14,7 @@ inputs:
|
||||
pnpm-version:
|
||||
description: pnpm version for corepack.
|
||||
required: false
|
||||
default: "10.32.1"
|
||||
default: "10.23.0"
|
||||
install-bun:
|
||||
description: Whether to install Bun alongside Node.
|
||||
required: false
|
||||
@@ -34,6 +34,20 @@ inputs:
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Checkout submodules (retry)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git submodule sync --recursive
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -49,7 +63,7 @@ runs:
|
||||
|
||||
- name: Setup Bun
|
||||
if: inputs.install-bun == 'true'
|
||||
uses: oven-sh/setup-bun@v2.2.0
|
||||
uses: oven-sh/setup-bun@v2.1.3
|
||||
with:
|
||||
bun-version: "1.3.9"
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ inputs:
|
||||
pnpm-version:
|
||||
description: pnpm version to activate via corepack.
|
||||
required: false
|
||||
default: "10.32.1"
|
||||
default: "10.23.0"
|
||||
cache-key-suffix:
|
||||
description: Suffix appended to the cache key.
|
||||
required: false
|
||||
|
||||
123
.github/labeler.yml
vendored
123
.github/labeler.yml
vendored
@@ -59,22 +59,6 @@
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/nostr/**"
|
||||
- "docs/channels/nostr.md"
|
||||
"channel: qqbot":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/qqbot/**"
|
||||
- "docs/channels/qqbot.md"
|
||||
"channel: qa-channel":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/qa-channel/**"
|
||||
- "docs/channels/qa-channel.md"
|
||||
"extensions: qa-lab":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/qa-lab/**"
|
||||
- "docs/concepts/qa-e2e-automation.md"
|
||||
- "docs/channels/qa-channel.md"
|
||||
"channel: signal":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@@ -181,10 +165,7 @@
|
||||
- "Dockerfile.*"
|
||||
- "docker-compose.yml"
|
||||
- "docker-setup.sh"
|
||||
- "setup-podman.sh"
|
||||
- ".dockerignore"
|
||||
- "scripts/docker/setup.sh"
|
||||
- "scripts/podman/setup.sh"
|
||||
- "scripts/**/*docker*"
|
||||
- "scripts/**/Dockerfile*"
|
||||
- "scripts/sandbox-*.sh"
|
||||
@@ -217,6 +198,14 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/diagnostics-otel/**"
|
||||
"extensions: google-antigravity-auth":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/google-antigravity-auth/**"
|
||||
"extensions: google-gemini-cli-auth":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/google-gemini-cli-auth/**"
|
||||
"extensions: llm-task":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@@ -237,115 +226,27 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/open-prose/**"
|
||||
"extensions: qwen-portal-auth":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/qwen-portal-auth/**"
|
||||
"extensions: device-pair":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/device-pair/**"
|
||||
"extensions: duckduckgo":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/duckduckgo/**"
|
||||
"extensions: acpx":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/acpx/**"
|
||||
"extensions: byteplus":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/byteplus/**"
|
||||
"extensions: deepseek":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/deepseek/**"
|
||||
"extensions: stepfun":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/stepfun/**"
|
||||
"extensions: anthropic":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/anthropic/**"
|
||||
"extensions: cloudflare-ai-gateway":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/cloudflare-ai-gateway/**"
|
||||
"extensions: minimax-portal-auth":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/minimax-portal-auth/**"
|
||||
"extensions: huggingface":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/huggingface/**"
|
||||
"extensions: kilocode":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/kilocode/**"
|
||||
"extensions: openai":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/openai/**"
|
||||
"extensions: kimi-coding":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/kimi-coding/**"
|
||||
"extensions: minimax":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/minimax/**"
|
||||
"extensions: modelstudio":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/modelstudio/**"
|
||||
"extensions: moonshot":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/moonshot/**"
|
||||
"extensions: nvidia":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/nvidia/**"
|
||||
"extensions: phone-control":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/phone-control/**"
|
||||
"extensions: qianfan":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/qianfan/**"
|
||||
"extensions: synthetic":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/synthetic/**"
|
||||
"extensions: tavily":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/tavily/**"
|
||||
"extensions: talk-voice":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/talk-voice/**"
|
||||
"extensions: together":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/together/**"
|
||||
"extensions: venice":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/venice/**"
|
||||
"extensions: vercel-ai-gateway":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/vercel-ai-gateway/**"
|
||||
"extensions: volcengine":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/volcengine/**"
|
||||
"extensions: xiaomi":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/xiaomi/**"
|
||||
"extensions: fal":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/fal/**"
|
||||
|
||||
46
.github/pull_request_template.md
vendored
46
.github/pull_request_template.md
vendored
@@ -2,8 +2,6 @@
|
||||
|
||||
Describe the problem and fix in 2–5 bullets:
|
||||
|
||||
If this PR fixes a plugin beta-release blocker, title it `fix(<plugin-id>): beta blocker - <summary>` and link the matching `Beta blocker: <plugin-name> - <summary>` issue labeled `beta-blocker`. Contributors cannot label PRs, so the title is the PR-side signal for maintainers and automation.
|
||||
|
||||
- Problem:
|
||||
- Why it matters:
|
||||
- What changed:
|
||||
@@ -13,7 +11,7 @@ If this PR fixes a plugin beta-release blocker, title it `fix(<plugin-id>): beta
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] Feature
|
||||
- [ ] Refactor required for the fix
|
||||
- [ ] Refactor
|
||||
- [ ] Docs
|
||||
- [ ] Security hardening
|
||||
- [ ] Chore/infra
|
||||
@@ -33,48 +31,12 @@ If this PR fixes a plugin beta-release blocker, title it `fix(<plugin-id>): beta
|
||||
|
||||
- Closes #
|
||||
- Related #
|
||||
- [ ] This PR fixes a bug or regression
|
||||
|
||||
## Root Cause (if applicable)
|
||||
|
||||
For bug fixes or regressions, explain why this happened, not just what changed. Otherwise write `N/A`. If the cause is unclear, write `Unknown`.
|
||||
|
||||
- Root cause:
|
||||
- Missing detection / guardrail:
|
||||
- Contributing context (if known):
|
||||
|
||||
## Regression Test Plan (if applicable)
|
||||
|
||||
For bug fixes or regressions, name the smallest reliable test coverage that should catch this. Otherwise write `N/A`.
|
||||
|
||||
- Coverage level that should have caught this:
|
||||
- [ ] Unit test
|
||||
- [ ] Seam / integration test
|
||||
- [ ] End-to-end test
|
||||
- [ ] Existing coverage already sufficient
|
||||
- Target test or file:
|
||||
- Scenario the test should lock in:
|
||||
- Why this is the smallest reliable guardrail:
|
||||
- Existing test that already covers this (if any):
|
||||
- If no new test is added, why not:
|
||||
|
||||
## User-visible / Behavior Changes
|
||||
|
||||
List user-visible changes (including defaults/config).
|
||||
If none, write `None`.
|
||||
|
||||
## Diagram (if applicable)
|
||||
|
||||
For UI changes or non-trivial logic flows, include a small ASCII diagram reviewers can scan quickly. Otherwise write `N/A`.
|
||||
|
||||
```text
|
||||
Before:
|
||||
[user action] -> [old state]
|
||||
|
||||
After:
|
||||
[user action] -> [new state] -> [result]
|
||||
```
|
||||
|
||||
## Security Impact (required)
|
||||
|
||||
- New permissions/capabilities? (`Yes/No`)
|
||||
@@ -139,6 +101,12 @@ If a bot review conversation is addressed by this PR, resolve that conversation
|
||||
- Migration needed? (`Yes/No`)
|
||||
- If yes, exact upgrade steps:
|
||||
|
||||
## Failure Recovery (if this breaks)
|
||||
|
||||
- How to disable/revert this change quickly:
|
||||
- Files/config to restore:
|
||||
- Known bad symptoms reviewers should watch for:
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
List only real risks for this PR. Add/remove entries as needed. If none, write `None`.
|
||||
|
||||
10
.github/workflows/auto-response.yml
vendored
10
.github/workflows/auto-response.yml
vendored
@@ -11,10 +11,6 @@ on:
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request_target' }}
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
@@ -402,16 +398,10 @@ jobs:
|
||||
const invalidLabel = "invalid";
|
||||
const spamLabel = "r: spam";
|
||||
const dirtyLabel = "dirty";
|
||||
const badBarnacleLabel = "bad-barnacle";
|
||||
const noisyPrMessage =
|
||||
"Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch.";
|
||||
|
||||
if (pullRequest) {
|
||||
if (labelSet.has(badBarnacleLabel)) {
|
||||
core.info(`Skipping PR auto-response checks for #${pullRequest.number} because ${badBarnacleLabel} is present.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (labelSet.has(dirtyLabel)) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
|
||||
1293
.github/workflows/ci.yml
vendored
1293
.github/workflows/ci.yml
vendored
File diff suppressed because it is too large
Load Diff
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
@@ -116,7 +116,7 @@ jobs:
|
||||
- name: Build Android for CodeQL
|
||||
if: matrix.language == 'java-kotlin'
|
||||
working-directory: apps/android
|
||||
run: ./gradlew --no-daemon :app:assemblePlayDebug
|
||||
run: ./gradlew --no-daemon :app:assembleDebug
|
||||
|
||||
- name: Build Swift for CodeQL
|
||||
if: matrix.language == 'swift'
|
||||
|
||||
172
.github/workflows/control-ui-locale-refresh.yml
vendored
172
.github/workflows/control-ui-locale-refresh.yml
vendored
@@ -1,172 +0,0 @@
|
||||
name: Control UI Locale Refresh
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- ui/src/i18n/locales/en.ts
|
||||
- ui/src/i18n/locales/*.ts
|
||||
- ui/src/i18n/.i18n/*
|
||||
- ui/src/i18n/lib/types.ts
|
||||
- ui/src/i18n/lib/registry.ts
|
||||
- scripts/control-ui-i18n.ts
|
||||
- .github/workflows/control-ui-locale-refresh.yml
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
schedule:
|
||||
- cron: "23 4 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: control-ui-locale-refresh
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
plan:
|
||||
if: github.repository == 'openclaw/openclaw' && (github.event_name != 'push' || github.actor != 'github-actions[bot]')
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
has_locales: ${{ steps.plan.outputs.has_locales }}
|
||||
locales_json: ${{ steps.plan.outputs.locales_json }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Plan locale matrix
|
||||
id: plan
|
||||
env:
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
all_locales_json='["zh-CN","zh-TW","pt-BR","de","es","ja-JP","ko","fr","tr","uk","id","pl"]'
|
||||
|
||||
if [ "$EVENT_NAME" != "push" ]; then
|
||||
echo "has_locales=true" >> "$GITHUB_OUTPUT"
|
||||
echo "locales_json=$all_locales_json" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
before_ref="$BEFORE_SHA"
|
||||
if [ -z "$before_ref" ] || [ "$before_ref" = "0000000000000000000000000000000000000000" ]; then
|
||||
before_ref="$(git rev-parse HEAD^)"
|
||||
fi
|
||||
|
||||
changed_files="$(git diff --name-only "$before_ref" HEAD)"
|
||||
echo "changed files:"
|
||||
printf '%s\n' "$changed_files"
|
||||
|
||||
if printf '%s\n' "$changed_files" | grep -Eq '^(ui/src/i18n/locales/en\.ts|ui/src/i18n/lib/types\.ts|ui/src/i18n/lib/registry\.ts|scripts/control-ui-i18n\.ts|\.github/workflows/control-ui-locale-refresh\.yml)$'; then
|
||||
echo "has_locales=true" >> "$GITHUB_OUTPUT"
|
||||
echo "locales_json=$all_locales_json" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
locales_json="$(printf '%s\n' "$changed_files" | node <<'EOF'
|
||||
const fs = require("node:fs");
|
||||
const changed = fs.readFileSync(0, "utf8").split(/\r?\n/).filter(Boolean);
|
||||
const locales = new Set();
|
||||
for (const file of changed) {
|
||||
let match = file.match(/^ui\/src\/i18n\/locales\/(.+)\.ts$/);
|
||||
if (match && match[1] !== "en") {
|
||||
locales.add(match[1]);
|
||||
continue;
|
||||
}
|
||||
match = file.match(/^ui\/src\/i18n\/\.i18n\/(.+)\.(?:meta\.json|tm\.jsonl)$/);
|
||||
if (match) {
|
||||
locales.add(match[1]);
|
||||
}
|
||||
}
|
||||
process.stdout.write(JSON.stringify([...locales]));
|
||||
EOF
|
||||
)"
|
||||
|
||||
if [ "$locales_json" = "[]" ]; then
|
||||
echo "has_locales=false" >> "$GITHUB_OUTPUT"
|
||||
echo "locales_json=[]" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "has_locales=true" >> "$GITHUB_OUTPUT"
|
||||
echo "locales_json=$locales_json" >> "$GITHUB_OUTPUT"
|
||||
|
||||
refresh:
|
||||
needs: plan
|
||||
if: github.repository == 'openclaw/openclaw' && needs.plan.outputs.has_locales == 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
locale: ${{ fromJson(needs.plan.outputs.locales_json) }}
|
||||
runs-on: ubuntu-latest
|
||||
name: Refresh ${{ matrix.locale }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: true
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Ensure translation provider secrets exist
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENCLAW_DOCS_I18N_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${OPENAI_API_KEY:-}" ] && [ -z "${ANTHROPIC_API_KEY:-}" ]; then
|
||||
echo "Missing OPENCLAW_DOCS_I18N_OPENAI_API_KEY, OPENAI_API_KEY, or ANTHROPIC_API_KEY secret."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Refresh control UI locale files
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENCLAW_DOCS_I18N_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
OPENCLAW_CONTROL_UI_I18N_MODEL: gpt-5.4
|
||||
OPENCLAW_CONTROL_UI_I18N_THINKING: low
|
||||
run: node --import tsx scripts/control-ui-i18n.ts sync --locale "${{ matrix.locale }}" --write
|
||||
|
||||
- name: Commit and push locale updates
|
||||
env:
|
||||
LOCALE: ${{ matrix.locale }}
|
||||
TARGET_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if git diff --quiet -- ui/src/i18n; then
|
||||
echo "No control UI locale changes for ${LOCALE}."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add -A ui/src/i18n
|
||||
FAST_COMMIT=1 git commit -m "chore(ui): refresh ${LOCALE} control ui locale"
|
||||
|
||||
for attempt in 1 2 3 4 5; do
|
||||
git fetch origin "${TARGET_BRANCH}"
|
||||
git rebase "origin/${TARGET_BRANCH}"
|
||||
if git push origin HEAD:"${TARGET_BRANCH}"; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Push attempt ${attempt} for ${LOCALE} failed; retrying."
|
||||
sleep $((attempt * 2))
|
||||
done
|
||||
|
||||
echo "Failed to push ${LOCALE} locale update after retries."
|
||||
exit 1
|
||||
14
.github/workflows/docker-release.yml
vendored
14
.github/workflows/docker-release.yml
vendored
@@ -2,6 +2,8 @@ name: Docker Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*"
|
||||
paths-ignore:
|
||||
@@ -13,12 +15,12 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Existing release tag to backfill (for example v2026.3.22)
|
||||
description: Existing release tag to backfill (for example v2026.3.13)
|
||||
required: true
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.event_name == 'workflow_dispatch' && format('docker-release-manual-{0}', inputs.tag) || format('docker-release-push-{0}', github.run_id) }}
|
||||
group: docker-release-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
@@ -157,8 +159,6 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha,scope=docker-release-amd64
|
||||
cache-to: type=gha,mode=max,scope=docker-release-amd64
|
||||
tags: ${{ steps.tags.outputs.value }}
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
provenance: false
|
||||
@@ -171,8 +171,6 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha,scope=docker-release-amd64
|
||||
cache-to: type=gha,mode=max,scope=docker-release-amd64
|
||||
build-args: |
|
||||
OPENCLAW_VARIANT=slim
|
||||
tags: ${{ steps.tags.outputs.slim }}
|
||||
@@ -274,8 +272,6 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/arm64
|
||||
cache-from: type=gha,scope=docker-release-arm64
|
||||
cache-to: type=gha,mode=max,scope=docker-release-arm64
|
||||
tags: ${{ steps.tags.outputs.value }}
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
provenance: false
|
||||
@@ -288,8 +284,6 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/arm64
|
||||
cache-from: type=gha,scope=docker-release-arm64
|
||||
cache-to: type=gha,mode=max,scope=docker-release-arm64
|
||||
build-args: |
|
||||
OPENCLAW_VARIANT=slim
|
||||
tags: ${{ steps.tags.outputs.slim }}
|
||||
|
||||
70
.github/workflows/docs-sync-publish.yml
vendored
70
.github/workflows/docs-sync-publish.yml
vendored
@@ -1,70 +0,0 @@
|
||||
name: Docs Sync Publish Repo
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- docs/**
|
||||
- scripts/docs-sync-publish.mjs
|
||||
- .github/workflows/docs-sync-publish.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync-publish-repo:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout source repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Clone publish repo
|
||||
env:
|
||||
OPENCLAW_DOCS_SYNC_TOKEN: ${{ secrets.OPENCLAW_DOCS_SYNC_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git clone \
|
||||
"https://x-access-token:${OPENCLAW_DOCS_SYNC_TOKEN}@github.com/openclaw/docs.git" \
|
||||
publish
|
||||
|
||||
- name: Sync docs into publish repo
|
||||
run: |
|
||||
node scripts/docs-sync-publish.mjs \
|
||||
--target "$GITHUB_WORKSPACE/publish" \
|
||||
--source-repo "$GITHUB_REPOSITORY" \
|
||||
--source-sha "$GITHUB_SHA"
|
||||
|
||||
- name: Commit publish repo sync
|
||||
working-directory: publish
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if git diff --quiet -- docs .openclaw-sync; then
|
||||
echo "No publish-repo changes."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git config user.name "openclaw-docs-sync[bot]"
|
||||
git config user.email "openclaw-docs-sync[bot]@users.noreply.github.com"
|
||||
git add docs .openclaw-sync
|
||||
git commit -m "chore(sync): mirror docs from $GITHUB_REPOSITORY@$GITHUB_SHA"
|
||||
for attempt in 1 2 3 4 5; do
|
||||
git fetch origin main
|
||||
git rebase origin/main
|
||||
if git push origin HEAD:main; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Push attempt ${attempt} failed; retrying."
|
||||
sleep $((attempt * 2))
|
||||
done
|
||||
|
||||
echo "Failed to push publish-repo sync after retries."
|
||||
exit 1
|
||||
@@ -1,42 +0,0 @@
|
||||
name: Docs Trigger Locale Translate On Release
|
||||
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
dispatch-translate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger locale translates in publish repo
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.OPENCLAW_DOCS_SYNC_TOKEN }}
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for event_type in \
|
||||
translate-zh-cn-release \
|
||||
translate-ja-jp-release \
|
||||
translate-es-release \
|
||||
translate-pt-br-release \
|
||||
translate-ko-release \
|
||||
translate-de-release \
|
||||
translate-fr-release \
|
||||
translate-ar-release \
|
||||
translate-it-release \
|
||||
translate-tr-release \
|
||||
translate-uk-release \
|
||||
translate-id-release \
|
||||
translate-pl-release
|
||||
do
|
||||
gh api repos/openclaw/docs/dispatches \
|
||||
--method POST \
|
||||
-f event_type="${event_type}" \
|
||||
-f client_payload[release_tag]="${RELEASE_TAG}" \
|
||||
-f client_payload[source_repository]="${GITHUB_REPOSITORY}" \
|
||||
-f client_payload[source_sha]="${GITHUB_SHA}"
|
||||
done
|
||||
134
.github/workflows/install-smoke.yml
vendored
134
.github/workflows/install-smoke.yml
vendored
@@ -4,89 +4,41 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.event_name == 'pull_request' && format('{0}-{1}', github.workflow, github.event.pull_request.number) || format('{0}-{1}', github.workflow, github.run_id) }}
|
||||
group: install-smoke-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
docs-scope:
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
outputs:
|
||||
docs_only: ${{ steps.manifest.outputs.docs_only }}
|
||||
run_install_smoke: ${{ steps.manifest.outputs.run_install_smoke }}
|
||||
docs_only: ${{ steps.check.outputs.docs_only }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
fetch-tags: false
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Ensure preflight base commit
|
||||
- name: Ensure docs-scope base commit
|
||||
uses: ./.github/actions/ensure-base-commit
|
||||
with:
|
||||
base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
|
||||
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}
|
||||
|
||||
- name: Detect docs-only changes
|
||||
id: docs_scope
|
||||
id: check
|
||||
uses: ./.github/actions/detect-docs-changes
|
||||
|
||||
- name: Detect changed smoke scope
|
||||
id: changed_scope
|
||||
if: steps.docs_scope.outputs.docs_only != 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
BASE="${{ github.event.before }}"
|
||||
else
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
fi
|
||||
|
||||
node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD
|
||||
|
||||
- name: Setup Node environment
|
||||
if: steps.docs_scope.outputs.docs_only != 'true'
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
install-deps: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Build install-smoke CI manifest
|
||||
id: manifest
|
||||
env:
|
||||
OPENCLAW_CI_DOCS_ONLY: ${{ steps.docs_scope.outputs.docs_only }}
|
||||
OPENCLAW_CI_RUN_CHANGED_SMOKE: ${{ steps.changed_scope.outputs.run_changed_smoke || 'false' }}
|
||||
run: |
|
||||
docs_only="${OPENCLAW_CI_DOCS_ONLY:-false}"
|
||||
run_changed_smoke="${OPENCLAW_CI_RUN_CHANGED_SMOKE:-false}"
|
||||
run_install_smoke=false
|
||||
if [ "$docs_only" != "true" ] && [ "$run_changed_smoke" = "true" ]; then
|
||||
run_install_smoke=true
|
||||
fi
|
||||
{
|
||||
echo "docs_only=$docs_only"
|
||||
echo "run_install_smoke=$run_install_smoke"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
install-smoke:
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_install_smoke == 'true'
|
||||
needs: [docs-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: "false"
|
||||
DOCKER_BUILD_RECORD_UPLOAD: "false"
|
||||
steps:
|
||||
- name: Checkout CLI
|
||||
uses: actions/checkout@v6
|
||||
@@ -101,8 +53,6 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
build-args: |
|
||||
OPENCLAW_DOCKER_APT_UPGRADE=0
|
||||
tags: openclaw-dockerfile-smoke:local
|
||||
load: true
|
||||
push: false
|
||||
@@ -112,66 +62,24 @@ jobs:
|
||||
run: |
|
||||
docker run --rm --entrypoint sh openclaw-dockerfile-smoke:local -lc 'which openclaw && openclaw --version'
|
||||
|
||||
# This smoke validates that the build-arg path preinstalls the matrix
|
||||
# runtime deps declared by the plugin and that matrix discovery stays
|
||||
# healthy in the final runtime image.
|
||||
# This smoke only validates that the build-arg path preinstalls selected
|
||||
# extension deps without breaking image build or basic CLI startup. It
|
||||
# does not exercise runtime loading/registration of diagnostics-otel.
|
||||
- name: Build extension Dockerfile smoke image
|
||||
uses: useblacksmith/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
build-args: |
|
||||
OPENCLAW_DOCKER_APT_UPGRADE=0
|
||||
OPENCLAW_EXTENSIONS=matrix
|
||||
OPENCLAW_EXTENSIONS=diagnostics-otel
|
||||
tags: openclaw-ext-smoke:local
|
||||
load: true
|
||||
push: false
|
||||
provenance: false
|
||||
|
||||
- name: Smoke test Dockerfile with matrix extension build arg
|
||||
- name: Smoke test Dockerfile with extension build arg
|
||||
run: |
|
||||
docker run --rm --entrypoint sh openclaw-ext-smoke:local -lc '
|
||||
which openclaw &&
|
||||
openclaw --version &&
|
||||
node -e "
|
||||
const Module = require(\"node:module\");
|
||||
const matrixPackage = require(\"/app/extensions/matrix/package.json\");
|
||||
const requireFromMatrix = Module.createRequire(\"/app/extensions/matrix/package.json\");
|
||||
const runtimeDeps = Object.keys(matrixPackage.dependencies ?? {});
|
||||
if (runtimeDeps.length === 0) {
|
||||
throw new Error(
|
||||
\"matrix package has no declared runtime dependencies; smoke cannot validate install mirroring\",
|
||||
);
|
||||
}
|
||||
for (const dep of runtimeDeps) {
|
||||
requireFromMatrix.resolve(dep);
|
||||
}
|
||||
const { spawnSync } = require(\"node:child_process\");
|
||||
const run = spawnSync(\"openclaw\", [\"plugins\", \"list\", \"--json\"], { encoding: \"utf8\" });
|
||||
if (run.status !== 0) {
|
||||
process.stderr.write(run.stderr || run.stdout || \"plugins list failed\\n\");
|
||||
process.exit(run.status ?? 1);
|
||||
}
|
||||
const parsed = JSON.parse(run.stdout);
|
||||
const matrix = (parsed.plugins || []).find((entry) => entry.id === \"matrix\");
|
||||
if (!matrix) {
|
||||
throw new Error(\"matrix plugin missing from bundled plugin list\");
|
||||
}
|
||||
const matrixDiag = (parsed.diagnostics || []).filter(
|
||||
(diag) =>
|
||||
typeof diag.source === \"string\" &&
|
||||
diag.source.includes(\"/extensions/matrix\") &&
|
||||
typeof diag.message === \"string\" &&
|
||||
diag.message.includes(\"extension entry escapes package directory\"),
|
||||
);
|
||||
if (matrixDiag.length > 0) {
|
||||
throw new Error(
|
||||
\"unexpected matrix diagnostics: \" +
|
||||
matrixDiag.map((diag) => diag.message).join(\"; \"),
|
||||
);
|
||||
}
|
||||
"
|
||||
'
|
||||
docker run --rm --entrypoint sh openclaw-ext-smoke:local -lc 'which openclaw && openclaw --version'
|
||||
|
||||
- name: Build installer smoke image
|
||||
uses: useblacksmith/build-push-action@v2
|
||||
@@ -196,12 +104,12 @@ jobs:
|
||||
|
||||
- name: Run installer docker tests
|
||||
env:
|
||||
OPENCLAW_INSTALL_URL: https://openclaw.ai/install.sh
|
||||
OPENCLAW_INSTALL_CLI_URL: https://openclaw.ai/install-cli.sh
|
||||
OPENCLAW_NO_ONBOARD: "1"
|
||||
OPENCLAW_INSTALL_SMOKE_SKIP_CLI: "1"
|
||||
OPENCLAW_INSTALL_SMOKE_SKIP_IMAGE_BUILD: "1"
|
||||
OPENCLAW_INSTALL_NONROOT_SKIP_IMAGE_BUILD: ${{ github.event_name == 'pull_request' && '0' || '1' }}
|
||||
OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT: ${{ github.event_name == 'pull_request' && '1' || '0' }}
|
||||
OPENCLAW_INSTALL_SMOKE_SKIP_PREVIOUS: "1"
|
||||
CLAWDBOT_INSTALL_URL: https://openclaw.ai/install.sh
|
||||
CLAWDBOT_INSTALL_CLI_URL: https://openclaw.ai/install-cli.sh
|
||||
CLAWDBOT_NO_ONBOARD: "1"
|
||||
CLAWDBOT_INSTALL_SMOKE_SKIP_CLI: "1"
|
||||
CLAWDBOT_INSTALL_SMOKE_SKIP_IMAGE_BUILD: "1"
|
||||
CLAWDBOT_INSTALL_NONROOT_SKIP_IMAGE_BUILD: ${{ github.event_name == 'pull_request' && '0' || '1' }}
|
||||
CLAWDBOT_INSTALL_SMOKE_SKIP_NONROOT: ${{ github.event_name == 'pull_request' && '1' || '0' }}
|
||||
CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS: "1"
|
||||
run: bash scripts/test-install-sh-docker.sh
|
||||
|
||||
164
.github/workflows/labeler.yml
vendored
164
.github/workflows/labeler.yml
vendored
@@ -2,9 +2,9 @@ name: Labeler
|
||||
|
||||
on:
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers] maintainer-owned triage workflow; no untrusted checkout or PR code execution
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
types: [opened, synchronize, reopened]
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
types: [opened]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
max_prs:
|
||||
@@ -19,10 +19,6 @@ on:
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request_target' }}
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
@@ -209,59 +205,6 @@ jobs:
|
||||
// labels: [trustedLabel],
|
||||
// });
|
||||
// }
|
||||
- name: Apply beta-blocker title label
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
script: |
|
||||
const pullRequest = context.payload.pull_request;
|
||||
if (!pullRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
const labelName = "beta-blocker";
|
||||
const matchesBetaBlocker = /\bbeta blocker\b/i.test(pullRequest.title ?? "");
|
||||
|
||||
try {
|
||||
await github.rest.issues.getLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: labelName,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
core.info(`Skipping ${labelName} labeling because the label does not exist in the repository.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
per_page: 100,
|
||||
});
|
||||
const hasLabel = currentLabels.some((label) => label.name === labelName);
|
||||
|
||||
if (matchesBetaBlocker && !hasLabel) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
labels: [labelName],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!matchesBetaBlocker && hasLabel) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
name: labelName,
|
||||
});
|
||||
}
|
||||
- name: Apply too-many-prs label
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
@@ -472,7 +415,6 @@ jobs:
|
||||
const maxCount = processAll ? Number.POSITIVE_INFINITY : Math.max(1, maxPrs);
|
||||
|
||||
const sizeLabels = ["size: XS", "size: S", "size: M", "size: L", "size: XL"];
|
||||
const betaBlockerLabel = "beta-blocker";
|
||||
const labelColor = "b76e79";
|
||||
// const trustedLabel = "trusted-contributor";
|
||||
// const experiencedLabel = "experienced-contributor";
|
||||
@@ -503,22 +445,6 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
async function hasBetaBlockerLabel() {
|
||||
try {
|
||||
await github.rest.issues.getLabel({
|
||||
owner,
|
||||
repo,
|
||||
name: betaBlockerLabel,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveContributorLabel(login) {
|
||||
if (contributorCache.has(login)) {
|
||||
return contributorCache.get(login);
|
||||
@@ -650,37 +576,7 @@ jobs:
|
||||
labelNames.add(label);
|
||||
}
|
||||
|
||||
async function applyBetaBlockerTitleLabel(pullRequest, labelNames) {
|
||||
const matchesBetaBlocker = /\bbeta blocker\b/i.test(pullRequest.title ?? "");
|
||||
|
||||
if (matchesBetaBlocker) {
|
||||
if (!labelNames.has(betaBlockerLabel)) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pullRequest.number,
|
||||
labels: [betaBlockerLabel],
|
||||
});
|
||||
labelNames.add(betaBlockerLabel);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!labelNames.has(betaBlockerLabel)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pullRequest.number,
|
||||
name: betaBlockerLabel,
|
||||
});
|
||||
labelNames.delete(betaBlockerLabel);
|
||||
}
|
||||
|
||||
await ensureSizeLabels();
|
||||
const betaBlockerLabelExists = await hasBetaBlockerLabel();
|
||||
|
||||
let page = 1;
|
||||
let processed = 0;
|
||||
@@ -718,9 +614,6 @@ jobs:
|
||||
|
||||
await applySizeLabel(pullRequest, currentLabels, labelNames);
|
||||
await applyContributorLabel(pullRequest, labelNames);
|
||||
if (betaBlockerLabelExists) {
|
||||
await applyBetaBlockerTitleLabel(pullRequest, labelNames);
|
||||
}
|
||||
|
||||
processed += 1;
|
||||
}
|
||||
@@ -822,56 +715,3 @@ jobs:
|
||||
// labels: [trustedLabel],
|
||||
// });
|
||||
// }
|
||||
- name: Apply beta-blocker title label
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
script: |
|
||||
const issue = context.payload.issue;
|
||||
if (!issue || issue.pull_request) {
|
||||
return;
|
||||
}
|
||||
|
||||
const labelName = "beta-blocker";
|
||||
const matchesBetaBlocker = /^beta blocker:/i.test(issue.title ?? "");
|
||||
|
||||
try {
|
||||
await github.rest.issues.getLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: labelName,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
core.info(`Skipping ${labelName} labeling because the label does not exist in the repository.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
per_page: 100,
|
||||
});
|
||||
const hasLabel = currentLabels.some((label) => label.name === labelName);
|
||||
|
||||
if (matchesBetaBlocker && !hasLabel) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: [labelName],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!matchesBetaBlocker && hasLabel) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
name: labelName,
|
||||
});
|
||||
}
|
||||
|
||||
93
.github/workflows/macos-release.yml
vendored
93
.github/workflows/macos-release.yml
vendored
@@ -1,93 +0,0 @@
|
||||
name: macOS Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Existing release tag to validate for macOS release handoff (for example v2026.3.22 or v2026.3.22-beta.1)
|
||||
required: true
|
||||
type: string
|
||||
preflight_only:
|
||||
description: Retained for operator compatibility; this public workflow is validation-only
|
||||
required: true
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: macos-release-${{ inputs.tag }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.x"
|
||||
PNPM_VERSION: "10.32.1"
|
||||
|
||||
jobs:
|
||||
validate_macos_release_request:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Validate tag input format
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-beta\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then
|
||||
echo "Invalid release tag format: ${RELEASE_TAG}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout selected tag
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: refs/tags/${{ inputs.tag }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Ensure matching GitHub release exists
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Build Control UI
|
||||
run: pnpm ui:build
|
||||
|
||||
- name: Validate release tag and package metadata
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
RELEASE_MAIN_REF: origin/main
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RELEASE_SHA=$(git rev-parse HEAD)
|
||||
export RELEASE_SHA RELEASE_TAG RELEASE_MAIN_REF
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
pnpm release:openclaw:npm:check
|
||||
|
||||
- name: Summarize next step
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
{
|
||||
echo "## Public macOS validation only"
|
||||
echo
|
||||
echo "This workflow validates the public release handoff and still builds JS artifacts needed for release checks."
|
||||
echo "It does not sign, notarize, or upload macOS assets."
|
||||
echo
|
||||
echo "Next step:"
|
||||
echo "- Run \`openclaw/releases-private/.github/workflows/openclaw-macos-validate.yml\` with tag \`${RELEASE_TAG}\` and wait for the private mac validation lane to pass."
|
||||
echo "- Run \`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml\` with tag \`${RELEASE_TAG}\` and \`preflight_only=true\` for the full private mac preflight."
|
||||
echo "- For the real publish path, run the same private mac publish workflow from \`main\` with the successful private preflight \`preflight_run_id\` so it promotes the prepared artifacts instead of rebuilding them."
|
||||
echo "- For stable releases, also download \`macos-appcast-${RELEASE_TAG}\` from the successful private run and commit \`appcast.xml\` back to \`main\` in \`openclaw/openclaw\`."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
510
.github/workflows/openclaw-npm-release.yml
vendored
510
.github/workflows/openclaw-npm-release.yml
vendored
@@ -1,71 +1,147 @@
|
||||
name: OpenClaw NPM Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Release tag to publish (for example v2026.3.22, v2026.3.22-beta.1, or fallback v2026.3.22-1)
|
||||
description: Release tag to publish (for example v2026.3.14, v2026.3.14-beta.1, or fallback v2026.3.14-1)
|
||||
required: true
|
||||
type: string
|
||||
preflight_only:
|
||||
description: Run validation/build only and skip the gated publish job
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
preflight_run_id:
|
||||
description: Existing successful preflight workflow run id to promote without rebuilding
|
||||
required: false
|
||||
type: string
|
||||
npm_dist_tag:
|
||||
description: npm dist-tag to publish to for stable releases
|
||||
required: true
|
||||
default: beta
|
||||
type: choice
|
||||
options:
|
||||
- beta
|
||||
- latest
|
||||
promote_beta_to_latest:
|
||||
description: Skip publish and promote the stable version already on npm beta to latest
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: openclaw-npm-release-${{ github.event_name == 'workflow_dispatch' && format('{0}-{1}-{2}', inputs.tag, inputs.npm_dist_tag, inputs.promote_beta_to_latest) || github.ref }}
|
||||
group: openclaw-npm-release-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.x"
|
||||
PNPM_VERSION: "10.32.1"
|
||||
PNPM_VERSION: "10.23.0"
|
||||
|
||||
jobs:
|
||||
preflight_openclaw_npm:
|
||||
if: ${{ inputs.preflight_only && !inputs.promote_beta_to_latest }}
|
||||
preview_openclaw_npm:
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Print release plan
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RELEASE_SHA=$(git rev-parse HEAD)
|
||||
PACKAGE_VERSION=$(node -p "require('./package.json').version")
|
||||
if [[ "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*-[1-9][0-9]*$ ]]; then
|
||||
TAG_KIND="fallback correction"
|
||||
else
|
||||
TAG_KIND="standard"
|
||||
fi
|
||||
echo "Release plan for ${RELEASE_TAG}:"
|
||||
echo "Resolved release SHA: ${RELEASE_SHA}"
|
||||
echo "Resolved package version: ${PACKAGE_VERSION}"
|
||||
echo "Resolved tag kind: ${TAG_KIND}"
|
||||
if [[ "${TAG_KIND}" == "fallback correction" ]]; then
|
||||
echo "Correction tag note: npm version remains ${PACKAGE_VERSION}"
|
||||
fi
|
||||
echo "Would run: git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main"
|
||||
echo "Would run with env: RELEASE_SHA=${RELEASE_SHA} RELEASE_TAG=${RELEASE_TAG} RELEASE_MAIN_REF=origin/main pnpm release:openclaw:npm:check"
|
||||
echo "Would run: npm view openclaw@${PACKAGE_VERSION} version"
|
||||
echo "Would run: pnpm check"
|
||||
echo "Would run: pnpm build"
|
||||
echo "Would run: pnpm release:check"
|
||||
|
||||
- name: Validate release tag and package metadata
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
RELEASE_MAIN_REF: origin/main
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
RELEASE_SHA=$(git rev-parse HEAD)
|
||||
export RELEASE_SHA RELEASE_TAG RELEASE_MAIN_REF
|
||||
# Fetch the full main ref so merge-base ancestry checks keep working
|
||||
# for older tagged commits that are still contained in main.
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
pnpm release:openclaw:npm:check
|
||||
|
||||
- name: Ensure version is not already published
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
PACKAGE_VERSION=$(node -p "require('./package.json').version")
|
||||
IS_CORRECTION_TAG=0
|
||||
if [[ "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*-[1-9][0-9]*$ ]]; then
|
||||
IS_CORRECTION_TAG=1
|
||||
fi
|
||||
|
||||
if npm view "openclaw@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
|
||||
if [[ "${IS_CORRECTION_TAG}" == "1" ]]; then
|
||||
echo "openclaw@${PACKAGE_VERSION} is already published on npm."
|
||||
echo "Correction tag ${RELEASE_TAG} is allowed as a fallback release tag, so preview will continue without treating this as an error."
|
||||
exit 0
|
||||
fi
|
||||
echo "openclaw@${PACKAGE_VERSION} is already published on npm."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${IS_CORRECTION_TAG}" == "1" ]]; then
|
||||
echo "Previewing fallback correction tag ${RELEASE_TAG} for npm version openclaw@${PACKAGE_VERSION}"
|
||||
else
|
||||
echo "Previewing openclaw@${PACKAGE_VERSION}"
|
||||
fi
|
||||
|
||||
- name: Check
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
pnpm check
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
pnpm build
|
||||
|
||||
- name: Verify release contents
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
pnpm release:check
|
||||
|
||||
- name: Preview publish command
|
||||
run: bash scripts/openclaw-npm-publish.sh --dry-run
|
||||
|
||||
publish_openclaw_npm:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
# npm trusted publishing + provenance requires a GitHub-hosted runner.
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm-release
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Validate tag input format
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-beta\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then
|
||||
echo "Invalid release tag format: ${RELEASE_TAG}"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${RELEASE_TAG}" == *"-beta."* && "${RELEASE_NPM_DIST_TAG}" != "beta" ]]; then
|
||||
echo "Beta prerelease tags must publish to npm dist-tag beta."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Forbid preflight artifact promotion on validation-only runs
|
||||
if: ${{ inputs.preflight_only && inputs.preflight_run_id != '' }}
|
||||
run: |
|
||||
echo "preflight_run_id is only valid for real publish runs."
|
||||
exit 1
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
@@ -81,18 +157,25 @@ jobs:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Ensure version is not already published
|
||||
- name: Validate release tag and package metadata
|
||||
env:
|
||||
PREFLIGHT_ONLY: ${{ inputs.preflight_only }}
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
RELEASE_MAIN_REF: origin/main
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RELEASE_SHA=$(git rev-parse HEAD)
|
||||
export RELEASE_SHA RELEASE_TAG RELEASE_MAIN_REF
|
||||
# Fetch the full main ref so merge-base ancestry checks keep working
|
||||
# for older tagged commits that are still contained in main.
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
pnpm release:openclaw:npm:check
|
||||
|
||||
- name: Ensure version is not already published
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE_VERSION=$(node -p "require('./package.json').version")
|
||||
|
||||
if npm view "openclaw@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
|
||||
if [[ "${PREFLIGHT_ONLY}" == "true" ]]; then
|
||||
echo "openclaw@${PACKAGE_VERSION} is already published on npm; continuing because preflight_only=true."
|
||||
exit 0
|
||||
fi
|
||||
echo "openclaw@${PACKAGE_VERSION} is already published on npm."
|
||||
exit 1
|
||||
fi
|
||||
@@ -100,350 +183,13 @@ jobs:
|
||||
echo "Publishing openclaw@${PACKAGE_VERSION}"
|
||||
|
||||
- name: Check
|
||||
env:
|
||||
OPENCLAW_LOCAL_CHECK: "0"
|
||||
run: pnpm check
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Build Control UI
|
||||
run: pnpm ui:build
|
||||
|
||||
- name: Validate release tag and package metadata
|
||||
if: ${{ inputs.preflight_run_id == '' }}
|
||||
env:
|
||||
OPENCLAW_NPM_RELEASE_SKIP_PACK_CHECK: "1"
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
RELEASE_MAIN_REF: origin/main
|
||||
OPENCLAW_NPM_PUBLISH_TAG: ${{ inputs.npm_dist_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RELEASE_SHA=$(git rev-parse HEAD)
|
||||
export RELEASE_SHA RELEASE_TAG RELEASE_MAIN_REF
|
||||
# Fetch the full main ref so merge-base ancestry checks keep working
|
||||
# for older tagged commits that are still contained in main.
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
pnpm release:openclaw:npm:check
|
||||
|
||||
- name: Verify release contents
|
||||
run: pnpm release:check
|
||||
|
||||
- name: Validate live cache credentials
|
||||
if: ${{ github.ref == 'refs/heads/main' }}
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${OPENAI_API_KEY}" ]]; then
|
||||
echo "Missing OPENAI_API_KEY secret for release live cache validation." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "${ANTHROPIC_API_KEY}" ]]; then
|
||||
echo "Missing ANTHROPIC_API_KEY secret for release live cache validation." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify live prompt cache floors
|
||||
if: ${{ github.ref == 'refs/heads/main' }}
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENCLAW_LIVE_CACHE_TEST: "1"
|
||||
OPENCLAW_LIVE_TEST: "1"
|
||||
run: pnpm test:live:cache
|
||||
|
||||
- name: Pack prepared npm tarball
|
||||
id: packed_tarball
|
||||
env:
|
||||
OPENCLAW_PREPACK_PREPARED: "1"
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACK_JSON="$(npm pack --json)"
|
||||
echo "$PACK_JSON"
|
||||
PACK_PATH="$(printf '%s\n' "$PACK_JSON" | node -e 'const chunks=[]; process.stdin.on("data", (chunk) => chunks.push(chunk)); process.stdin.on("end", () => { const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")); const first = Array.isArray(parsed) ? parsed[0] : null; if (!first || typeof first.filename !== "string" || !first.filename) { process.exit(1); } process.stdout.write(first.filename); });')"
|
||||
if [[ -z "$PACK_PATH" || ! -f "$PACK_PATH" ]]; then
|
||||
echo "npm pack did not produce a tarball file." >&2
|
||||
exit 1
|
||||
fi
|
||||
RELEASE_SHA="$(git rev-parse HEAD)"
|
||||
ARTIFACT_DIR="$RUNNER_TEMP/openclaw-npm-preflight"
|
||||
rm -rf "$ARTIFACT_DIR"
|
||||
mkdir -p "$ARTIFACT_DIR"
|
||||
cp "$PACK_PATH" "$ARTIFACT_DIR/"
|
||||
printf '%s\n' "$RELEASE_TAG" > "$ARTIFACT_DIR/release-tag.txt"
|
||||
printf '%s\n' "$RELEASE_SHA" > "$ARTIFACT_DIR/release-sha.txt"
|
||||
printf '%s\n' "$RELEASE_NPM_DIST_TAG" > "$ARTIFACT_DIR/release-npm-dist-tag.txt"
|
||||
echo "dir=$ARTIFACT_DIR" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload prepared npm publish bundle
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: openclaw-npm-preflight-${{ inputs.tag }}
|
||||
path: ${{ steps.packed_tarball.outputs.dir }}
|
||||
if-no-files-found: error
|
||||
|
||||
validate_publish_request:
|
||||
if: ${{ !inputs.preflight_only && !inputs.promote_beta_to_latest }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Require main workflow ref for publish
|
||||
env:
|
||||
WORKFLOW_REF: ${{ github.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]]; then
|
||||
echo "Real publish runs must be dispatched from main. Use preflight_only=true for branch validation."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Require preflight artifact promotion on real publish
|
||||
env:
|
||||
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${PREFLIGHT_RUN_ID}" ]]; then
|
||||
echo "Real publish requires preflight_run_id from a successful npm preflight run." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
publish_openclaw_npm:
|
||||
# npm trusted publishing + provenance requires a GitHub-hosted runner.
|
||||
needs: [validate_publish_request]
|
||||
if: ${{ !inputs.preflight_only && !inputs.promote_beta_to_latest }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm-release
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Validate tag input format
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-beta\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then
|
||||
echo "Invalid release tag format: ${RELEASE_TAG}"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${RELEASE_TAG}" == *"-beta."* && "${RELEASE_NPM_DIST_TAG}" != "beta" ]]; then
|
||||
echo "Beta prerelease tags must publish to npm dist-tag beta."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: refs/tags/${{ inputs.tag }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Ensure version is not already published
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE_VERSION=$(node -p "require('./package.json').version")
|
||||
|
||||
if npm view "openclaw@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
|
||||
echo "openclaw@${PACKAGE_VERSION} is already published on npm."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Publishing openclaw@${PACKAGE_VERSION}"
|
||||
|
||||
- name: Verify preflight run metadata
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RUN_JSON="$(gh run view "$PREFLIGHT_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,conclusion,url)"
|
||||
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "OpenClaw NPM Release"], ["headBranch", "main"], ["event", "workflow_dispatch"], ["conclusion", "success"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced npm preflight run ${process.env.PREFLIGHT_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } console.log(`Using npm preflight run ${process.env.PREFLIGHT_RUN_ID}: ${run.url}`);'
|
||||
|
||||
- name: Download prepared npm tarball
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: openclaw-npm-preflight-${{ inputs.tag }}
|
||||
path: preflight-tarball
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ inputs.preflight_run_id }}
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
- name: Validate release tag and package metadata
|
||||
if: ${{ inputs.preflight_run_id == '' }}
|
||||
env:
|
||||
OPENCLAW_NPM_RELEASE_SKIP_PACK_CHECK: "1"
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
RELEASE_MAIN_REF: origin/main
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RELEASE_SHA=$(git rev-parse HEAD)
|
||||
export RELEASE_SHA RELEASE_TAG RELEASE_MAIN_REF
|
||||
# Fetch the full main ref so merge-base ancestry checks keep working
|
||||
# for older tagged commits that are still contained in main.
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
pnpm release:openclaw:npm:check
|
||||
|
||||
- name: Verify prepared tarball provenance
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
EXPECTED_RELEASE_SHA="$(git rev-parse HEAD)"
|
||||
TAG_FILE="preflight-tarball/release-tag.txt"
|
||||
SHA_FILE="preflight-tarball/release-sha.txt"
|
||||
NPM_DIST_TAG_FILE="preflight-tarball/release-npm-dist-tag.txt"
|
||||
if [[ ! -f "$TAG_FILE" || ! -f "$SHA_FILE" || ! -f "$NPM_DIST_TAG_FILE" ]]; then
|
||||
echo "Prepared preflight metadata is missing." >&2
|
||||
ls -la preflight-tarball >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
ARTIFACT_RELEASE_TAG="$(tr -d '\r\n' < "$TAG_FILE")"
|
||||
ARTIFACT_RELEASE_SHA="$(tr -d '\r\n' < "$SHA_FILE")"
|
||||
ARTIFACT_RELEASE_NPM_DIST_TAG="$(tr -d '\r\n' < "$NPM_DIST_TAG_FILE")"
|
||||
if [[ "$ARTIFACT_RELEASE_TAG" != "$RELEASE_TAG" ]]; then
|
||||
echo "Prepared preflight tag mismatch: expected $RELEASE_TAG, got $ARTIFACT_RELEASE_TAG" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$ARTIFACT_RELEASE_SHA" != "$EXPECTED_RELEASE_SHA" ]]; then
|
||||
echo "Prepared preflight SHA mismatch: expected $EXPECTED_RELEASE_SHA, got $ARTIFACT_RELEASE_SHA" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$ARTIFACT_RELEASE_NPM_DIST_TAG" != "$RELEASE_NPM_DIST_TAG" ]]; then
|
||||
echo "Prepared preflight npm dist-tag mismatch: expected $RELEASE_NPM_DIST_TAG, got $ARTIFACT_RELEASE_NPM_DIST_TAG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Resolve publish tarball
|
||||
id: publish_tarball
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TARBALL_PATH="$(find preflight-tarball -type f -name '*.tgz' -print | sort | tail -n 1)"
|
||||
if [[ -z "$TARBALL_PATH" ]]; then
|
||||
echo "Prepared preflight tarball not found." >&2
|
||||
ls -la preflight-tarball >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
echo "path=$TARBALL_PATH" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Publish
|
||||
env:
|
||||
OPENCLAW_PREPACK_PREPARED: "1"
|
||||
OPENCLAW_NPM_PUBLISH_TAG: ${{ inputs.npm_dist_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
publish_target="${{ steps.publish_tarball.outputs.path }}"
|
||||
if [[ -n "${publish_target}" ]]; then
|
||||
publish_target="./${publish_target}"
|
||||
fi
|
||||
bash scripts/openclaw-npm-publish.sh --publish "${publish_target}"
|
||||
|
||||
promote_beta_to_latest:
|
||||
if: ${{ inputs.promote_beta_to_latest }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm-release
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Require main workflow ref for promotion
|
||||
env:
|
||||
WORKFLOW_REF: ${{ github.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]]; then
|
||||
echo "Promotion runs must be dispatched from main."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate promotion inputs
|
||||
env:
|
||||
PREFLIGHT_ONLY: ${{ inputs.preflight_only }}
|
||||
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
|
||||
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${PREFLIGHT_ONLY}" == "true" ]]; then
|
||||
echo "Promotion mode cannot run with preflight_only=true."
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "${PREFLIGHT_RUN_ID}" ]]; then
|
||||
echo "Promotion mode does not use preflight_run_id."
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${RELEASE_NPM_DIST_TAG}" != "beta" ]]; then
|
||||
echo "Promotion mode expects npm_dist_tag=beta because it moves beta to latest without publishing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate stable tag input format
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*)?$ ]]; then
|
||||
echo "Invalid stable release tag format: ${RELEASE_TAG}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "RELEASE_VERSION=${RELEASE_TAG#v}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
install-deps: "false"
|
||||
|
||||
- name: Validate npm dist-tags
|
||||
env:
|
||||
RELEASE_VERSION: ${{ env.RELEASE_VERSION }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
beta_version="$(npm view openclaw dist-tags.beta)"
|
||||
latest_version="$(npm view openclaw dist-tags.latest)"
|
||||
|
||||
echo "Current beta dist-tag: ${beta_version}"
|
||||
echo "Current latest dist-tag: ${latest_version}"
|
||||
|
||||
if [[ "${beta_version}" != "${RELEASE_VERSION}" ]]; then
|
||||
echo "npm beta points at ${beta_version}, expected ${RELEASE_VERSION}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! npm view "openclaw@${RELEASE_VERSION}" version >/dev/null 2>&1; then
|
||||
echo "openclaw@${RELEASE_VERSION} is not published on npm." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Promote beta to latest
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
RELEASE_VERSION: ${{ env.RELEASE_VERSION }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm whoami >/dev/null
|
||||
npm dist-tag add "openclaw@${RELEASE_VERSION}" latest
|
||||
promoted_latest="$(npm view openclaw dist-tags.latest)"
|
||||
if [[ "${promoted_latest}" != "${RELEASE_VERSION}" ]]; then
|
||||
echo "npm latest points at ${promoted_latest}, expected ${RELEASE_VERSION} after promotion." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Promoted openclaw@${RELEASE_VERSION} from beta to latest."
|
||||
run: bash scripts/openclaw-npm-publish.sh --publish
|
||||
|
||||
276
.github/workflows/plugin-clawhub-release.yml
vendored
276
.github/workflows/plugin-clawhub-release.yml
vendored
@@ -1,276 +0,0 @@
|
||||
name: Plugin ClawHub Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish_scope:
|
||||
description: Publish the selected plugins or all ClawHub-publishable plugins from the workflow ref
|
||||
required: true
|
||||
default: selected
|
||||
type: choice
|
||||
options:
|
||||
- selected
|
||||
- all-publishable
|
||||
plugins:
|
||||
description: Comma-separated plugin package names to publish when publish_scope=selected
|
||||
required: false
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: plugin-clawhub-release-${{ github.sha }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.x"
|
||||
PNPM_VERSION: "10.32.1"
|
||||
CLAWHUB_REGISTRY: "https://clawhub.ai"
|
||||
CLAWHUB_REPOSITORY: "openclaw/clawhub"
|
||||
# Pinned to a reviewed ClawHub commit so release behavior stays reproducible.
|
||||
CLAWHUB_REF: "4af2bd50a71465683dbf8aa269af764b9d39bdf5"
|
||||
|
||||
jobs:
|
||||
preview_plugins_clawhub:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
ref_sha: ${{ steps.ref.outputs.sha }}
|
||||
has_candidates: ${{ steps.plan.outputs.has_candidates }}
|
||||
candidate_count: ${{ steps.plan.outputs.candidate_count }}
|
||||
skipped_published_count: ${{ steps.plan.outputs.skipped_published_count }}
|
||||
matrix: ${{ steps.plan.outputs.matrix }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Resolve checked-out ref
|
||||
id: ref
|
||||
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate ref is on main
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
git merge-base --is-ancestor HEAD origin/main
|
||||
|
||||
- name: Validate publishable plugin metadata
|
||||
env:
|
||||
PUBLISH_SCOPE: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_scope || '' }}
|
||||
RELEASE_PLUGINS: ${{ github.event_name == 'workflow_dispatch' && inputs.plugins || '' }}
|
||||
BASE_REF: ${{ github.event_name != 'workflow_dispatch' && github.event.before || '' }}
|
||||
HEAD_REF: ${{ steps.ref.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -n "${PUBLISH_SCOPE}" ]]; then
|
||||
release_args=(--selection-mode "${PUBLISH_SCOPE}")
|
||||
if [[ -n "${RELEASE_PLUGINS}" ]]; then
|
||||
release_args+=(--plugins "${RELEASE_PLUGINS}")
|
||||
fi
|
||||
pnpm release:plugins:clawhub:check -- "${release_args[@]}"
|
||||
elif [[ -n "${BASE_REF}" ]]; then
|
||||
pnpm release:plugins:clawhub:check -- --base-ref "${BASE_REF}" --head-ref "${HEAD_REF}"
|
||||
else
|
||||
pnpm release:plugins:clawhub:check
|
||||
fi
|
||||
|
||||
- name: Resolve plugin release plan
|
||||
id: plan
|
||||
env:
|
||||
PUBLISH_SCOPE: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_scope || '' }}
|
||||
RELEASE_PLUGINS: ${{ github.event_name == 'workflow_dispatch' && inputs.plugins || '' }}
|
||||
BASE_REF: ${{ github.event_name != 'workflow_dispatch' && github.event.before || '' }}
|
||||
HEAD_REF: ${{ steps.ref.outputs.sha }}
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p .local
|
||||
if [[ -n "${PUBLISH_SCOPE}" ]]; then
|
||||
plan_args=(--selection-mode "${PUBLISH_SCOPE}")
|
||||
if [[ -n "${RELEASE_PLUGINS}" ]]; then
|
||||
plan_args+=(--plugins "${RELEASE_PLUGINS}")
|
||||
fi
|
||||
node --import tsx scripts/plugin-clawhub-release-plan.ts "${plan_args[@]}" > .local/plugin-clawhub-release-plan.json
|
||||
elif [[ -n "${BASE_REF}" ]]; then
|
||||
node --import tsx scripts/plugin-clawhub-release-plan.ts --base-ref "${BASE_REF}" --head-ref "${HEAD_REF}" > .local/plugin-clawhub-release-plan.json
|
||||
else
|
||||
node --import tsx scripts/plugin-clawhub-release-plan.ts > .local/plugin-clawhub-release-plan.json
|
||||
fi
|
||||
|
||||
cat .local/plugin-clawhub-release-plan.json
|
||||
|
||||
candidate_count="$(jq -r '.candidates | length' .local/plugin-clawhub-release-plan.json)"
|
||||
skipped_published_count="$(jq -r '.skippedPublished | length' .local/plugin-clawhub-release-plan.json)"
|
||||
has_candidates="false"
|
||||
if [[ "${candidate_count}" != "0" ]]; then
|
||||
has_candidates="true"
|
||||
fi
|
||||
matrix_json="$(jq -c '.candidates' .local/plugin-clawhub-release-plan.json)"
|
||||
|
||||
{
|
||||
echo "candidate_count=${candidate_count}"
|
||||
echo "skipped_published_count=${skipped_published_count}"
|
||||
echo "has_candidates=${has_candidates}"
|
||||
echo "matrix=${matrix_json}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "Plugin release candidates:"
|
||||
jq -r '.candidates[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"' .local/plugin-clawhub-release-plan.json
|
||||
|
||||
echo "Already published / skipped:"
|
||||
jq -r '.skippedPublished[]? | "- \(.packageName)@\(.version)"' .local/plugin-clawhub-release-plan.json
|
||||
|
||||
- name: Fail manual publish when target versions already exist
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.publish_scope == 'selected' && steps.plan.outputs.skipped_published_count != '0'
|
||||
run: |
|
||||
echo "::error::One or more selected plugin versions already exist on ClawHub. Bump the version before running a real publish."
|
||||
exit 1
|
||||
|
||||
preview_plugin_pack:
|
||||
needs: preview_plugins_clawhub
|
||||
if: needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.preview_plugins_clawhub.outputs.ref_sha }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
use-sticky-disk: "false"
|
||||
install-deps: "false"
|
||||
|
||||
- name: Checkout ClawHub CLI source
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: ${{ env.CLAWHUB_REPOSITORY }}
|
||||
ref: ${{ env.CLAWHUB_REF }}
|
||||
path: clawhub-source
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Install ClawHub CLI dependencies
|
||||
working-directory: clawhub-source
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Bootstrap ClawHub CLI
|
||||
run: |
|
||||
cat > "$RUNNER_TEMP/clawhub" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
exec bun "$GITHUB_WORKSPACE/clawhub-source/packages/clawhub/src/cli.ts" "$@"
|
||||
EOF
|
||||
chmod +x "$RUNNER_TEMP/clawhub"
|
||||
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Preview publish command
|
||||
env:
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
SOURCE_REPO: ${{ github.repository }}
|
||||
SOURCE_COMMIT: ${{ needs.preview_plugins_clawhub.outputs.ref_sha }}
|
||||
SOURCE_REF: ${{ github.ref }}
|
||||
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
|
||||
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
|
||||
run: bash scripts/plugin-clawhub-publish.sh --dry-run "${PACKAGE_DIR}"
|
||||
|
||||
publish_plugins_clawhub:
|
||||
needs: [preview_plugins_clawhub, preview_plugin_pack]
|
||||
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
environment: clawhub-plugin-release
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.preview_plugins_clawhub.outputs.ref_sha }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
use-sticky-disk: "false"
|
||||
install-deps: "false"
|
||||
|
||||
- name: Checkout ClawHub CLI source
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: ${{ env.CLAWHUB_REPOSITORY }}
|
||||
ref: ${{ env.CLAWHUB_REF }}
|
||||
path: clawhub-source
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Install ClawHub CLI dependencies
|
||||
working-directory: clawhub-source
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Bootstrap ClawHub CLI
|
||||
run: |
|
||||
cat > "$RUNNER_TEMP/clawhub" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
exec bun "$GITHUB_WORKSPACE/clawhub-source/packages/clawhub/src/cli.ts" "$@"
|
||||
EOF
|
||||
chmod +x "$RUNNER_TEMP/clawhub"
|
||||
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Ensure version is not already published
|
||||
env:
|
||||
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
|
||||
PACKAGE_VERSION: ${{ matrix.plugin.version }}
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
encoded_name="$(node -e 'console.log(encodeURIComponent(process.env.PACKAGE_NAME ?? ""))')"
|
||||
encoded_version="$(node -e 'console.log(encodeURIComponent(process.env.PACKAGE_VERSION ?? ""))')"
|
||||
url="${CLAWHUB_REGISTRY%/}/api/v1/packages/${encoded_name}/versions/${encoded_version}"
|
||||
status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' "${url}")"
|
||||
if [[ "${status}" =~ ^2 ]]; then
|
||||
echo "${PACKAGE_NAME}@${PACKAGE_VERSION} is already published on ClawHub."
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${status}" != "404" ]]; then
|
||||
echo "Unexpected ClawHub response (${status}) for ${PACKAGE_NAME}@${PACKAGE_VERSION}."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Publish
|
||||
env:
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
SOURCE_REPO: ${{ github.repository }}
|
||||
SOURCE_COMMIT: ${{ needs.preview_plugins_clawhub.outputs.ref_sha }}
|
||||
SOURCE_REF: ${{ github.ref }}
|
||||
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
|
||||
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
|
||||
run: bash scripts/plugin-clawhub-publish.sh --publish "${PACKAGE_DIR}"
|
||||
217
.github/workflows/plugin-npm-release.yml
vendored
217
.github/workflows/plugin-npm-release.yml
vendored
@@ -1,217 +0,0 @@
|
||||
name: Plugin NPM Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- ".github/workflows/plugin-npm-release.yml"
|
||||
- "extensions/**"
|
||||
- "package.json"
|
||||
- "scripts/lib/plugin-npm-release.ts"
|
||||
- "scripts/plugin-npm-publish.sh"
|
||||
- "scripts/plugin-npm-release-check.ts"
|
||||
- "scripts/plugin-npm-release-plan.ts"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish_scope:
|
||||
description: Publish the selected plugins or all publishable plugins from the ref
|
||||
required: true
|
||||
default: selected
|
||||
type: choice
|
||||
options:
|
||||
- selected
|
||||
- all-publishable
|
||||
ref:
|
||||
description: Commit SHA on main to publish from (copy from the preview run)
|
||||
required: true
|
||||
type: string
|
||||
plugins:
|
||||
description: Comma-separated plugin package names to publish when publish_scope=selected
|
||||
required: false
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: plugin-npm-release-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.x"
|
||||
PNPM_VERSION: "10.32.1"
|
||||
|
||||
jobs:
|
||||
preview_plugins_npm:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
ref_sha: ${{ steps.ref.outputs.sha }}
|
||||
has_candidates: ${{ steps.plan.outputs.has_candidates }}
|
||||
candidate_count: ${{ steps.plan.outputs.candidate_count }}
|
||||
matrix: ${{ steps.plan.outputs.matrix }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Resolve checked-out ref
|
||||
id: ref
|
||||
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate ref is on main
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
git merge-base --is-ancestor HEAD origin/main
|
||||
|
||||
- name: Validate publishable plugin metadata
|
||||
env:
|
||||
PUBLISH_SCOPE: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_scope || '' }}
|
||||
RELEASE_PLUGINS: ${{ github.event_name == 'workflow_dispatch' && inputs.plugins || '' }}
|
||||
BASE_REF: ${{ github.event_name != 'workflow_dispatch' && github.event.before || '' }}
|
||||
HEAD_REF: ${{ steps.ref.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -n "${PUBLISH_SCOPE}" ]]; then
|
||||
release_args=(--selection-mode "${PUBLISH_SCOPE}")
|
||||
if [[ -n "${RELEASE_PLUGINS}" ]]; then
|
||||
release_args+=(--plugins "${RELEASE_PLUGINS}")
|
||||
fi
|
||||
pnpm release:plugins:npm:check -- "${release_args[@]}"
|
||||
elif [[ -n "${BASE_REF}" ]]; then
|
||||
pnpm release:plugins:npm:check -- --base-ref "${BASE_REF}" --head-ref "${HEAD_REF}"
|
||||
else
|
||||
pnpm release:plugins:npm:check
|
||||
fi
|
||||
|
||||
- name: Resolve plugin release plan
|
||||
id: plan
|
||||
env:
|
||||
PUBLISH_SCOPE: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_scope || '' }}
|
||||
RELEASE_PLUGINS: ${{ github.event_name == 'workflow_dispatch' && inputs.plugins || '' }}
|
||||
BASE_REF: ${{ github.event_name != 'workflow_dispatch' && github.event.before || '' }}
|
||||
HEAD_REF: ${{ steps.ref.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p .local
|
||||
if [[ -n "${PUBLISH_SCOPE}" ]]; then
|
||||
plan_args=(--selection-mode "${PUBLISH_SCOPE}")
|
||||
if [[ -n "${RELEASE_PLUGINS}" ]]; then
|
||||
plan_args+=(--plugins "${RELEASE_PLUGINS}")
|
||||
fi
|
||||
node --import tsx scripts/plugin-npm-release-plan.ts "${plan_args[@]}" > .local/plugin-npm-release-plan.json
|
||||
elif [[ -n "${BASE_REF}" ]]; then
|
||||
node --import tsx scripts/plugin-npm-release-plan.ts --base-ref "${BASE_REF}" --head-ref "${HEAD_REF}" > .local/plugin-npm-release-plan.json
|
||||
else
|
||||
node --import tsx scripts/plugin-npm-release-plan.ts > .local/plugin-npm-release-plan.json
|
||||
fi
|
||||
|
||||
cat .local/plugin-npm-release-plan.json
|
||||
|
||||
candidate_count="$(jq -r '.candidates | length' .local/plugin-npm-release-plan.json)"
|
||||
has_candidates="false"
|
||||
if [[ "${candidate_count}" != "0" ]]; then
|
||||
has_candidates="true"
|
||||
fi
|
||||
matrix_json="$(jq -c '.candidates' .local/plugin-npm-release-plan.json)"
|
||||
|
||||
{
|
||||
echo "candidate_count=${candidate_count}"
|
||||
echo "has_candidates=${has_candidates}"
|
||||
echo "matrix=${matrix_json}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "Plugin release candidates:"
|
||||
jq -r '.candidates[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"' .local/plugin-npm-release-plan.json
|
||||
|
||||
echo "Already published / skipped:"
|
||||
jq -r '.skippedPublished[]? | "- \(.packageName)@\(.version)"' .local/plugin-npm-release-plan.json
|
||||
|
||||
preview_plugin_pack:
|
||||
needs: preview_plugins_npm
|
||||
if: needs.preview_plugins_npm.outputs.has_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
plugin: ${{ fromJson(needs.preview_plugins_npm.outputs.matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.preview_plugins_npm.outputs.ref_sha }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
install-deps: "false"
|
||||
|
||||
- name: Preview publish command
|
||||
run: bash scripts/plugin-npm-publish.sh --dry-run "${{ matrix.plugin.packageDir }}"
|
||||
|
||||
- name: Preview npm pack contents
|
||||
working-directory: ${{ matrix.plugin.packageDir }}
|
||||
run: npm pack --dry-run --json --ignore-scripts
|
||||
|
||||
publish_plugins_npm:
|
||||
needs: [preview_plugins_npm, preview_plugin_pack]
|
||||
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_npm.outputs.has_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm-release
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
plugin: ${{ fromJson(needs.preview_plugins_npm.outputs.matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.preview_plugins_npm.outputs.ref_sha }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
install-deps: "false"
|
||||
|
||||
- name: Ensure version is not already published
|
||||
env:
|
||||
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
|
||||
PACKAGE_VERSION: ${{ matrix.plugin.version }}
|
||||
run: |
|
||||
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."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Publish
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: bash scripts/plugin-npm-publish.sh --publish "${{ matrix.plugin.packageDir }}"
|
||||
4
.github/workflows/sandbox-common-smoke.yml
vendored
4
.github/workflows/sandbox-common-smoke.yml
vendored
@@ -8,14 +8,13 @@ on:
|
||||
- Dockerfile.sandbox-common
|
||||
- scripts/sandbox-common-setup.sh
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
|
||||
paths:
|
||||
- Dockerfile.sandbox
|
||||
- Dockerfile.sandbox-common
|
||||
- scripts/sandbox-common-setup.sh
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
group: sandbox-common-smoke-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
@@ -23,7 +22,6 @@ env:
|
||||
|
||||
jobs:
|
||||
sandbox-common-smoke:
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
4
.github/workflows/stale.yml
vendored
4
.github/workflows/stale.yml
vendored
@@ -42,7 +42,7 @@ jobs:
|
||||
stale-issue-label: stale
|
||||
stale-pr-label: stale
|
||||
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale
|
||||
exempt-pr-labels: maintainer,no-stale,bad-barnacle
|
||||
exempt-pr-labels: maintainer,no-stale
|
||||
operations-per-run: 2000
|
||||
ascending: true
|
||||
exempt-all-assignees: true
|
||||
@@ -98,7 +98,7 @@ jobs:
|
||||
stale-issue-label: stale
|
||||
stale-pr-label: stale
|
||||
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale
|
||||
exempt-pr-labels: maintainer,no-stale,bad-barnacle
|
||||
exempt-pr-labels: maintainer,no-stale
|
||||
operations-per-run: 2000
|
||||
ascending: true
|
||||
exempt-all-assignees: true
|
||||
|
||||
17
.github/workflows/workflow-sanity.yml
vendored
17
.github/workflows/workflow-sanity.yml
vendored
@@ -7,7 +7,7 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
group: workflow-sanity-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
@@ -60,11 +60,8 @@ jobs:
|
||||
ACTIONLINT_VERSION="1.7.11"
|
||||
archive="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
|
||||
base_url="https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}"
|
||||
# GitHub release downloads occasionally return transient 5xx responses.
|
||||
# Retry all curl errors here so workflow-sanity does not fail closed on
|
||||
# a one-off release edge outage.
|
||||
curl --retry 5 --retry-delay 2 --retry-all-errors -sSfL -o "${archive}" "${base_url}/${archive}"
|
||||
curl --retry 5 --retry-delay 2 --retry-all-errors -sSfL -o checksums.txt "${base_url}/actionlint_${ACTIONLINT_VERSION}_checksums.txt"
|
||||
curl -sSfL -o "${archive}" "${base_url}/${archive}"
|
||||
curl -sSfL -o checksums.txt "${base_url}/actionlint_${ACTIONLINT_VERSION}_checksums.txt"
|
||||
grep " ${archive}\$" checksums.txt | sha256sum -c -
|
||||
tar -xzf "${archive}" actionlint
|
||||
sudo install -m 0755 actionlint /usr/local/bin/actionlint
|
||||
@@ -75,10 +72,7 @@ jobs:
|
||||
- name: Disallow direct inputs interpolation in composite run blocks
|
||||
run: python3 scripts/check-composite-action-input-interpolation.py
|
||||
|
||||
- name: Disallow tracked merge conflict markers
|
||||
run: node scripts/check-no-conflict-markers.mjs
|
||||
|
||||
generated-doc-baselines:
|
||||
config-docs-drift:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
@@ -93,6 +87,3 @@ jobs:
|
||||
|
||||
- name: Check config docs drift statefile
|
||||
run: pnpm config:docs:check
|
||||
|
||||
- name: Check plugin SDK API baseline drift
|
||||
run: pnpm plugin-sdk:api:check
|
||||
|
||||
21
.gitignore
vendored
21
.gitignore
vendored
@@ -4,12 +4,10 @@ node_modules
|
||||
docker-compose.override.yml
|
||||
docker-compose.extra.yml
|
||||
dist
|
||||
dist-runtime/
|
||||
pnpm-lock.yaml
|
||||
bun.lock
|
||||
bun.lockb
|
||||
coverage
|
||||
__openclaw_vitest__/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.tsbuildinfo
|
||||
@@ -31,7 +29,6 @@ apps/android/.gradle/
|
||||
apps/android/app/build/
|
||||
apps/android/.cxx/
|
||||
apps/android/.kotlin/
|
||||
apps/android/benchmark/results/
|
||||
|
||||
# Bun build artifacts
|
||||
*.bun-build
|
||||
@@ -85,7 +82,6 @@ apps/ios/*.mobileprovision
|
||||
# Local untracked files
|
||||
.local/
|
||||
docs/.local/
|
||||
docs/internal/
|
||||
tmp/
|
||||
IDENTITY.md
|
||||
USER.md
|
||||
@@ -102,6 +98,8 @@ USER.md
|
||||
/local/
|
||||
package-lock.json
|
||||
.claude/
|
||||
.agents/
|
||||
.agents
|
||||
.agent/
|
||||
skills-lock.json
|
||||
|
||||
@@ -135,18 +133,3 @@ ui/src/ui/__screenshots__
|
||||
ui/src/ui/views/__screenshots__
|
||||
ui/.vitest-attachments
|
||||
docs/superpowers
|
||||
|
||||
# Generated docs baseline artifacts (locally generated, only hashes tracked)
|
||||
docs/.generated/*.json
|
||||
docs/.generated/*.jsonl
|
||||
|
||||
# Deprecated changelog fragment workflow
|
||||
changelog/fragments/
|
||||
|
||||
# Local scratch workspace
|
||||
.tmp/
|
||||
.artifacts/
|
||||
test/fixtures/openclaw-vitest-unit-report.json
|
||||
analysis/
|
||||
.artifacts/qa-e2e/
|
||||
extensions/qa-lab/web/dist/
|
||||
|
||||
@@ -33,9 +33,6 @@
|
||||
"img",
|
||||
"a",
|
||||
"br",
|
||||
"table",
|
||||
"tr",
|
||||
"td",
|
||||
"details",
|
||||
"summary",
|
||||
"p",
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
**/node_modules/
|
||||
**/.runtime-deps-*/
|
||||
docs/.generated/
|
||||
|
||||
3
.npmrc
3
.npmrc
@@ -1,4 +1 @@
|
||||
# pnpm build-script allowlist lives in package.json -> pnpm.onlyBuiltDependencies.
|
||||
# TS 7 native-preview fails to resolve packages reliably from pnpm's isolated linker.
|
||||
# Keep the workspace on a hoisted layout so pnpm check/build stay stable.
|
||||
node-linker=hoisted
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
docs/.generated/
|
||||
@@ -12314,14 +12314,14 @@
|
||||
"filename": "src/config/schema.help.ts",
|
||||
"hashed_secret": "9f4cda226d3868676ac7f86f59e4190eb94bd208",
|
||||
"is_verified": false,
|
||||
"line_number": 657
|
||||
"line_number": 653
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/config/schema.help.ts",
|
||||
"hashed_secret": "01822c8bbf6a8b136944b14182cb885100ec2eae",
|
||||
"is_verified": false,
|
||||
"line_number": 690
|
||||
"line_number": 686
|
||||
}
|
||||
],
|
||||
"src/config/schema.irc.ts": [
|
||||
@@ -12360,14 +12360,14 @@
|
||||
"filename": "src/config/schema.labels.ts",
|
||||
"hashed_secret": "e73c9fcad85cd4eecc74181ec4bdb31064d68439",
|
||||
"is_verified": false,
|
||||
"line_number": 219
|
||||
"line_number": 217
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/config/schema.labels.ts",
|
||||
"hashed_secret": "2eda7cd978f39eebec3bf03e4410a40e14167fff",
|
||||
"is_verified": false,
|
||||
"line_number": 328
|
||||
"line_number": 326
|
||||
}
|
||||
],
|
||||
"src/config/slack-http-config.test.ts": [
|
||||
|
||||
368
AGENTS.md
368
AGENTS.md
@@ -1,79 +1,59 @@
|
||||
# Repository Guidelines
|
||||
|
||||
- Repo: https://github.com/openclaw/openclaw
|
||||
- In chat replies, file references must be repo-root relative only (example: `src/telegram/index.ts:80`); never absolute paths or `~/...`.
|
||||
- In chat replies, file references must be repo-root relative only (example: `extensions/bluebubbles/src/channel.ts:80`); never absolute paths or `~/...`.
|
||||
- GitHub issues/comments/PR comments: use literal multiline strings or `-F - <<'EOF'` (or $'...') for real newlines; never embed "\\n".
|
||||
- GitHub comment footgun: never use `gh issue/pr comment -b "..."` when body contains backticks or shell chars. Always use single-quoted heredoc (`-F - <<'EOF'`) so no command substitution/escaping corruption.
|
||||
- GitHub linking footgun: don’t wrap issue/PR refs like `#24643` in backticks when you want auto-linking. Use plain `#24643` (optionally add full URL).
|
||||
- PR landing comments: always make commit SHAs clickable with full commit links (both landed SHA + source SHA when present).
|
||||
- PR review conversations: if a bot leaves review conversations on your PR, address them and resolve those conversations yourself once fixed. Leave a conversation unresolved only when reviewer or maintainer judgment is still needed; do not leave bot-conversation cleanup to maintainers.
|
||||
- GitHub searching footgun: don't limit yourself to the first 500 issues or PRs when wanting to search all. Unless you're supposed to look at the most recent, keep going until you've reached the last page in the search
|
||||
- Security advisory analysis: before triage/severity decisions, read `SECURITY.md` to align with OpenClaw's trust model and design boundaries.
|
||||
- Do not edit files covered by security-focused `CODEOWNERS` rules unless a listed owner explicitly asked for the change or is already reviewing it with you. Treat those paths as restricted surfaces, not drive-by cleanup.
|
||||
|
||||
## Auto-close labels (issues and PRs)
|
||||
|
||||
- If an issue/PR matches one of the reasons below, apply the label and let `.github/workflows/auto-response.yml` handle comment/close/lock.
|
||||
- Do not manually close + manually comment for these reasons.
|
||||
- Why: keeps wording consistent, preserves automation behavior (`state_reason`, locking), and keeps triage/reporting searchable by label.
|
||||
- `r:*` labels can be used on both issues and PRs.
|
||||
|
||||
- `r: skill`: close with guidance to publish skills on Clawhub.
|
||||
- `r: support`: close with redirect to Discord support + stuck FAQ.
|
||||
- `r: no-ci-pr`: close test-fix-only PRs for failing `main` CI and post the standard explanation.
|
||||
- `r: too-many-prs`: close when author exceeds active PR limit.
|
||||
- `r: testflight`: close requests asking for TestFlight access/builds. OpenClaw does not provide TestFlight distribution yet, so use the standard response (“Not available, build from source.”) instead of ad-hoc replies.
|
||||
- `r: third-party-extension`: close with guidance to ship as third-party plugin.
|
||||
- `r: moltbook`: close + lock as off-topic (not affiliated).
|
||||
- `r: spam`: close + lock as spam (`lock_reason: spam`).
|
||||
- `invalid`: close invalid items (issues are closed as `not_planned`; PRs are closed).
|
||||
- `dirty`: close PRs with too many unrelated/unexpected changes (PR-only label).
|
||||
|
||||
## PR truthfulness and bug-fix validation
|
||||
|
||||
- Never merge a bug-fix PR based only on issue text, PR text, or AI rationale.
|
||||
- Before `/landpr`, run `/reviewpr` and require explicit evidence for bug-fix claims.
|
||||
- Minimum merge gate for bug-fix PRs:
|
||||
1. symptom evidence (repro/log/failing test),
|
||||
2. verified root cause in code with file/line,
|
||||
3. fix touches the implicated code path,
|
||||
4. regression test (fail before/pass after) when feasible; if not feasible, include manual verification proof and why no test was added.
|
||||
- If claim is unsubstantiated or likely hallucinated/BS: do not merge. Request evidence/changes, or close with `invalid` when appropriate.
|
||||
- If linked issue appears wrong/outdated, correct triage first; do not merge speculative fixes.
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
|
||||
- Tests: colocated `*.test.ts`.
|
||||
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
|
||||
- Nomenclature: use "plugin" / "plugins" in docs, UI, changelogs, and contributor guidance. The bundled workspace plugin tree remains the internal package layout to avoid repo-wide churn from a rename.
|
||||
- Bundled plugin naming: for repo-owned workspace plugins, keep the canonical plugin id aligned across `openclaw.plugin.json:id`, the default workspace folder name, and package names anchored to the same id (`@openclaw/<id>` or approved suffix forms like `-provider`, `-plugin`, `-speech`, `-sandbox`, `-media-understanding`). Keep `openclaw.install.npmSpec` equal to the package name and `openclaw.channel.id` equal to the plugin id when present. Exceptions must be explicit and covered by the repo invariant test.
|
||||
- Plugins: live in the bundled workspace plugin tree (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
|
||||
- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
|
||||
- Plugins: install runs `npm install --omit=dev` in plugin dir; runtime deps must live in `dependencies`. Avoid `workspace:*` in `dependencies` (npm install breaks); put `openclaw` in `devDependencies` or `peerDependencies` instead (runtime resolves `openclaw/plugin-sdk` via jiti alias).
|
||||
- Import boundaries: extension production code should treat `openclaw/plugin-sdk/*` plus local `api.ts` / `runtime-api.ts` barrels as the public surface. Do not import core `src/**`, `src/plugin-sdk-internal/**`, or another extension's `src/**` directly.
|
||||
- Installers served from `https://openclaw.ai/*`: live in the sibling repo `../openclaw.ai` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
|
||||
- Messaging channels: always consider **all** built-in + extension channels when refactoring shared logic (routing, allowlists, pairing, command gating, onboarding, docs).
|
||||
- Core channel docs: `docs/channels/`
|
||||
- Core channel code: `src/telegram`, `src/discord`, `src/slack`, `src/signal`, `src/imessage`, `src/web` (WhatsApp web), `src/channels`, `src/routing`
|
||||
- Bundled plugin channels: the workspace plugin tree (for example Matrix, Zalo, ZaloUser, Voice Call)
|
||||
- When adding channels/plugins/apps/docs, update `.github/labeler.yml` and create matching GitHub labels (use existing channel/plugin label colors).
|
||||
|
||||
## Architecture Boundaries
|
||||
|
||||
- Start here for the repo map:
|
||||
- bundled workspace plugin tree = bundled plugins and the closest example surface for third-party plugins
|
||||
- `src/plugin-sdk/*` = the public plugin contract that extensions are allowed to import
|
||||
- `src/channels/*` = core channel implementation details behind the plugin/channel boundary
|
||||
- `src/plugins/*` = plugin discovery, manifest validation, loader, registry, and contract enforcement
|
||||
- `src/gateway/protocol/*` = typed Gateway control-plane and node wire protocol
|
||||
- Progressive disclosure lives in local boundary guides:
|
||||
- bundled-plugin-tree `AGENTS.md`
|
||||
- `src/plugin-sdk/AGENTS.md`
|
||||
- `src/channels/AGENTS.md`
|
||||
- `src/plugins/AGENTS.md`
|
||||
- `src/gateway/protocol/AGENTS.md`
|
||||
- Plugin and extension boundary:
|
||||
- Public docs: `docs/plugins/building-plugins.md`, `docs/plugins/architecture.md`, `docs/plugins/sdk-overview.md`, `docs/plugins/sdk-entrypoints.md`, `docs/plugins/sdk-runtime.md`, `docs/plugins/manifest.md`, `docs/plugins/sdk-channel-plugins.md`, `docs/plugins/sdk-provider-plugins.md`
|
||||
- Definition files: `src/plugin-sdk/plugin-entry.ts`, `src/plugin-sdk/core.ts`, `src/plugin-sdk/provider-entry.ts`, `src/plugin-sdk/channel-contract.ts`, `scripts/lib/plugin-sdk-entrypoints.json`, `package.json`
|
||||
- Invariant: core must stay extension-agnostic. Adding a bundled or third-party extension should not require unrelated core edits just to teach core that the extension exists.
|
||||
- Rule: extensions must cross into core only through `openclaw/plugin-sdk/*`, manifest metadata, and documented runtime helpers. Do not import `src/**` from extension production code.
|
||||
- Rule: core code and tests must not deep-import bundled plugin internals such as a plugin's `src/**` files or `onboard.js`. If core needs a bundled plugin helper, expose it through that plugin's `api.ts` and, when it is a real cross-package contract, through `src/plugin-sdk/<id>.ts`.
|
||||
- Rule: do not add hardcoded bundled extension/provider/channel/capability id lists, maps, or named special cases in core when a manifest, capability, registry, or plugin-owned contract can express the same behavior.
|
||||
- Rule: extension-owned compatibility behavior belongs to the owning extension. Core may orchestrate generic doctor/config flows, but extension-specific legacy repairs, detection rules, onboarding, auth detection, and provider defaults should live in plugin-owned contracts.
|
||||
- Rule: for legacy config specifically, prefer doctor-owned repair paths over startup/load-time core migrations. Do not add new plugin-specific legacy migration logic to shared core/runtime surfaces when `openclaw doctor --fix` can own it.
|
||||
- Rule: when a test is asserting extension-specific behavior, keep that coverage in the owning extension when feasible. Core tests should assert generic contracts and registry/capability behavior, not extension internals.
|
||||
- Refactor trigger: if you encounter core code or tests that name a specific extension/provider/channel for extension-owned behavior, refactor toward a generic registry/capability/plugin-owned seam instead of adding another special case.
|
||||
- Compatibility: new plugin seams are allowed, but they must be added as documented, backwards-compatible, versioned contracts. We have third-party plugins in the wild and do not break them casually.
|
||||
- Channel boundary:
|
||||
- Public docs: `docs/plugins/sdk-channel-plugins.md`, `docs/plugins/architecture.md`
|
||||
- Definition files: `src/channels/plugins/types.plugin.ts`, `src/channels/plugins/types.core.ts`, `src/channels/plugins/types.adapters.ts`, `src/plugin-sdk/core.ts`, `src/plugin-sdk/channel-contract.ts`
|
||||
- Rule: `src/channels/**` is core implementation. If plugin authors need a new seam, add it to the Plugin SDK instead of telling them to import channel internals.
|
||||
- Provider/model boundary:
|
||||
- Public docs: `docs/plugins/sdk-provider-plugins.md`, `docs/concepts/model-providers.md`, `docs/plugins/architecture.md`
|
||||
- Definition files: `src/plugins/types.ts`, `src/plugin-sdk/provider-entry.ts`, `src/plugin-sdk/provider-auth.ts`, `src/plugin-sdk/provider-catalog-shared.ts`, `src/plugin-sdk/provider-model-shared.ts`
|
||||
- Rule: core owns the generic inference loop; provider plugins own provider-specific behavior through registration and typed hooks. Do not solve provider needs by reaching into unrelated core internals.
|
||||
- Rule: avoid ad hoc reads of `plugins.entries.<id>.config` from unrelated core code. If core needs plugin-owned auth/config behavior, add or use a generic seam (`resolveSyntheticAuth`, public SDK/helper facades, manifest metadata, plugin auto-enable hooks) and honor plugin disablement plus SecretRef semantics.
|
||||
- Rule: vendor-owned tools and settings belong in the owning plugin. Do not add provider-specific tool config, secret collection, or runtime enablement to core `tools.*` surfaces unless the tool is intentionally core-owned.
|
||||
- Gateway protocol boundary:
|
||||
- Public docs: `docs/gateway/protocol.md`, `docs/gateway/bridge-protocol.md`, `docs/concepts/architecture.md`
|
||||
- Definition files: `src/gateway/protocol/schema.ts`, `src/gateway/protocol/schema/*.ts`, `src/gateway/protocol/index.ts`
|
||||
- Rule: protocol changes are contract changes. Prefer additive evolution; incompatible changes require explicit versioning, docs, and client/codegen follow-through.
|
||||
- Config contract boundary:
|
||||
- Canonical public config lives in exported config types, zod/schema surfaces, schema help/labels, generated config metadata, config baselines, and any user-facing gateway/config payloads. Keep those surfaces aligned.
|
||||
- When a legacy config key is retired from the public contract, remove it from every public config surface above. Keep backward compatibility only through raw-config migration/doctor seams unless explicit product policy says otherwise.
|
||||
- Do not reintroduce removed legacy aliases into public types/schema/help/baselines “for convenience”. If old configs still need to load, handle that in `legacy.migrations.*`, config ingest, or `openclaw doctor --fix`.
|
||||
- `hooks.internal.entries` is the canonical public hook config model. `hooks.internal.handlers` is compatibility-only input and must not be re-exposed in public schema/help/baseline surfaces.
|
||||
- Bundled plugin contract boundary:
|
||||
- Public docs: `docs/plugins/architecture.md`, `docs/plugins/manifest.md`, `docs/plugins/sdk-overview.md`
|
||||
- Definition files: `src/plugins/contracts/registry.ts`, `src/plugins/types.ts`, `src/plugins/public-artifacts.ts`
|
||||
- Rule: keep manifest metadata, runtime registration, public SDK exports, and contract tests aligned. Do not create a hidden path around the declared plugin interfaces.
|
||||
- Extension test boundary:
|
||||
- Keep extension-owned onboarding/config/provider coverage under the owning bundled plugin package when feasible.
|
||||
- If core tests need bundled plugin behavior, consume it through public `src/plugin-sdk/<id>.ts` facades or the plugin's `api.ts`, not private extension modules.
|
||||
- If a core test is asserting extension-specific behavior instead of a generic contract, move it to the owning extension package.
|
||||
- Extensions (channel plugins): `extensions/*` (e.g. `extensions/msteams`, `extensions/matrix`, `extensions/zalo`, `extensions/zalouser`, `extensions/voice-call`)
|
||||
- When adding channels/extensions/apps/docs, update `.github/labeler.yml` and create matching GitHub labels (use existing channel/extension label colors).
|
||||
|
||||
## Docs Linking (Mintlify)
|
||||
|
||||
@@ -83,29 +63,19 @@
|
||||
- For docs, UI copy, and picker lists, order services/providers alphabetically unless the section is explicitly describing runtime behavior (for example auto-detection or execution order).
|
||||
- Section cross-references: use anchors on root-relative paths (example: `[Hooks](/configuration#hooks)`).
|
||||
- Doc headings and anchors: avoid em dashes and apostrophes in headings because they break Mintlify anchor links.
|
||||
- When the user asks for links, reply with full `https://docs.openclaw.ai/...` URLs (not root-relative).
|
||||
- When Peter asks for links, reply with full `https://docs.openclaw.ai/...` URLs (not root-relative).
|
||||
- When you touch docs, end the reply with the `https://docs.openclaw.ai/...` URLs you referenced.
|
||||
- README (GitHub): keep absolute docs URLs (`https://docs.openclaw.ai/...`) so links work on GitHub.
|
||||
- Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”.
|
||||
|
||||
## Docs i18n (generated publish locales)
|
||||
## Docs i18n (zh-CN)
|
||||
|
||||
- Foreign-language docs are not maintained in this repo. The generated publish output lives in the separate `openclaw/docs` repo (often cloned locally as the sibling `openclaw-docs` directory); do not add or edit localized docs under `docs/<locale>/**` here.
|
||||
- Those localized docs are autogenerated. Treat this repo's English docs plus glossary files as the source of truth, and let the publish/translation pipeline update `openclaw/docs`.
|
||||
- Pipeline: update English docs here → adjust the matching `docs/.i18n/glossary.<locale>.json` entries → let the publish-repo sync + `scripts/docs-i18n` run in `openclaw/docs` / local `openclaw-docs` clone → apply targeted fixes only if instructed.
|
||||
- Before rerunning `scripts/docs-i18n`, add glossary entries for any new technical terms, page titles, or short nav labels that must stay in English or use a fixed translation (for example `Doctor` or `Polls`).
|
||||
- `pnpm docs:check-i18n-glossary` enforces glossary coverage for changed English doc titles and short internal doc labels before translation reruns.
|
||||
- Translation memory lives in generated `docs/.i18n/*.tm.jsonl` files in the publish repo.
|
||||
- `docs/zh-CN/**` is generated; do not edit unless the user explicitly asks.
|
||||
- Pipeline: update English docs → adjust glossary (`docs/.i18n/glossary.zh-CN.json`) → run `scripts/docs-i18n` → apply targeted fixes only if instructed.
|
||||
- Translation memory: `docs/.i18n/zh-CN.tm.jsonl` (generated).
|
||||
- See `docs/.i18n/README.md`.
|
||||
- The pipeline can be slow/inefficient; if it’s dragging, ping @jospalmbier on Discord instead of hacking around it.
|
||||
|
||||
## Control UI i18n (generated in repo)
|
||||
|
||||
- Control UI foreign-language locale bundles are generated in this repo; do not hand-edit `ui/src/i18n/locales/*.ts` for non-English locales or `ui/src/i18n/.i18n/*` unless a targeted generated-output fix is explicitly requested.
|
||||
- Source of truth is `ui/src/i18n/locales/en.ts` plus the generator/runtime wiring in `scripts/control-ui-i18n.ts`, `ui/src/i18n/lib/types.ts`, and `ui/src/i18n/lib/registry.ts`.
|
||||
- Pipeline: update English control UI strings and locale wiring here → run `pnpm ui:i18n:sync` (or let `Control UI Locale Refresh` do it) → commit the regenerated locale bundles and `.i18n` metadata.
|
||||
- If the control UI locale outputs drift, regenerate them; do not manually translate or hand-maintain the generated locale files by default.
|
||||
|
||||
## exe.dev VM ops (general)
|
||||
|
||||
- Access: stable path is `ssh exe.dev` then `ssh vm-name` (assume SSH key already set).
|
||||
@@ -122,70 +92,26 @@
|
||||
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
|
||||
- Install deps: `pnpm install`
|
||||
- If deps are missing (for example `node_modules` missing, `vitest not found`, or `command not found`), run the repo’s package-manager install command (prefer lockfile/README-defined PM), then rerun the exact requested command once. Apply this to test/build/lint/typecheck/dev commands; if retry still fails, report the command and first actionable error.
|
||||
- Pre-commit hooks: `prek install`. The hook runs the repo verification flow, including `pnpm check`.
|
||||
- `FAST_COMMIT=1` skips the repo-wide `pnpm format` and `pnpm check` inside the pre-commit hook only. Use it when you intentionally want a faster commit path and are running equivalent targeted verification manually. It does not change CI and does not change what `pnpm check` itself does.
|
||||
- Pre-commit hooks: `prek install` (runs same checks as CI)
|
||||
- Also supported: `bun install` (keep `pnpm-lock.yaml` + Bun patching in sync when touching deps/patches).
|
||||
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
|
||||
- Run CLI in dev: `pnpm openclaw ...` (bun) or `pnpm dev`.
|
||||
- Node remains supported for running built output (`dist/*`) and production installs.
|
||||
- Mac packaging (dev): `scripts/package-mac-app.sh` defaults to current arch.
|
||||
- Mac packaging (dev): `scripts/package-mac-app.sh` defaults to current arch. Release checklist: `docs/platforms/mac/release.md`.
|
||||
- Type-check/build: `pnpm build`
|
||||
- TypeScript checks: `pnpm tsgo`
|
||||
- Lint/format: `pnpm check`
|
||||
- Local agent/dev shells default to lower-memory `OPENCLAW_LOCAL_CHECK=1` behavior for `pnpm tsgo` and `pnpm lint`; set `OPENCLAW_LOCAL_CHECK=0` in CI/shared runs.
|
||||
- Format check: `pnpm format` (oxfmt --check)
|
||||
- Format fix: `pnpm format:fix` (oxfmt --write)
|
||||
- Terminology:
|
||||
- "gate" means a verification command or command set that must be green for the decision you are making.
|
||||
- A local dev gate is the fast default loop, usually `pnpm check` plus any scoped test you actually need.
|
||||
- A landing gate is the broader bar before pushing `main`, usually `pnpm check`, `pnpm test`, and `pnpm build` when the touched surface can affect build output, packaging, lazy-loading/module boundaries, or published surfaces.
|
||||
- A CI gate is whatever the relevant workflow enforces for that lane (for example `check`, `check-additional`, `build-smoke`, or release validation).
|
||||
- Local dev gate: prefer `pnpm check` for the normal edit loop. It keeps the repo-architecture policy guards out of the default local loop.
|
||||
- CI architecture gate: `check-additional` enforces architecture and boundary policy guards that are intentionally kept out of the default local loop.
|
||||
- Formatting gate: the pre-commit hook runs `pnpm format` before `pnpm check`. If you want a formatting-only preflight locally, run `pnpm format` explicitly.
|
||||
- If you need a fast commit loop, `FAST_COMMIT=1 git commit ...` skips the hook’s repo-wide `pnpm format` and `pnpm check`; use that only when you are deliberately covering the touched surface some other way.
|
||||
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
|
||||
- Generated baseline drift detection uses SHA-256 hash files under `docs/.generated/` (`.sha256` files tracked in git; full JSON baselines are gitignored, generated locally for inspection).
|
||||
- Config schema drift uses `pnpm config:docs:gen` / `pnpm config:docs:check`.
|
||||
- Plugin SDK API drift uses `pnpm plugin-sdk:api:gen` / `pnpm plugin-sdk:api:check`.
|
||||
- If you change config schema/help or the public Plugin SDK surface, run the matching gen command and commit the updated `.sha256` hash file. Keep the two drift-check flows adjacent in scripts/workflows/docs guidance rather than inventing a third pattern.
|
||||
- For narrowly scoped changes, prefer narrowly scoped tests that directly validate the touched behavior. If no meaningful scoped test exists, say so explicitly and use the next most direct validation available.
|
||||
- Verification modes for work on `main`:
|
||||
- Default mode: `main` is relatively stable. Count pre-commit hook coverage when it already verified the current tree, avoid rerunning the exact same checks just for ceremony, and prefer keeping CI/main green before landing.
|
||||
- Fast-commit mode: `main` is moving fast and you intentionally optimize for shorter commit loops. Prefer explicit local verification close to the final landing point, and it is acceptable to use `--no-verify` for intermediate or catch-up commits after equivalent checks have already run locally.
|
||||
- Preferred landing bar for pushes to `main`: in Default mode, favor `pnpm check` and `pnpm test` near the final rebase/push point when feasible. In fast-commit mode, verify the touched surface locally near landing without insisting every intermediate commit replay the full hook.
|
||||
- Scoped tests prove the change itself. `pnpm test` remains the default `main` landing bar; scoped tests do not replace full-suite gates by default.
|
||||
- Hard gate: if the change can affect build output, packaging, lazy-loading/module boundaries, or published surfaces, `pnpm build` MUST be run and MUST pass before pushing `main`.
|
||||
- Default rule: do not land changes with failing format, lint, type, build, or required test checks when those failures are caused by the change or plausibly related to the touched surface. Fast-commit mode changes how verification is sequenced; it does not lower the requirement to validate and clean up the touched surface before final landing.
|
||||
- For narrowly scoped changes, if unrelated failures already exist on latest `origin/main`, state that clearly, report the scoped tests you ran, and ask before broadening scope into unrelated fixes or landing despite those failures.
|
||||
- Do not use scoped tests as permission to ignore plausibly related failures.
|
||||
|
||||
## Prompt Cache Stability
|
||||
|
||||
- Treat prompt-cache stability as correctness/perf-critical, not cosmetic.
|
||||
- Any code that assembles model or tool payloads from maps, sets, registries, plugin lists, MCP catalogs, filesystem reads, or network results must make ordering deterministic before building the request.
|
||||
- Do not rewrite older transcript/history bytes on every turn unless you intentionally want to invalidate the cached prefix. Legacy cleanup, pruning, normalization, and migration logic should preserve recent prompt bytes when possible.
|
||||
- If truncation or compaction is required, prefer mutating newest or tail content first so the cached prefix stays byte-identical for as long as possible.
|
||||
- For cache-sensitive changes, require a regression test that proves turn-to-turn prefix stability or deterministic request assembly; helper-local tests alone are not enough.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
|
||||
- Formatting/linting via Oxlint and Oxfmt.
|
||||
- Never add `@ts-nocheck` and do not add inline lint suppressions by default. Fix root causes first; only keep a suppression when the code is intentionally correct, the rule cannot express that safely, and the comment explains why.
|
||||
- Do not disable `no-explicit-any`; prefer real types, `unknown`, or a narrow adapter/helper instead. Update Oxlint/Oxfmt config only when required.
|
||||
- Prefer `zod` or existing schema helpers at external boundaries such as config, webhook payloads, CLI/JSON output, persisted JSON, and third-party API responses.
|
||||
- Prefer discriminated unions when parameter shape changes runtime behavior.
|
||||
- Prefer `Result<T, E>`-style outcomes and closed error-code unions for recoverable runtime decisions.
|
||||
- Keep human-readable strings for logs, CLI output, and UI; do not use freeform strings as the source of truth for internal branching.
|
||||
- Avoid `?? 0`, empty-string, empty-object, or magic-string sentinels when they can change runtime meaning silently.
|
||||
- If introducing a new optional field or nullable semantic in core logic, prefer an explicit union or dedicated type when the value changes behavior.
|
||||
- New runtime control-flow code should not branch on `error: string` or `reason: string` when a closed code union would be reasonable.
|
||||
- Formatting/linting via Oxlint and Oxfmt; run `pnpm check` before commits.
|
||||
- Never add `@ts-nocheck` and do not disable `no-explicit-any`; fix root causes and update Oxlint/Oxfmt config only when required.
|
||||
- Dynamic import guardrail: do not mix `await import("x")` and static `import ... from "x"` for the same module in production code paths. If you need lazy loading, create a dedicated `*.runtime.ts` boundary (that re-exports from `x`) and dynamically import that boundary from lazy callers only.
|
||||
- Dynamic import verification: after refactors that touch lazy-loading/module boundaries, run `pnpm build` and check for `[INEFFECTIVE_DYNAMIC_IMPORT]` warnings before submitting.
|
||||
- Extension SDK self-import guardrail: inside an extension package, do not import that same extension via `openclaw/plugin-sdk/<extension>` from production files. Route internal imports through a local barrel such as `./api.ts` or `./runtime-api.ts`, and keep the `plugin-sdk/<extension>` path as the external contract only.
|
||||
- Extension package boundary guardrail: inside a bundled plugin package, do not use relative imports/exports that resolve outside that same package root. If shared code belongs in the plugin SDK, import `openclaw/plugin-sdk/<subpath>` instead of reaching into `src/plugin-sdk/**` or other repo paths via `../`.
|
||||
- Extension API surface rule: `openclaw/plugin-sdk/<subpath>` is the only public cross-package contract for extension-facing SDK code. If an extension needs a new seam, add a public subpath first; do not reach into `src/plugin-sdk/**` by relative path.
|
||||
- Never share class behavior via prototype mutation (`applyPrototypeMixins`, `Object.defineProperty` on `.prototype`, or exporting `Class.prototype` for merges). Use explicit inheritance/composition (`A extends B extends C`) or helper composition so TypeScript can typecheck.
|
||||
- If this pattern is needed, stop and get explicit approval before shipping; default behavior is to split/refactor into an explicit class hierarchy and keep members strongly typed.
|
||||
- In tests, prefer per-instance stubs over prototype mutation (`SomeClass.prototype.method = ...`) unless a test explicitly documents why prototype-level patching is required.
|
||||
@@ -195,37 +121,23 @@
|
||||
- Naming: use **OpenClaw** for product/app/docs headings; use `openclaw` for CLI command, package/binary, paths, and config keys.
|
||||
- Written English: use American spelling and grammar in code, comments, docs, and UI strings (e.g. "color" not "colour", "behavior" not "behaviour", "analyze" not "analyse").
|
||||
|
||||
## Release / Advisory Workflows
|
||||
## Release Channels (Naming)
|
||||
|
||||
- Use `$openclaw-release-maintainer` at `.agents/skills/openclaw-release-maintainer/SKILL.md` for release naming, version coordination, release auth, and changelog-backed release-note workflows.
|
||||
- Use `$openclaw-ghsa-maintainer` at `.agents/skills/openclaw-ghsa-maintainer/SKILL.md` for GHSA advisory inspection, patch/publish flow, private-fork checks, and GHSA API validation.
|
||||
- Release and publish remain explicit-approval actions even when using the skill.
|
||||
- stable: tagged releases only (e.g. `vYYYY.M.D`), npm dist-tag `latest`.
|
||||
- beta: prerelease tags `vYYYY.M.D-beta.N`, npm dist-tag `beta` (may ship without macOS app).
|
||||
- beta naming: prefer `-beta.N`; do not mint new `-1/-2` betas. Legacy `vYYYY.M.D-<patch>` and `vYYYY.M.D.beta.N` remain recognized.
|
||||
- dev: moving head on `main` (no tag; git checkout main).
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
|
||||
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
|
||||
- When tests need example Anthropic/OpenAI model constants, prefer `sonnet-4.6` and `gpt-5.4`; update older Anthropic/GPT examples when you touch those tests.
|
||||
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
|
||||
- Write tests to clean up timers, env, globals, mocks, sockets, temp dirs, and module state so `--isolate=false` stays green.
|
||||
- Test performance guardrail: do not put `vi.resetModules()` plus `await import(...)` in `beforeEach`/per-test loops for heavy modules unless module state truly requires it. Prefer static imports or one-time `beforeAll` imports, then reset mocks/runtime state directly.
|
||||
- Test performance guardrail: if a test file uses stable `vi.mock(...)` hoists or other static module mocks, do not pair them with `vi.resetModules()` and a fresh `await import(...)` in every `beforeEach`. Import the heavy module once in `beforeAll`, then reset/prime mocks in `beforeEach` so Browser/Matrix-style hotspot tests do not pay the module graph cost per case.
|
||||
- Test performance guardrail: inside an extension package, prefer a thin local seam (`./api.ts`, `./runtime-api.ts`, or a narrower local `*.runtime-api.ts`) over direct `openclaw/plugin-sdk/*` imports for internal production code. Keep local seams curated and lightweight; only reach for direct `plugin-sdk/*` imports when you are crossing a real package boundary or when no suitable local seam exists yet.
|
||||
- Test performance guardrail: keep expensive runtime fallback work such as snapshotting, migration, installs, or bootstrap behind dedicated `*.runtime.ts` boundaries so tests can mock the seam instead of accidentally invoking real work.
|
||||
- Test performance guardrail: for import-only/runtime-wrapper tests, keep the wrapper lazy. Do not eagerly load heavy verification/bootstrap/runtime modules at module top level if the exported function can import them on demand.
|
||||
- Test performance guardrail: prefer explicit mock factories over `importOriginal()` for broad modules. Reserve `importOriginal()` for narrow modules where partial-real behavior is genuinely needed.
|
||||
- Test performance guardrail: do not partial-mock broad `openclaw/plugin-sdk/*` barrels in hot tests. Add a plugin-local `*.runtime.ts` seam and mock that seam instead.
|
||||
- Test performance guardrail: when production code already accepts `deps`, callbacks, or runtime injection, use that seam in tests before adding module-level mocks.
|
||||
- Test performance guardrail: prefer narrow public SDK subpaths such as `models-provider-runtime`, `skill-commands-runtime`, and `reply-dispatch-runtime` over older broad helper barrels when both expose the needed helper.
|
||||
- Test performance guardrail: treat import-dominated test time as a boundary bug. Refactor the import surface before adding more cases to the slow file.
|
||||
- Agents MUST NOT modify baseline, inventory, ignore, snapshot, or expected-failure files to silence failing checks without explicit approval in this chat.
|
||||
- For targeted/local debugging, use the native root-project entrypoint: `pnpm test <path-or-filter> [vitest args...]` (for example `pnpm test src/commands/onboard-search.test.ts -t "shows registered plugin providers"`); do not default to raw `pnpm vitest run ...` because it bypasses the repo's default config/profile/pool routing.
|
||||
- For targeted/local debugging, keep using the wrapper: `pnpm test -- <path-or-filter> [vitest args...]` (for example `pnpm test -- src/commands/onboard-search.test.ts -t "shows registered plugin providers"`); do not default to raw `pnpm vitest run ...` because it bypasses wrapper config/profile/pool routing.
|
||||
- Do not set test workers above 16; tried already.
|
||||
- Vitest now defaults to native root-project `threads`, with hard `forks` exceptions for `gateway`, `agents`, and `commands`. Keep new pool changes explicit and justified; use `OPENCLAW_VITEST_POOL=forks` for full local fork debugging.
|
||||
- If local Vitest runs cause memory pressure, the default worker budget now derives from host capabilities (CPU, memory band, current load). For a conservative explicit override during land/gate runs, use `OPENCLAW_VITEST_MAX_WORKERS=1 pnpm test`.
|
||||
- Live tests (real keys): `OPENCLAW_LIVE_TEST=1 pnpm test:live` (OpenClaw-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`.
|
||||
- `pnpm test:live` defaults quiet now. Keep `[live]` progress; suppress profile/gateway chatter. Full logs: `OPENCLAW_LIVE_TEST_QUIET=0 pnpm test:live`.
|
||||
- Full kit + what’s covered: `docs/help/testing.md`.
|
||||
- If local Vitest runs cause memory pressure (common on non-Mac-Studio hosts), use `OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test` for land/gate runs.
|
||||
- Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (OpenClaw-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`.
|
||||
- Full kit + what’s covered: `docs/testing.md`.
|
||||
- Changelog: user-facing changes only; no internal/meta notes (version alignment, appcast reminders, release process).
|
||||
- Changelog placement: in the active version block, append new entries to the end of the target section (`### Changes` or `### Fixes`); do not insert new entries at the top of a section.
|
||||
- Changelog attribution: use at most one contributor mention per line; prefer `Thanks @author` and do not also add `by @author` on the same entry.
|
||||
@@ -234,9 +146,7 @@
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
- Use `$openclaw-pr-maintainer` at `.agents/skills/openclaw-pr-maintainer/SKILL.md` for maintainer PR triage, review, close, search, and landing workflows.
|
||||
- This includes auto-close labels, bug-fix evidence gates, GitHub comment/search footguns, and maintainer PR decision flow.
|
||||
- For the repo's end-to-end maintainer PR workflow, use `$openclaw-pr-maintainer` at `.agents/skills/openclaw-pr-maintainer/SKILL.md`.
|
||||
**Full maintainer PR workflow (optional):** If you want the repo's end-to-end maintainer workflow (triage order, quality bar, rebase rules, commit/changelog conventions, co-contributor policy, and the `review-pr` > `prepare-pr` > `merge-pr` pipeline), see `.agents/skills/PR_WORKFLOW.md`. Maintainers may use other workflows; when a maintainer specifies a workflow, follow that. If no workflow is specified, default to PR_WORKFLOW.
|
||||
|
||||
- `/landpr` lives in the global Codex prompts (`~/.codex/prompts/landpr.md`); when landing or merging any PR, always follow that `/landpr` process.
|
||||
- Create commits with `scripts/committer "<msg>" <file...>`; avoid manual `git add`/`git commit` so staging stays scoped.
|
||||
@@ -245,30 +155,100 @@
|
||||
- PR submission template (canonical): `.github/pull_request_template.md`
|
||||
- Issue submission templates (canonical): `.github/ISSUE_TEMPLATE/`
|
||||
|
||||
## Shorthand Commands
|
||||
|
||||
- `sync`: if working tree is dirty, commit all changes (pick a sensible Conventional Commit message), then `git pull --rebase`; if rebase conflicts and cannot resolve, stop; otherwise `git push`.
|
||||
|
||||
## Git Notes
|
||||
|
||||
- If `git branch -d/-D <branch>` is policy-blocked, delete the local ref directly: `git update-ref -d refs/heads/<branch>`.
|
||||
- Agents MUST NOT create or push merge commits on `main`. If `main` has advanced, rebase local commits onto the latest `origin/main` before pushing.
|
||||
- Bulk PR close/reopen safety: if a close action would affect more than 5 PRs, first ask for explicit user confirmation with the exact PR count and target scope/query.
|
||||
|
||||
## GitHub Search (`gh`)
|
||||
|
||||
- Prefer targeted keyword search before proposing new work or duplicating fixes.
|
||||
- Use `--repo openclaw/openclaw` + `--match title,body` first; add `--match comments` when triaging follow-up threads.
|
||||
- PRs: `gh search prs --repo openclaw/openclaw --match title,body --limit 50 -- "auto-update"`
|
||||
- Issues: `gh search issues --repo openclaw/openclaw --match title,body --limit 50 -- "auto-update"`
|
||||
- Structured output example:
|
||||
`gh search issues --repo openclaw/openclaw --match title,body --limit 50 --json number,title,state,url,updatedAt -- "auto update" --jq '.[] | "\(.number) | \(.state) | \(.title) | \(.url)"'`
|
||||
|
||||
## Security & Configuration Tips
|
||||
|
||||
- Web provider stores creds at `~/.openclaw/credentials/`; rerun `openclaw login` if logged out.
|
||||
- Pi sessions live under `~/.openclaw/sessions/` by default; the base directory is not configurable.
|
||||
- Environment variables: see `~/.profile`.
|
||||
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
|
||||
- Release flow: use the private [maintainer release docs](https://github.com/openclaw/maintainers/blob/main/release/README.md) for the actual runbook, `docs/reference/RELEASING.md` for the public release policy, and `$openclaw-release-maintainer` for the maintainership workflow.
|
||||
- Release flow: always read `docs/reference/RELEASING.md` and `docs/platforms/mac/release.md` before any release work; do not ask routine questions once those docs answer them.
|
||||
|
||||
## Local Runtime / Platform Notes
|
||||
## GHSA (Repo Advisory) Patch/Publish
|
||||
|
||||
- Before reviewing security advisories, read `SECURITY.md`.
|
||||
- Fetch: `gh api /repos/openclaw/openclaw/security-advisories/<GHSA>`
|
||||
- Latest npm: `npm view openclaw version --userconfig "$(mktemp)"`
|
||||
- Private fork PRs must be closed:
|
||||
`fork=$(gh api /repos/openclaw/openclaw/security-advisories/<GHSA> | jq -r .private_fork.full_name)`
|
||||
`gh pr list -R "$fork" --state open` (must be empty)
|
||||
- Description newline footgun: write Markdown via heredoc to `/tmp/ghsa.desc.md` (no `"\\n"` strings)
|
||||
- Build patch JSON via jq: `jq -n --rawfile desc /tmp/ghsa.desc.md '{summary,severity,description:$desc,vulnerabilities:[...]}' > /tmp/ghsa.patch.json`
|
||||
- GHSA API footgun: cannot set `severity` and `cvss_vector_string` in the same PATCH; do separate calls.
|
||||
- Patch + publish: `gh api -X PATCH /repos/openclaw/openclaw/security-advisories/<GHSA> --input /tmp/ghsa.patch.json` (publish = include `"state":"published"`; no `/publish` endpoint)
|
||||
- If publish fails (HTTP 422): missing `severity`/`description`/`vulnerabilities[]`, or private fork has open PRs
|
||||
- Verify: re-fetch; ensure `state=published`, `published_at` set; `jq -r .description | rg '\\\\n'` returns nothing
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Rebrand/migration issues or legacy config/service warnings: run `openclaw doctor` (see `docs/gateway/doctor.md`).
|
||||
|
||||
## Agent-Specific Notes
|
||||
|
||||
- Vocabulary: "makeup" = "mac app".
|
||||
- Rebrand/migration issues or legacy config/service warnings: run `openclaw doctor` (see `docs/gateway/doctor.md`).
|
||||
- Use `$openclaw-parallels-smoke` at `.agents/skills/openclaw-parallels-smoke/SKILL.md` for Parallels smoke, rerun, upgrade, debug, and result-interpretation workflows across macOS, Windows, and Linux guests.
|
||||
- For the macOS Discord roundtrip deep dive, use the narrower `.agents/skills/parallels-discord-roundtrip/SKILL.md` companion skill.
|
||||
- Parallels macOS retests: use the snapshot most closely named like `macOS 26.3.1 fresh` when the user asks for a clean/fresh macOS rerun; avoid older Tahoe snapshots unless explicitly requested.
|
||||
- Parallels beta smoke: use `--target-package-spec openclaw@<beta-version>` for the beta artifact, and pin the stable side with both `--install-version <stable-version>` and `--latest-version <stable-version>` for upgrade runs. npm dist-tags can move mid-run.
|
||||
- Parallels beta smoke, Windows nuance: old stable `2026.3.12` still prints the Unicode Windows onboarding banner, so mojibake during the stable precheck log is expected there. Judge the beta package by the post-upgrade lane.
|
||||
- Parallels macOS smoke playbook:
|
||||
- `prlctl exec` is fine for deterministic repo commands, but it can misrepresent interactive shell behavior (`PATH`, `HOME`, `curl | bash`, shebang resolution). For installer parity or shell-sensitive repros, prefer the guest Terminal or `prlctl enter`.
|
||||
- Fresh Tahoe snapshot current reality: `brew` exists, `node` may not be on `PATH` in noninteractive guest exec. Use absolute `/opt/homebrew/bin/node` for repo/CLI runs when needed.
|
||||
- Preferred automation entrypoint: `pnpm test:parallels:macos`. It restores the snapshot most closely matching `macOS 26.3.1 fresh`, serves the current `main` tarball from the host, then runs fresh-install and latest-release-to-main smoke lanes.
|
||||
- Gateway verification in smoke runs should use `openclaw gateway status --deep --require-rpc`, not plain `--deep`, so probe failures go non-zero.
|
||||
- Latest-release pre-upgrade diagnostics still need compatibility fallback: stable `2026.3.12` does not know `--require-rpc`, so precheck status dumps should fall back to plain `gateway status --deep` until the guest is upgraded.
|
||||
- Harness output: pass `--json` for machine-readable summary; per-phase logs land under `/tmp/openclaw-parallels-smoke.*`.
|
||||
- All-OS parallel runs should share the host `dist` build via `/tmp/openclaw-parallels-build.lock` instead of rebuilding three times.
|
||||
- Current expected outcome on latest stable pre-upgrade: `precheck=latest-ref-fail` is normal on `2026.3.12`; treat it as a baseline signal, not a regression, unless the post-upgrade `main` lane also fails.
|
||||
- Fresh host-served tgz install: restore fresh snapshot, install tgz as guest root with `HOME=/var/root`, then run onboarding as the desktop user via `prlctl exec --current-user`.
|
||||
- For `openclaw onboard --non-interactive --secret-input-mode ref --install-daemon`, expect env-backed auth-profile refs (for example `OPENAI_API_KEY`) to be copied into the service env at install time; this path was fixed and should stay green.
|
||||
- Don’t run local + gateway agent turns in parallel on the same fresh workspace/session; they can collide on the session lock. Run sequentially.
|
||||
- Root-installed tarball smoke on Tahoe can still log plugin blocks for world-writable `extensions/*` under `/opt/homebrew/lib/node_modules/openclaw`; treat that as separate from onboarding/gateway health unless the task is plugin loading.
|
||||
- Parallels Windows smoke playbook:
|
||||
- Preferred automation entrypoint: `pnpm test:parallels:windows`. It restores the snapshot most closely matching `pre-openclaw-native-e2e-2026-03-12`, serves the current `main` tarball from the host, then runs fresh-install and latest-release-to-main smoke lanes.
|
||||
- Gateway verification in smoke runs should use `openclaw gateway status --deep --require-rpc`, not plain `--deep`, so probe failures go non-zero.
|
||||
- Latest-release pre-upgrade diagnostics still need compatibility fallback: stable `2026.3.12` does not know `--require-rpc`, so precheck status dumps should fall back to plain `gateway status --deep` until the guest is upgraded.
|
||||
- Always use `prlctl exec --current-user` for Windows guest runs; plain `prlctl exec` lands in `NT AUTHORITY\SYSTEM` and does not match the real desktop-user install path.
|
||||
- Prefer explicit `npm.cmd` / `openclaw.cmd`. Bare `npm` / `openclaw` in PowerShell can hit the `.ps1` shim and fail under restrictive execution policy.
|
||||
- Use PowerShell only as the transport (`powershell.exe -NoProfile -ExecutionPolicy Bypass`) and call the `.cmd` shims explicitly from inside it.
|
||||
- Harness output: pass `--json` for machine-readable summary; per-phase logs land under `/tmp/openclaw-parallels-windows.*`.
|
||||
- Current expected outcome on latest stable pre-upgrade: `precheck=latest-ref-fail` is normal on `2026.3.12`; treat it as a baseline signal, not a regression, unless the post-upgrade `main` lane also fails.
|
||||
- Keep Windows onboarding/status text ASCII-clean in logs. Fancy punctuation in banners shows up as mojibake through the current guest PowerShell capture path.
|
||||
- Parallels Linux smoke playbook:
|
||||
- Preferred automation entrypoint: `pnpm test:parallels:linux`. It restores the snapshot most closely matching `fresh` on `Ubuntu 24.04.3 ARM64`, serves the current `main` tarball from the host, then runs fresh-install and latest-release-to-main smoke lanes.
|
||||
- Use plain `prlctl exec` on this snapshot. `--current-user` is not the right transport there.
|
||||
- Fresh snapshot reality: `curl` is missing and `apt-get update` can fail on clock skew. Bootstrap with `apt-get -o Acquire::Check-Date=false update` and install `curl ca-certificates` before testing installer paths.
|
||||
- Fresh `main` tgz smoke on Linux still needs the latest-release installer first, because this snapshot has no Node/npm before bootstrap. The harness does stable bootstrap first, then overlays current `main`.
|
||||
- This snapshot does not have a usable `systemd --user` session. Treat managed daemon install as unsupported here; use `--skip-health`, then verify with direct `openclaw gateway run --bind loopback --port 18789 --force`.
|
||||
- Env-backed auth refs are still fine, but any direct shell launch (`openclaw gateway run`, `openclaw agent --local`, Linux `gateway status --deep` against that direct run) must inherit the referenced env vars in the same shell.
|
||||
- `prlctl exec` reaps detached Linux child processes on this snapshot, so a background `openclaw gateway run` launched from automation is not a trustworthy smoke path. The harness verifies installer + `agent --local`; do direct gateway checks only from an interactive guest shell when needed.
|
||||
- When you do run Linux gateway checks manually from an interactive guest shell, use `openclaw gateway status --deep --require-rpc` so an RPC miss is a hard failure.
|
||||
- Prefer direct argv guest commands for fetch/install steps (`curl`, `npm install -g`, `openclaw ...`) over nested `bash -lc` quoting; Linux guest quoting through Parallels was the flaky part.
|
||||
- Harness output: pass `--json` for machine-readable summary; per-phase logs land under `/tmp/openclaw-parallels-linux.*`.
|
||||
- Current expected outcome on Linux smoke: fresh + upgrade should pass installer and `agent --local`; gateway remains `skipped-no-detached-linux-gateway` on this snapshot and should not be treated as a regression by itself.
|
||||
- Never edit `node_modules` (global/Homebrew/npm/git installs too). Updates overwrite. Skill notes go in `tools.md` or `AGENTS.md`.
|
||||
- If you need local-only `.agents` ignores, use `.git/info/exclude` instead of repo `.gitignore`.
|
||||
- When adding a new `AGENTS.md` anywhere in the repo, also add a `CLAUDE.md` symlink pointing to it (example: `ln -s AGENTS.md CLAUDE.md`).
|
||||
- Signal: "update fly" => `fly ssh console -a flawd-bot -C "bash -lc 'cd /data/clawd/openclaw && git pull --rebase origin main'"` then `fly machines restart e825232f34d058 -a flawd-bot`.
|
||||
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
|
||||
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
||||
- Never update the Carbon dependency.
|
||||
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`).
|
||||
- Patching dependencies (pnpm patches, overrides, or vendored changes) requires explicit approval; do not do this by default.
|
||||
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars.
|
||||
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
|
||||
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the OpenClaw Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep openclaw` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
|
||||
@@ -276,32 +256,16 @@
|
||||
- If shared guardrails are available locally, review them; otherwise follow this repo's guidance.
|
||||
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
|
||||
- Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync.
|
||||
- Version locations: `package.json` (CLI), `apps/android/app/build.gradle.kts` (versionName/versionCode), `apps/ios/Sources/Info.plist` + `apps/ios/Tests/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `apps/macos/Sources/OpenClaw/Resources/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `docs/install/updating.md` (pinned npm version), and Peekaboo Xcode projects/Info.plists (MARKETING_VERSION/CURRENT_PROJECT_VERSION).
|
||||
- Version locations: `package.json` (CLI), `apps/android/app/build.gradle.kts` (versionName/versionCode), `apps/ios/Sources/Info.plist` + `apps/ios/Tests/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `apps/macos/Sources/OpenClaw/Resources/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `docs/install/updating.md` (pinned npm version), `docs/platforms/mac/release.md` (APP_VERSION/APP_BUILD examples), Peekaboo Xcode projects/Info.plists (MARKETING_VERSION/CURRENT_PROJECT_VERSION).
|
||||
- "Bump version everywhere" means all version locations above **except** `appcast.xml` (only touch appcast when cutting a new macOS Sparkle release).
|
||||
- **Restart apps:** “restart iOS/Android apps” means rebuild (recompile/install) and relaunch, not just kill/launch.
|
||||
- **Device checks:** before testing, verify connected real devices (iOS/Android) before reaching for simulators/emulators.
|
||||
- Mobile pairing: `ws://` (cleartext) is allowed for private LAN addresses (RFC 1918, link-local, mDNS `.local`) and loopback. Private LAN hosts typically lack PKI-backed identity, so requiring TLS there adds complexity without meaningful security gain. `wss://` is required for Tailscale and public endpoints.
|
||||
- Security report scope: reports that treat cleartext `ws://` mobile pairing over private LAN as a vulnerability are out of scope unless they demonstrate a trust-boundary bypass beyond passive network observation on the same LAN.
|
||||
- iOS Team ID lookup: `security find-identity -p codesigning -v` → use Apple Development (…) TEAMID. Fallback: `defaults read com.apple.dt.Xcode IDEProvisioningTeamIdentifiers`.
|
||||
- A2UI bundle hash: `src/canvas-host/a2ui/.bundle.hash` is auto-generated; ignore unexpected changes, and only regenerate via `pnpm canvas:a2ui:bundle` (or `scripts/bundle-a2ui.sh`) when needed. Commit the hash as a separate commit.
|
||||
- Release signing/notary credentials are managed outside the repo; maintainers keep that setup in the private [maintainer release docs](https://github.com/openclaw/maintainers/tree/main/release).
|
||||
- Lobster palette: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed.
|
||||
- When asked to open a “session” file, open the Pi session logs under `~/.openclaw/agents/<agentId>/sessions/*.jsonl` (use the `agent=<id>` value in the Runtime line of the system prompt; newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
|
||||
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
|
||||
- Voice wake forwarding tips:
|
||||
- Command template should stay `openclaw-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes.
|
||||
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`openclaw` binaries resolve when invoked via `openclaw-mac`.
|
||||
|
||||
## Collaboration / Safety Notes
|
||||
|
||||
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
|
||||
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
||||
- Never update the Carbon dependency.
|
||||
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`).
|
||||
- Patching dependencies (pnpm patches, overrides, or vendored changes) requires explicit approval; do not do this by default.
|
||||
- Release signing/notary keys are managed outside the repo; follow internal release docs.
|
||||
- Notary auth env vars (`APP_STORE_CONNECT_ISSUER_ID`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_API_KEY_P8`) are expected in your environment (per internal release docs).
|
||||
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless explicitly requested (this includes `git pull --rebase --autostash`). Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
|
||||
- **Multi-agent safety:** when the user says "push", you may `git pull --rebase` to integrate latest changes (never discard other agents' work). When the user says "commit", scope to your changes only. When the user says "commit all", commit everything in grouped chunks.
|
||||
- **Multi-agent safety:** prefer grouped `commit` / `pull --rebase` / `push` cycles for related work instead of many tiny syncs.
|
||||
- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless explicitly requested.
|
||||
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless explicitly requested.
|
||||
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.
|
||||
@@ -310,12 +274,64 @@
|
||||
- If staged+unstaged diffs are formatting-only, auto-resolve without asking.
|
||||
- If commit/push already requested, auto-stage and include formatting-only follow-ups in the same commit (or a tiny follow-up commit if needed), no extra confirmation.
|
||||
- Only ask when changes are semantic (logic/data/behavior).
|
||||
- Lobster seam: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed.
|
||||
- **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant.
|
||||
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
|
||||
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
|
||||
- Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`.
|
||||
- Tool schema guardrails: avoid raw `format` property names in tool schemas; some validators treat `format` as a reserved keyword and reject the schema.
|
||||
- When asked to open a “session” file, open the Pi session logs under `~/.openclaw/agents/<agentId>/sessions/*.jsonl` (use the `agent=<id>` value in the Runtime line of the system prompt; newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
|
||||
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
|
||||
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
|
||||
- Voice wake forwarding tips:
|
||||
- Command template should stay `openclaw-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes.
|
||||
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`openclaw` binaries resolve when invoked via `openclaw-mac`.
|
||||
- For manual `openclaw message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
|
||||
- Release guardrails: do not change version numbers without operator’s explicit consent; always ask permission before running any npm publish/release step.
|
||||
- Beta release guardrail: when using a beta Git tag (for example `vYYYY.M.D-beta.N`), publish npm with a matching beta version suffix (for example `YYYY.M.D-beta.N`) rather than a plain version on `--tag beta`; otherwise the plain version name gets consumed/blocked.
|
||||
|
||||
## NPM + 1Password (publish/verify)
|
||||
|
||||
- Use the 1password skill; all `op` commands must run inside a fresh tmux session.
|
||||
- Correct 1Password path for npm release auth: `op://Private/Npmjs` (use that item; OTP stays `op://Private/Npmjs/one-time password?attribute=otp`).
|
||||
- Sign in: `eval "$(op signin --account my.1password.com)"` (app unlocked + integration on).
|
||||
- OTP: `op read 'op://Private/Npmjs/one-time password?attribute=otp'`.
|
||||
- Publish: `npm publish --access public --otp="<otp>"` (run from the package dir).
|
||||
- Verify without local npmrc side effects: `npm view <pkg> version --userconfig "$(mktemp)"`.
|
||||
- Kill the tmux session after publish.
|
||||
|
||||
## Plugin Release Fast Path (no core `openclaw` publish)
|
||||
|
||||
- Release only already-on-npm plugins. Source list is in `docs/reference/RELEASING.md` under "Current npm plugin list".
|
||||
- Run all CLI `op` calls and `npm publish` inside tmux to avoid hangs/interruption:
|
||||
- `tmux new -d -s release-plugins-$(date +%Y%m%d-%H%M%S)`
|
||||
- `eval "$(op signin --account my.1password.com)"`
|
||||
- 1Password helpers:
|
||||
- password used by `npm login`:
|
||||
`op item get Npmjs --format=json | jq -r '.fields[] | select(.id=="password").value'`
|
||||
- OTP:
|
||||
`op read 'op://Private/Npmjs/one-time password?attribute=otp'`
|
||||
- Fast publish loop (local helper script in `/tmp` is fine; keep repo clean):
|
||||
- compare local plugin `version` to `npm view <name> version`
|
||||
- only run `npm publish --access public --otp="<otp>"` when versions differ
|
||||
- skip if package is missing on npm or version already matches.
|
||||
- Keep `openclaw` untouched: never run publish from repo root unless explicitly requested.
|
||||
- Post-check for each release:
|
||||
- per-plugin: `npm view @openclaw/<name> version --userconfig "$(mktemp)"` should be `2026.2.17`
|
||||
- core guard: `npm view openclaw version --userconfig "$(mktemp)"` should stay at previous version unless explicitly requested.
|
||||
|
||||
## Changelog Release Notes
|
||||
|
||||
- When cutting a mac release with beta GitHub prerelease:
|
||||
- Tag `vYYYY.M.D-beta.N` from the release commit (example: `v2026.2.15-beta.1`).
|
||||
- Create prerelease with title `openclaw YYYY.M.D-beta.N`.
|
||||
- Use release notes from `CHANGELOG.md` version section (`Changes` + `Fixes`, no title duplicate).
|
||||
- Attach at least `OpenClaw-YYYY.M.D.zip` and `OpenClaw-YYYY.M.D.dSYM.zip`; include `.dmg` if available.
|
||||
|
||||
- Keep top version entries in `CHANGELOG.md` sorted by impact:
|
||||
- `### Changes` first.
|
||||
- `### Fixes` deduped and ranked with user-facing fixes first.
|
||||
- Before tagging/publishing, run:
|
||||
- `node --import tsx scripts/release-check.ts`
|
||||
- `pnpm release:check`
|
||||
- `pnpm test:install:smoke` or `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke` for non-root smoke path.
|
||||
|
||||
2055
CHANGELOG.md
2055
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,7 @@ Welcome to the lobster tank! 🦞
|
||||
- GitHub: [@joshp123](https://github.com/joshp123) · X: [@jjpcodes](https://x.com/jjpcodes)
|
||||
|
||||
- **Ayaan Zaidi** - Telegram subsystem, Android app
|
||||
- GitHub: [@obviyus](https://github.com/obviyus) · X: [@obviyus](https://x.com/obviyus)
|
||||
- GitHub: [@obviyus](https://github.com/obviyus) · X: [@0bviyus](https://x.com/0bviyus)
|
||||
|
||||
- **Tyler Yust** - Agents/subagents, cron, BlueBubbles, macOS app
|
||||
- GitHub: [@tyler6204](https://github.com/tyler6204) · X: [@tyleryust](https://x.com/tyleryust)
|
||||
@@ -47,7 +47,7 @@ Welcome to the lobster tank! 🦞
|
||||
- **Christoph Nakazawa** - JS Infra
|
||||
- GitHub: [@cpojer](https://github.com/cpojer) · X: [@cnakazawa](https://x.com/cnakazawa)
|
||||
|
||||
- **Gustavo Madeira Santana** - Multi-agents, CLI, Performance, Plugins, Matrix
|
||||
- **Gustavo Madeira Santana** - Multi-agents, CLI, web UI
|
||||
- GitHub: [@gumadeiras](https://github.com/gumadeiras) · X: [@gumadeiras](https://x.com/gumadeiras)
|
||||
|
||||
- **Onur Solmaz** - Agents, dev workflows, ACP integrations, MS Teams
|
||||
@@ -58,7 +58,6 @@ Welcome to the lobster tank! 🦞
|
||||
|
||||
- **Jonathan Taylor** - ACP subsystem, Gateway features/bugs, Gog/Mog/Sog CLI's, SEDMAT
|
||||
- GitHub [@visionik](https://github.com/visionik) · X: [@visionik](https://x.com/visionik)
|
||||
|
||||
- **Josh Lehman** - Compaction, Tlon/Urbit subsystem
|
||||
- GitHub [@jalehman](https://github.com/jalehman) · X: [@jlehman\_](https://x.com/jlehman_)
|
||||
|
||||
@@ -77,35 +76,20 @@ Welcome to the lobster tank! 🦞
|
||||
- **Tengji (George) Zhang** - Chinese model APIs, cloud, pi
|
||||
- GitHub: [@odysseus0](https://github.com/odysseus0) · X: [@odysseus0z](https://x.com/odysseus0z)
|
||||
|
||||
- **Andrew (Bubbles) Demczuk** - Agents/Gateway/TTS/VTT
|
||||
- GitHub: [@ademczuk](https://github.com/ademczuk) · X: [@ademczuk](https://x.com/ademczuk)
|
||||
|
||||
## How to Contribute
|
||||
|
||||
1. **Bugs & small fixes** → Open a PR!
|
||||
2. **New features / architecture** → Start a [GitHub Discussion](https://github.com/openclaw/openclaw/discussions) or ask in Discord first
|
||||
3. **Refactor-only PRs** → Don't open a PR. We are not accepting refactor-only changes unless a maintainer explicitly asks for them as part of a concrete fix.
|
||||
4. **Test/CI-only PRs for known `main` failures** → Don't open a PR. The Maintainer team is already tracking those failures, and PRs that only tweak tests or CI to chase them will be closed unless they are required to validate a new fix.
|
||||
5. **Questions** → Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828)
|
||||
|
||||
## PR Limits
|
||||
|
||||
We cap at **10 open PRs per author**. If you exceed this, the `r: too-many-prs` label is added and your PR is auto-closed. This is a hard limit.
|
||||
|
||||
For coordinated change sets that genuinely need more than 10 PRs, join the **#clawtributors** channel in Discord and talk to maintainers first.
|
||||
3. **Questions** → Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828)
|
||||
|
||||
## Before You PR
|
||||
|
||||
- Test locally with your OpenClaw instance
|
||||
- Run tests: `pnpm build && pnpm check && pnpm test`
|
||||
- For extension/plugin changes, run the fast local lane first:
|
||||
- `pnpm test:extension <extension-name>`
|
||||
- `pnpm test:extension --list` to see valid extension ids
|
||||
- If you changed shared plugin or channel surfaces, run `pnpm test:contracts`
|
||||
- For targeted shared-surface work, use `pnpm test:contracts:channels` or `pnpm test:contracts:plugins`
|
||||
- These commands also cover the shared seam/smoke files that the default unit lane skips
|
||||
- If you changed broader runtime behavior, still run the relevant wider lanes (`pnpm test:extensions`, `pnpm test:channels`, or `pnpm test`) before asking for review
|
||||
- If you have access to Codex, run `codex review --base origin/main` locally before opening or updating your PR. Treat this as the current highest standard of AI review, even if GitHub Codex review also runs.
|
||||
- Do not submit refactor-only PRs unless a maintainer explicitly requested that refactor for an active fix or deliverable.
|
||||
- Do not submit test or CI-config fixes for failures already red on `main` CI. If a failure is already visible in the [main branch CI runs](https://github.com/openclaw/openclaw/actions), it's a known issue the Maintainer team is tracking, and a PR that only addresses those failures will be closed automatically. If you spot a _new_ regression not yet shown in main CI, report it as an issue first.
|
||||
- Do not submit test-only PRs that just try to make known `main` CI failures pass. Test changes are acceptable when they are required to validate a new fix or cover new behavior in the same PR.
|
||||
- Ensure CI checks pass
|
||||
- Keep PRs focused (one thing per PR; do not mix unrelated concerns)
|
||||
- Describe what & why
|
||||
@@ -165,10 +149,7 @@ We are currently prioritizing:
|
||||
- **Skills**: For skill contributions, head to [ClawHub](https://clawhub.ai/) — the community hub for OpenClaw skills.
|
||||
- **Performance**: Optimizing token usage and compaction logic.
|
||||
|
||||
Check the [GitHub Issues](https://github.com/openclaw/openclaw/issues) for
|
||||
["good first issue"](https://github.com/openclaw/openclaw/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22)
|
||||
labels. If none are open, pick a small docs or bug issue and leave a quick comment saying
|
||||
you'd like to work on it.
|
||||
Check the [GitHub Issues](https://github.com/openclaw/openclaw/issues) for "good first issue" labels!
|
||||
|
||||
## Maintainers
|
||||
|
||||
|
||||
39
Dockerfile
39
Dockerfile
@@ -5,17 +5,15 @@
|
||||
#
|
||||
# Multi-stage build produces a minimal runtime image without build tools,
|
||||
# source code, or Bun. Works with Docker, Buildx, and Podman.
|
||||
# The ext-deps stage extracts only the package.json files we need from the
|
||||
# bundled plugin workspace tree, so the main build layer is not invalidated by
|
||||
# unrelated plugin source changes.
|
||||
# The ext-deps stage extracts only the package.json files we need from
|
||||
# extensions/, so the main build layer is not invalidated by unrelated
|
||||
# extension source changes.
|
||||
#
|
||||
# Two runtime variants:
|
||||
# Default (bookworm): docker build .
|
||||
# Slim (bookworm-slim): docker build --build-arg OPENCLAW_VARIANT=slim .
|
||||
ARG OPENCLAW_EXTENSIONS=""
|
||||
ARG OPENCLAW_VARIANT=default
|
||||
ARG OPENCLAW_BUNDLED_PLUGIN_DIR=extensions
|
||||
ARG OPENCLAW_DOCKER_APT_UPGRADE=1
|
||||
ARG OPENCLAW_NODE_BOOKWORM_IMAGE="node:24-bookworm@sha256:3a09aa6354567619221ef6c45a5051b671f953f0a1924d1f819ffb236e520e6b"
|
||||
ARG OPENCLAW_NODE_BOOKWORM_DIGEST="sha256:3a09aa6354567619221ef6c45a5051b671f953f0a1924d1f819ffb236e520e6b"
|
||||
ARG OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE="node:24-bookworm-slim@sha256:e8e2e91b1378f83c5b2dd15f0247f34110e2fe895f6ca7719dbb780f929368eb"
|
||||
@@ -28,20 +26,18 @@ ARG OPENCLAW_NODE_BOOKWORM_SLIM_DIGEST="sha256:e8e2e91b1378f83c5b2dd15f0247f3411
|
||||
|
||||
FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS ext-deps
|
||||
ARG OPENCLAW_EXTENSIONS
|
||||
ARG OPENCLAW_BUNDLED_PLUGIN_DIR
|
||||
COPY ${OPENCLAW_BUNDLED_PLUGIN_DIR} /tmp/${OPENCLAW_BUNDLED_PLUGIN_DIR}
|
||||
COPY extensions /tmp/extensions
|
||||
# Copy package.json for opted-in extensions so pnpm resolves their deps.
|
||||
RUN mkdir -p /out && \
|
||||
for ext in $OPENCLAW_EXTENSIONS; do \
|
||||
if [ -f "/tmp/${OPENCLAW_BUNDLED_PLUGIN_DIR}/$ext/package.json" ]; then \
|
||||
if [ -f "/tmp/extensions/$ext/package.json" ]; then \
|
||||
mkdir -p "/out/$ext" && \
|
||||
cp "/tmp/${OPENCLAW_BUNDLED_PLUGIN_DIR}/$ext/package.json" "/out/$ext/package.json"; \
|
||||
cp "/tmp/extensions/$ext/package.json" "/out/$ext/package.json"; \
|
||||
fi; \
|
||||
done
|
||||
|
||||
# ── Stage 2: Build ──────────────────────────────────────────────
|
||||
FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS build
|
||||
ARG OPENCLAW_BUNDLED_PLUGIN_DIR
|
||||
|
||||
# Install Bun (required for build scripts). Retry the whole bootstrap flow to
|
||||
# tolerate transient 5xx failures from bun.sh/GitHub during CI image builds.
|
||||
@@ -64,9 +60,8 @@ WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
|
||||
COPY ui/package.json ./ui/package.json
|
||||
COPY patches ./patches
|
||||
COPY scripts/postinstall-bundled-plugins.mjs scripts/npm-runner.mjs ./scripts/
|
||||
|
||||
COPY --from=ext-deps /out/ ./${OPENCLAW_BUNDLED_PLUGIN_DIR}/
|
||||
COPY --from=ext-deps /out/ ./extensions/
|
||||
|
||||
# Reduce OOM risk on low-memory hosts during dependency installation.
|
||||
# Docker builds on small VMs may otherwise fail with "Killed" (exit 137).
|
||||
@@ -77,7 +72,7 @@ COPY . .
|
||||
|
||||
# Normalize extension paths now so runtime COPY preserves safe modes
|
||||
# without adding a second full extensions layer.
|
||||
RUN for dir in /app/${OPENCLAW_BUNDLED_PLUGIN_DIR} /app/.agent /app/.agents; do \
|
||||
RUN for dir in /app/extensions /app/.agent /app/.agents; do \
|
||||
if [ -d "$dir" ]; then \
|
||||
find "$dir" -type d -exec chmod 755 {} +; \
|
||||
find "$dir" -type f -exec chmod 644 {} +; \
|
||||
@@ -97,7 +92,6 @@ RUN pnpm build:docker
|
||||
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
|
||||
ENV OPENCLAW_PREFER_PNPM=1
|
||||
RUN pnpm ui:build
|
||||
RUN pnpm qa:lab:build
|
||||
|
||||
# Prune dev dependencies and strip build-only metadata before copying
|
||||
# runtime assets into the final image.
|
||||
@@ -119,8 +113,6 @@ LABEL org.opencontainers.image.base.name="docker.io/library/node:24-bookworm-sli
|
||||
# ── Stage 3: Runtime ────────────────────────────────────────────
|
||||
FROM base-${OPENCLAW_VARIANT}
|
||||
ARG OPENCLAW_VARIANT
|
||||
ARG OPENCLAW_BUNDLED_PLUGIN_DIR
|
||||
ARG OPENCLAW_DOCKER_APT_UPGRADE
|
||||
|
||||
# OCI base-image metadata for downstream image consumers.
|
||||
# If you change these annotations, also update:
|
||||
@@ -137,16 +129,12 @@ WORKDIR /app
|
||||
|
||||
# Install system utilities present in bookworm but missing in bookworm-slim.
|
||||
# On the full bookworm image these are already installed (apt-get is a no-op).
|
||||
# Smoke workflows can opt out of distro upgrades to cut repeated CI time while
|
||||
# keeping the default runtime image behavior unchanged.
|
||||
RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
|
||||
apt-get update && \
|
||||
if [ "${OPENCLAW_DOCKER_APT_UPGRADE}" != "0" ]; then \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get upgrade -y --no-install-recommends; \
|
||||
fi && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get upgrade -y --no-install-recommends && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
procps hostname curl git lsof openssl
|
||||
procps hostname curl git openssl
|
||||
|
||||
RUN chown node:node /app
|
||||
|
||||
@@ -154,14 +142,9 @@ COPY --from=runtime-assets --chown=node:node /app/dist ./dist
|
||||
COPY --from=runtime-assets --chown=node:node /app/node_modules ./node_modules
|
||||
COPY --from=runtime-assets --chown=node:node /app/package.json .
|
||||
COPY --from=runtime-assets --chown=node:node /app/openclaw.mjs .
|
||||
COPY --from=runtime-assets --chown=node:node /app/${OPENCLAW_BUNDLED_PLUGIN_DIR} ./${OPENCLAW_BUNDLED_PLUGIN_DIR}
|
||||
COPY --from=runtime-assets --chown=node:node /app/extensions ./extensions
|
||||
COPY --from=runtime-assets --chown=node:node /app/skills ./skills
|
||||
COPY --from=runtime-assets --chown=node:node /app/docs ./docs
|
||||
COPY --from=runtime-assets --chown=node:node /app/qa ./qa
|
||||
|
||||
# In npm-installed Docker images, prefer the copied source extension tree for
|
||||
# bundled discovery so package metadata that points at source entries stays valid.
|
||||
ENV OPENCLAW_BUNDLED_PLUGINS_DIR=/app/${OPENCLAW_BUNDLED_PLUGIN_DIR}
|
||||
|
||||
# Keep pnpm available in the runtime image for container-local workflows.
|
||||
# Use a shared Corepack home so the non-root `node` user does not need a
|
||||
|
||||
@@ -14,7 +14,6 @@ RUN --mount=type=cache,id=openclaw-sandbox-bookworm-apt-cache,target=/var/cache/
|
||||
chromium \
|
||||
curl \
|
||||
fonts-liberation \
|
||||
fonts-noto-cjk \
|
||||
fonts-noto-color-emoji \
|
||||
git \
|
||||
jq \
|
||||
|
||||
99
README.md
99
README.md
@@ -2,8 +2,8 @@
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text-dark.svg">
|
||||
<img src="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text.svg" alt="OpenClaw" width="500">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text-dark.png">
|
||||
<img src="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text.png" alt="OpenClaw" width="500">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
@@ -19,71 +19,22 @@
|
||||
</p>
|
||||
|
||||
**OpenClaw** is a _personal AI assistant_ you run on your own devices.
|
||||
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, BlueBubbles, IRC, Microsoft Teams, Matrix, Feishu, LINE, Mattermost, Nextcloud Talk, Nostr, Synology Chat, Tlon, Twitch, Zalo, Zalo Personal, WeChat, WebChat). It can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, BlueBubbles, IRC, Microsoft Teams, Matrix, Feishu, LINE, Mattermost, Nextcloud Talk, Nostr, Synology Chat, Tlon, Twitch, Zalo, Zalo Personal, WebChat). It can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
|
||||
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
|
||||
|
||||
[Website](https://openclaw.ai) · [Docs](https://docs.openclaw.ai) · [Vision](VISION.md) · [DeepWiki](https://deepwiki.com/openclaw/openclaw) · [Getting Started](https://docs.openclaw.ai/start/getting-started) · [Updating](https://docs.openclaw.ai/install/updating) · [Showcase](https://docs.openclaw.ai/start/showcase) · [FAQ](https://docs.openclaw.ai/help/faq) · [Onboarding](https://docs.openclaw.ai/start/wizard) · [Nix](https://github.com/openclaw/nix-openclaw) · [Docker](https://docs.openclaw.ai/install/docker) · [Discord](https://discord.gg/clawd)
|
||||
[Website](https://openclaw.ai) · [Docs](https://docs.openclaw.ai) · [Vision](VISION.md) · [DeepWiki](https://deepwiki.com/openclaw/openclaw) · [Getting Started](https://docs.openclaw.ai/start/getting-started) · [Updating](https://docs.openclaw.ai/install/updating) · [Showcase](https://docs.openclaw.ai/start/showcase) · [FAQ](https://docs.openclaw.ai/help/faq) · [Wizard](https://docs.openclaw.ai/start/wizard) · [Nix](https://github.com/openclaw/nix-openclaw) · [Docker](https://docs.openclaw.ai/install/docker) · [Discord](https://discord.gg/clawd)
|
||||
|
||||
Preferred setup: run `openclaw onboard` in your terminal.
|
||||
OpenClaw Onboard guides you step by step through setting up the gateway, workspace, channels, and skills. It is the recommended CLI setup path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**.
|
||||
Preferred setup: run the onboarding wizard (`openclaw onboard`) in your terminal.
|
||||
The wizard guides you step by step through setting up the gateway, workspace, channels, and skills. The CLI wizard is the recommended path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**.
|
||||
Works with npm, pnpm, or bun.
|
||||
New install? Start here: [Getting started](https://docs.openclaw.ai/start/getting-started)
|
||||
|
||||
## Sponsors
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="16.66%">
|
||||
<a href="https://openai.com/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/sponsors/openai-light.svg">
|
||||
<img src="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/sponsors/openai.svg" alt="OpenAI" height="28">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="16.66%">
|
||||
<a href="https://github.com/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/sponsors/github-light.svg">
|
||||
<img src="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/sponsors/github.svg" alt="GitHub" height="28">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="16.66%">
|
||||
<a href="https://www.nvidia.com/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/sponsors/nvidia.svg">
|
||||
<img src="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/sponsors/nvidia-dark.svg" alt="NVIDIA" height="28">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="16.66%">
|
||||
<a href="https://vercel.com/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/sponsors/vercel-light.svg">
|
||||
<img src="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/sponsors/vercel.svg" alt="Vercel" height="24">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="16.66%">
|
||||
<a href="https://blacksmith.sh/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/sponsors/blacksmith-light.svg">
|
||||
<img src="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/sponsors/blacksmith.svg" alt="Blacksmith" height="28">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="16.66%">
|
||||
<a href="https://www.convex.dev/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/sponsors/convex-light.svg">
|
||||
<img src="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/sponsors/convex.svg" alt="Convex" height="24">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
| OpenAI | Vercel | Blacksmith | Convex |
|
||||
| ----------------------------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------------- |
|
||||
| [](https://openai.com/) | [](https://vercel.com/) | [](https://blacksmith.sh/) | [](https://www.convex.dev/) |
|
||||
|
||||
**Subscriptions (OAuth):**
|
||||
|
||||
@@ -98,7 +49,7 @@ Model note: while many providers/models are supported, for the best experience a
|
||||
|
||||
## Install (recommended)
|
||||
|
||||
Runtime: **Node 24 (recommended) or Node 22.16+**.
|
||||
Runtime: **Node ≥22**.
|
||||
|
||||
```bash
|
||||
npm install -g openclaw@latest
|
||||
@@ -107,11 +58,11 @@ npm install -g openclaw@latest
|
||||
openclaw onboard --install-daemon
|
||||
```
|
||||
|
||||
OpenClaw Onboard installs the Gateway daemon (launchd/systemd user service) so it stays running.
|
||||
The wizard installs the Gateway daemon (launchd/systemd user service) so it stays running.
|
||||
|
||||
## Quick start (TL;DR)
|
||||
|
||||
Runtime: **Node 24 (recommended) or Node 22.16+**.
|
||||
Runtime: **Node ≥22**.
|
||||
|
||||
Full beginner guide (auth, pairing, channels): [Getting started](https://docs.openclaw.ai/start/getting-started)
|
||||
|
||||
@@ -123,7 +74,7 @@ openclaw gateway --port 18789 --verbose
|
||||
# Send a message
|
||||
openclaw message send --to +1234567890 --message "Hello from OpenClaw"
|
||||
|
||||
# Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Google Chat/Signal/iMessage/BlueBubbles/IRC/Microsoft Teams/Matrix/Feishu/LINE/Mattermost/Nextcloud Talk/Nostr/Synology Chat/Tlon/Twitch/Zalo/Zalo Personal/WeChat/WebChat)
|
||||
# Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Google Chat/Signal/iMessage/BlueBubbles/IRC/Microsoft Teams/Matrix/Feishu/LINE/Mattermost/Nextcloud Talk/Nostr/Synology Chat/Tlon/Twitch/Zalo/Zalo Personal/WebChat)
|
||||
openclaw agent --message "Ship checklist" --thinking high
|
||||
```
|
||||
|
||||
@@ -152,7 +103,7 @@ pnpm build
|
||||
|
||||
pnpm openclaw onboard --install-daemon
|
||||
|
||||
# Dev loop (auto-reload on source/config changes)
|
||||
# Dev loop (auto-reload on TS changes)
|
||||
pnpm gateway:watch
|
||||
```
|
||||
|
||||
@@ -175,13 +126,13 @@ Run `openclaw doctor` to surface risky/misconfigured DM policies.
|
||||
## Highlights
|
||||
|
||||
- **[Local-first Gateway](https://docs.openclaw.ai/gateway)** — single control plane for sessions, channels, tools, and events.
|
||||
- **[Multi-channel inbox](https://docs.openclaw.ai/channels)** — WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, BlueBubbles (iMessage), iMessage (legacy), IRC, Microsoft Teams, Matrix, Feishu, LINE, Mattermost, Nextcloud Talk, Nostr, Synology Chat, Tlon, Twitch, Zalo, Zalo Personal, WeChat, WebChat, macOS, iOS/Android.
|
||||
- **[Multi-channel inbox](https://docs.openclaw.ai/channels)** — WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, BlueBubbles (iMessage), iMessage (legacy), IRC, Microsoft Teams, Matrix, Feishu, LINE, Mattermost, Nextcloud Talk, Nostr, Synology Chat, Tlon, Twitch, Zalo, Zalo Personal, WebChat, macOS, iOS/Android.
|
||||
- **[Multi-agent routing](https://docs.openclaw.ai/gateway/configuration)** — route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions).
|
||||
- **[Voice Wake](https://docs.openclaw.ai/nodes/voicewake) + [Talk Mode](https://docs.openclaw.ai/nodes/talk)** — wake words on macOS/iOS and continuous voice on Android (ElevenLabs + system TTS fallback).
|
||||
- **[Live Canvas](https://docs.openclaw.ai/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui).
|
||||
- **[First-class tools](https://docs.openclaw.ai/tools)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions.
|
||||
- **[Companion apps](https://docs.openclaw.ai/platforms/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.openclaw.ai/nodes).
|
||||
- **[Onboarding](https://docs.openclaw.ai/start/wizard) + [skills](https://docs.openclaw.ai/tools/skills)** — onboarding-driven setup with bundled/managed/workspace skills.
|
||||
- **[Onboarding](https://docs.openclaw.ai/start/wizard) + [skills](https://docs.openclaw.ai/tools/skills)** — wizard-driven setup with bundled/managed/workspace skills.
|
||||
|
||||
## Star History
|
||||
|
||||
@@ -192,14 +143,14 @@ Run `openclaw doctor` to surface risky/misconfigured DM policies.
|
||||
### Core platform
|
||||
|
||||
- [Gateway WS control plane](https://docs.openclaw.ai/gateway) with sessions, presence, config, cron, webhooks, [Control UI](https://docs.openclaw.ai/web), and [Canvas host](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui).
|
||||
- [CLI surface](https://docs.openclaw.ai/tools/agent-send): gateway, agent, send, [onboarding](https://docs.openclaw.ai/start/wizard), and [doctor](https://docs.openclaw.ai/gateway/doctor).
|
||||
- [CLI surface](https://docs.openclaw.ai/tools/agent-send): gateway, agent, send, [wizard](https://docs.openclaw.ai/start/wizard), and [doctor](https://docs.openclaw.ai/gateway/doctor).
|
||||
- [Pi agent runtime](https://docs.openclaw.ai/concepts/agent) in RPC mode with tool streaming and block streaming.
|
||||
- [Session model](https://docs.openclaw.ai/concepts/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.openclaw.ai/channels/groups).
|
||||
- [Media pipeline](https://docs.openclaw.ai/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.openclaw.ai/nodes/audio).
|
||||
|
||||
### Channels
|
||||
|
||||
- [Channels](https://docs.openclaw.ai/channels): [WhatsApp](https://docs.openclaw.ai/channels/whatsapp) (Baileys), [Telegram](https://docs.openclaw.ai/channels/telegram) (grammY), [Slack](https://docs.openclaw.ai/channels/slack) (Bolt), [Discord](https://docs.openclaw.ai/channels/discord) (discord.js), [Google Chat](https://docs.openclaw.ai/channels/googlechat) (Chat API), [Signal](https://docs.openclaw.ai/channels/signal) (signal-cli), [BlueBubbles](https://docs.openclaw.ai/channels/bluebubbles) (iMessage, recommended), [iMessage](https://docs.openclaw.ai/channels/imessage) (legacy imsg), [IRC](https://docs.openclaw.ai/channels/irc), [Microsoft Teams](https://docs.openclaw.ai/channels/msteams), [Matrix](https://docs.openclaw.ai/channels/matrix), [Feishu](https://docs.openclaw.ai/channels/feishu), [LINE](https://docs.openclaw.ai/channels/line), [Mattermost](https://docs.openclaw.ai/channels/mattermost), [Nextcloud Talk](https://docs.openclaw.ai/channels/nextcloud-talk), [Nostr](https://docs.openclaw.ai/channels/nostr), [Synology Chat](https://docs.openclaw.ai/channels/synology-chat), [Tlon](https://docs.openclaw.ai/channels/tlon), [Twitch](https://docs.openclaw.ai/channels/twitch), [Zalo](https://docs.openclaw.ai/channels/zalo), [Zalo Personal](https://docs.openclaw.ai/channels/zalouser), WeChat (`@tencent-weixin/openclaw-weixin`), [WebChat](https://docs.openclaw.ai/web/webchat).
|
||||
- [Channels](https://docs.openclaw.ai/channels): [WhatsApp](https://docs.openclaw.ai/channels/whatsapp) (Baileys), [Telegram](https://docs.openclaw.ai/channels/telegram) (grammY), [Slack](https://docs.openclaw.ai/channels/slack) (Bolt), [Discord](https://docs.openclaw.ai/channels/discord) (discord.js), [Google Chat](https://docs.openclaw.ai/channels/googlechat) (Chat API), [Signal](https://docs.openclaw.ai/channels/signal) (signal-cli), [BlueBubbles](https://docs.openclaw.ai/channels/bluebubbles) (iMessage, recommended), [iMessage](https://docs.openclaw.ai/channels/imessage) (legacy imsg), [IRC](https://docs.openclaw.ai/channels/irc), [Microsoft Teams](https://docs.openclaw.ai/channels/msteams), [Matrix](https://docs.openclaw.ai/channels/matrix), [Feishu](https://docs.openclaw.ai/channels/feishu), [LINE](https://docs.openclaw.ai/channels/line), [Mattermost](https://docs.openclaw.ai/channels/mattermost), [Nextcloud Talk](https://docs.openclaw.ai/channels/nextcloud-talk), [Nostr](https://docs.openclaw.ai/channels/nostr), [Synology Chat](https://docs.openclaw.ai/channels/synology-chat), [Tlon](https://docs.openclaw.ai/channels/tlon), [Twitch](https://docs.openclaw.ai/channels/twitch), [Zalo](https://docs.openclaw.ai/channels/zalo), [Zalo Personal](https://docs.openclaw.ai/channels/zalouser), [WebChat](https://docs.openclaw.ai/web/webchat).
|
||||
- [Group routing](https://docs.openclaw.ai/channels/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.openclaw.ai/channels).
|
||||
|
||||
### Apps + nodes
|
||||
@@ -234,7 +185,7 @@ Run `openclaw doctor` to surface risky/misconfigured DM policies.
|
||||
## How it works (short)
|
||||
|
||||
```
|
||||
WhatsApp / Telegram / Slack / Discord / Google Chat / Signal / iMessage / BlueBubbles / IRC / Microsoft Teams / Matrix / Feishu / LINE / Mattermost / Nextcloud Talk / Nostr / Synology Chat / Tlon / Twitch / Zalo / Zalo Personal / WeChat / WebChat
|
||||
WhatsApp / Telegram / Slack / Discord / Google Chat / Signal / iMessage / BlueBubbles / IRC / Microsoft Teams / Matrix / Feishu / LINE / Mattermost / Nextcloud Talk / Nostr / Synology Chat / Tlon / Twitch / Zalo / Zalo Personal / WebChat
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────┐
|
||||
@@ -342,7 +293,7 @@ If you plan to build/run companion apps, follow the platform runbooks below.
|
||||
- WebChat + debug tools.
|
||||
- Remote gateway control over SSH.
|
||||
|
||||
Note: signed builds required for macOS permissions to stick across rebuilds (see [macOS Permissions](https://docs.openclaw.ai/platforms/mac/permissions)).
|
||||
Note: signed builds required for macOS permissions to stick across rebuilds (see `docs/mac/permissions.md`).
|
||||
|
||||
### iOS node (optional)
|
||||
|
||||
@@ -413,7 +364,7 @@ Details: [Security guide](https://docs.openclaw.ai/gateway/security) · [Docker
|
||||
|
||||
### [Discord](https://docs.openclaw.ai/channels/discord)
|
||||
|
||||
- Set `DISCORD_BOT_TOKEN` or `channels.discord.token`.
|
||||
- Set `DISCORD_BOT_TOKEN` or `channels.discord.token` (env wins).
|
||||
- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `channels.discord.allowFrom`, `channels.discord.guilds`, or `channels.discord.mediaMaxMb` as needed.
|
||||
|
||||
```json5
|
||||
@@ -446,12 +397,6 @@ Details: [Security guide](https://docs.openclaw.ai/gateway/security) · [Docker
|
||||
- Configure a Teams app + Bot Framework, then add a `msteams` config section.
|
||||
- Allowlist who can talk via `msteams.allowFrom`; group access via `msteams.groupAllowFrom` or `msteams.groupPolicy: "open"`.
|
||||
|
||||
### WeChat
|
||||
|
||||
- Official Tencent plugin via [`@tencent-weixin/openclaw-weixin`](https://www.npmjs.com/package/@tencent-weixin/openclaw-weixin) (iLink Bot API). Private chats only; v2.x requires OpenClaw `>=2026.3.22`.
|
||||
- Install: `openclaw plugins install "@tencent-weixin/openclaw-weixin"`, then `openclaw channels login --channel openclaw-weixin` to scan the QR code.
|
||||
- Requires the WeChat ClawBot plugin (WeChat > Me > Settings > Plugins); gradual rollout by Tencent.
|
||||
|
||||
### [WebChat](https://docs.openclaw.ai/web/webchat)
|
||||
|
||||
- Uses the Gateway WebSocket; no separate WebChat port/config.
|
||||
@@ -477,7 +422,7 @@ Use these when you’re past the onboarding flow and want the deeper reference.
|
||||
- [Run the Gateway by the book with the operational runbook.](https://docs.openclaw.ai/gateway)
|
||||
- [Learn how the Control UI/Web surfaces work and how to expose them safely.](https://docs.openclaw.ai/web)
|
||||
- [Understand remote access over SSH tunnels or tailnets.](https://docs.openclaw.ai/gateway/remote)
|
||||
- [Follow OpenClaw Onboard for a guided setup.](https://docs.openclaw.ai/start/wizard)
|
||||
- [Follow the onboarding wizard flow for a guided setup.](https://docs.openclaw.ai/start/wizard)
|
||||
- [Wire external triggers via the webhook surface.](https://docs.openclaw.ai/automation/webhook)
|
||||
- [Set up Gmail Pub/Sub triggers.](https://docs.openclaw.ai/automation/gmail-pubsub)
|
||||
- [Learn the macOS menu bar companion details.](https://docs.openclaw.ai/platforms/mac/menu-bar)
|
||||
|
||||
35
SECURITY.md
35
SECURITY.md
@@ -56,12 +56,8 @@ These are frequently reported but are typically closed with no code change:
|
||||
- Authorized user-triggered local actions presented as privilege escalation. Example: an allowlisted/owner sender running `/export-session /absolute/path.html` to write on the host. In this trust model, authorized user actions are trusted host actions unless you demonstrate an auth/sandbox/boundary bypass.
|
||||
- Reports that only show a malicious plugin executing privileged actions after a trusted operator installs/enables it.
|
||||
- Reports that assume per-user multi-tenant authorization on a shared gateway host/config.
|
||||
- Reports that only show quoted/replied/thread/forwarded supplemental context from non-allowlisted senders being visible to the model, without demonstrating an auth, policy, approval, or sandbox boundary bypass.
|
||||
- Reports that treat the Gateway HTTP compatibility endpoints (`POST /v1/chat/completions`, `POST /v1/responses`) as if they implemented scoped operator auth (`operator.write` vs `operator.admin`). These endpoints authenticate the shared Gateway bearer secret/password and are documented full operator-access surfaces, not per-user/per-scope boundaries.
|
||||
- Reports that assume `x-openclaw-scopes` can reduce or redefine shared-secret bearer auth on the OpenAI-compatible HTTP endpoints. For shared-secret auth (`gateway.auth.mode="token"` or `"password"`), those endpoints ignore narrower bearer-declared scopes and restore the full default operator scope set plus owner semantics.
|
||||
- Reports that treat `POST /tools/invoke` under shared-secret bearer auth (`gateway.auth.mode="token"` or `"password"`) as a narrower per-request/per-scope authorization surface. That endpoint is designed as the same trusted-operator HTTP boundary: shared-secret bearer auth is full operator access there, narrower `x-openclaw-scopes` values do not reduce that path, and owner-only tool policy follows the shared-secret operator contract.
|
||||
- Reports that only show differences in heuristic detection/parity (for example obfuscation-pattern detection on one exec path but not another, such as `node.invoke -> system.run` parity gaps) without demonstrating bypass of auth, approvals, allowlist enforcement, sandboxing, or other documented trust boundaries.
|
||||
- Reports that only show an ACP tool can indirectly execute, mutate, orchestrate sessions, or reach another tool/runtime without demonstrating bypass of ACP prompt/approval, allowlist enforcement, sandboxing, or another documented trust boundary. ACP silent approval is intentionally limited to narrow readonly classes; parity-only indirect-command findings are hardening, not vulnerabilities.
|
||||
- ReDoS/DoS claims that require trusted operator configuration input (for example catastrophic regex in `sessionFilter` or `logging.redactPatterns`) without a trust-boundary bypass.
|
||||
- Archive/install extraction claims that require pre-existing local filesystem priming in trusted state (for example planting symlink/hardlink aliases under destination directories such as skills/tools paths) without showing an untrusted path that can create/control that primitive.
|
||||
- Reports that depend on replacing or rewriting an already-approved executable path on a trusted host (same-path inode/content swap) without showing an untrusted path to perform that write.
|
||||
@@ -97,15 +93,7 @@ When patching a GHSA via `gh api`, include `X-GitHub-Api-Version: 2022-11-28` (o
|
||||
OpenClaw does **not** model one gateway as a multi-tenant, adversarial user boundary.
|
||||
|
||||
- Authenticated Gateway callers are treated as trusted operators for that gateway instance.
|
||||
- Direct localhost/loopback Control UI and Gateway WebSocket sessions authenticated with the shared gateway secret (`token` / `password`) are in that same trusted-operator bucket. Local auto-paired device sessions on that path are expected to retain full localhost operator capability; they do not create a separate `operator.write` vs `operator.admin` security boundary.
|
||||
- The HTTP compatibility endpoints (`POST /v1/chat/completions`, `POST /v1/responses`) and direct tool endpoint (`POST /tools/invoke`) are in that same trusted-operator bucket. Passing Gateway bearer auth there is equivalent to operator access for that gateway; they do not implement a narrower `operator.write` vs `operator.admin` trust split.
|
||||
- Concretely, on the OpenAI-compatible HTTP surface:
|
||||
- shared-secret bearer auth (`token` / `password`) authenticates possession of the gateway operator secret
|
||||
- those requests receive the full default operator scope set (`operator.admin`, `operator.read`, `operator.write`, `operator.approvals`, `operator.pairing`)
|
||||
- chat-turn endpoints (`/v1/chat/completions`, `/v1/responses`) also treat those shared-secret callers as owner senders for owner-only tool policy
|
||||
- `POST /tools/invoke` follows that same shared-secret rule and also treats those callers as owner senders for owner-only tool policy
|
||||
- narrower `x-openclaw-scopes` headers are ignored for that shared-secret path
|
||||
- only identity-bearing HTTP modes (for example trusted proxy auth or `gateway.auth.mode="none"` on private ingress) honor declared per-request operator scopes
|
||||
- The HTTP compatibility endpoints (`POST /v1/chat/completions`, `POST /v1/responses`) are in that same trusted-operator bucket. Passing Gateway bearer auth there is equivalent to operator access for that gateway; they do not implement a narrower `operator.write` vs `operator.admin` trust split.
|
||||
- Session identifiers (`sessionKey`, session IDs, labels) are routing controls, not per-user authorization boundaries.
|
||||
- If one operator can view data from another operator on the same gateway, that is expected in this trust model.
|
||||
- OpenClaw can technically run multiple gateway instances on one machine, but recommended operations are clean separation by trust boundary.
|
||||
@@ -113,7 +101,7 @@ OpenClaw does **not** model one gateway as a multi-tenant, adversarial user boun
|
||||
- If multiple users need OpenClaw, use one VPS (or host/OS user boundary) per user.
|
||||
- For advanced setups, multiple gateways on one machine are possible, but only with strict isolation and are not the recommended default.
|
||||
- Exec behavior is host-first by default: `agents.defaults.sandbox.mode` defaults to `off`.
|
||||
- `tools.exec.host` defaults to `auto`: sandbox when sandbox runtime is active for the session, otherwise gateway.
|
||||
- `tools.exec.host` defaults to `sandbox` as a routing preference, but if sandbox runtime is not active for the session, exec runs on the gateway host.
|
||||
- Implicit exec calls (no explicit host in the tool call) follow the same behavior.
|
||||
- This is expected in OpenClaw's one-user trusted-operator model. If you need isolation, enable sandbox mode (`non-main`/`all`) and keep strict tool policy.
|
||||
|
||||
@@ -141,7 +129,6 @@ Plugins/extensions are part of OpenClaw's trusted computing base for a gateway.
|
||||
- Any report whose only claim is that an operator-enabled `dangerous*`/`dangerously*` config option weakens defaults (these are explicit break-glass tradeoffs by design)
|
||||
- Reports that depend on trusted operator-supplied configuration values to trigger availability impact (for example custom regex patterns). These may still be fixed as defense-in-depth hardening, but are not security-boundary bypasses.
|
||||
- Reports whose only claim is heuristic/parity drift in command-risk detection (for example obfuscation-pattern checks) across exec surfaces, without a demonstrated trust-boundary bypass. These are hardening-only findings and are not vulnerabilities; triage may close them as `invalid`/`no-action` or track them separately as low/informational hardening.
|
||||
- Reports whose only claim is that an ACP-exposed tool can indirectly execute commands, mutate host state, or reach another privileged tool/runtime without demonstrating a bypass of ACP prompt/approval, allowlist enforcement, sandboxing, or another documented trust boundary. These are hardening-only findings, not vulnerabilities.
|
||||
- Reports whose only claim is that exec approvals do not semantically model every interpreter/runtime loader form, subcommand, flag combination, package script, or transitive module/config import. Exec approvals bind exact request context and best-effort direct local file operands; they are not a complete semantic model of everything a runtime may load.
|
||||
- Exposed secrets that are third-party/user-controlled credentials (not OpenClaw-owned and not granting access to OpenClaw-operated infrastructure/services) without demonstrated OpenClaw impact
|
||||
- Reports whose only claim is host-side exec when sandbox runtime is disabled/unavailable (documented default behavior in the trusted-operator model), without a boundary bypass.
|
||||
@@ -169,24 +156,6 @@ OpenClaw's security model is "personal assistant" (one trusted operator, potenti
|
||||
- For company-shared setups, use a dedicated machine/VM/container and dedicated accounts; avoid mixing personal data on that runtime.
|
||||
- If that host/browser profile is logged into personal accounts (for example Apple/Google/personal password manager), you have collapsed the boundary and increased personal-data exposure risk.
|
||||
|
||||
## Context Visibility and Allowlists
|
||||
|
||||
OpenClaw distinguishes:
|
||||
|
||||
- **Trigger authorization**: who can trigger the agent (`dmPolicy`, `groupPolicy`, allowlists, mention gates)
|
||||
- **Context visibility**: what supplemental context is provided to the model (reply body, quoted text, thread history, forwarded metadata)
|
||||
|
||||
In current releases, allowlists primarily gate triggering and owner-style command access. They do not guarantee universal supplemental-context redaction across every channel/surface.
|
||||
|
||||
Current channel behavior is not fully uniform:
|
||||
|
||||
- some channels already filter parts of supplemental context by sender allowlist
|
||||
- other channels still pass supplemental context as received
|
||||
|
||||
Reports that only show supplemental-context visibility differences are typically hardening/consistency findings unless they also demonstrate a documented boundary bypass (auth, policy, approvals, sandbox, or equivalent).
|
||||
|
||||
Hardening roadmap may add explicit visibility modes (for example `all`, `allowlist`, `allowlist_quote`) so operators can opt into stricter context filtering with predictable tradeoffs.
|
||||
|
||||
## Agent and Model Assumptions
|
||||
|
||||
- The model/agent is **not** a trusted principal. Assume prompt/content injection can manipulate behavior.
|
||||
|
||||
471
appcast.xml
471
appcast.xml
@@ -3,305 +3,246 @@
|
||||
<channel>
|
||||
<title>OpenClaw</title>
|
||||
<item>
|
||||
<title>2026.4.2</title>
|
||||
<pubDate>Thu, 02 Apr 2026 18:57:54 +0000</pubDate>
|
||||
<title>2026.3.13</title>
|
||||
<pubDate>Sat, 14 Mar 2026 05:19:48 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026040290</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.4.2</sparkle:shortVersionString>
|
||||
<sparkle:version>2026031390</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.3.13</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.4.2</h2>
|
||||
<h3>Breaking</h3>
|
||||
<ul>
|
||||
<li>Plugins/xAI: move <code>x_search</code> settings from the legacy core <code>tools.web.x_search.*</code> path to the plugin-owned <code>plugins.entries.xai.config.xSearch.*</code> path, standardize <code>x_search</code> auth on <code>plugins.entries.xai.config.webSearch.apiKey</code> / <code>XAI_API_KEY</code>, and migrate legacy config with <code>openclaw doctor --fix</code>. (#59674) Thanks @vincentkoc.</li>
|
||||
<li>Plugins/web fetch: move Firecrawl <code>web_fetch</code> config from the legacy core <code>tools.web.fetch.firecrawl.*</code> path to the plugin-owned <code>plugins.entries.firecrawl.config.webFetch.*</code> path, route <code>web_fetch</code> fallback through the new fetch-provider boundary instead of a Firecrawl-only core branch, and migrate legacy config with <code>openclaw doctor --fix</code>. (#59465) Thanks @vincentkoc.</li>
|
||||
</ul>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.3.13</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Tasks/Task Flow: restore the core Task Flow substrate with managed-vs-mirrored sync modes, durable flow state/revision tracking, and <code>openclaw flows</code> inspection/recovery primitives so background orchestration can persist and be operated separately from plugin authoring layers. (#58930) Thanks @mbelinky.</li>
|
||||
<li>Tasks/Task Flow: add managed child task spawning plus sticky cancel intent, so external orchestrators can stop scheduling immediately and let parent Task Flows settle to <code>cancelled</code> once active child tasks finish. (#59610) Thanks @mbelinky.</li>
|
||||
<li>Plugins/Task Flow: add a bound <code>api.runtime.taskFlow</code> seam so plugins and trusted authoring layers can create and drive managed Task Flows from host-resolved OpenClaw context without passing owner identifiers on each call. (#59622) Thanks @mbelinky.</li>
|
||||
<li>Android/assistant: add assistant-role entrypoints plus Google Assistant App Actions metadata so Android can launch OpenClaw from the assistant trigger and hand prompts into the chat composer. (#59596) Thanks @obviyus.</li>
|
||||
<li>Exec defaults: make gateway/node host exec default to YOLO mode by requesting <code>security=full</code> with <code>ask=off</code>, and align host approval-file fallbacks plus docs/doctor reporting with that no-prompt default.</li>
|
||||
<li>Providers/runtime: add provider-owned replay hook surfaces for transcript policy, replay cleanup, and reasoning-mode dispatch. (#59143) Thanks @jalehman.</li>
|
||||
<li>Plugins/hooks: add <code>before_agent_reply</code> so plugins can short-circuit the LLM with synthetic replies after inline actions. (#20067) Thanks @JoshuaLelon.</li>
|
||||
<li>Channels/session routing: move provider-specific session conversation grammar into plugin-owned session-key surfaces, preserving Telegram topic routing and Feishu scoped inheritance across bootstrap, model override, restart, and tool-policy paths.</li>
|
||||
<li>Feishu/comments: add a dedicated Drive comment-event flow with comment-thread context resolution, in-thread replies, and <code>feishu_drive</code> comment actions for document collaboration workflows. (#58497) Thanks @wittam-01.</li>
|
||||
<li>Matrix/plugin: emit spec-compliant <code>m.mentions</code> metadata across text sends, media captions, edits, poll fallback text, and action-driven edits so Matrix mentions notify reliably in clients like Element. (#59323) Thanks @gumadeiras.</li>
|
||||
<li>Diffs: add plugin-owned <code>viewerBaseUrl</code> so viewer links can use a stable proxy/public origin without passing <code>baseUrl</code> on every tool call. (#59341) Related #59227. Thanks @gumadeiras.</li>
|
||||
<li>Agents/compaction: resolve <code>agents.defaults.compaction.model</code> consistently for manual <code>/compact</code> and other context-engine compaction paths, so engine-owned compaction uses the configured override model across runtime entrypoints. (#56710) Thanks @oliviareid-svg.</li>
|
||||
<li>Agents/compaction: add <code>agents.defaults.compaction.notifyUser</code> so the <code>🧹 Compacting context...</code> start notice is opt-in instead of always being shown. (#54251) Thanks @oguricap0327.</li>
|
||||
<li>WhatsApp/reactions: add <code>reactionLevel</code> guidance for agent reactions. Thanks @mcaxtr.</li>
|
||||
<li>Exec approvals/channels: auto-enable DM-first native chat approvals when supported channels can infer approvers from existing owner config, while keeping channel fanout explicit and clarifying forwarding versus native approval client config.</li>
|
||||
<li>Android/chat settings: redesign the chat settings sheet with grouped device and media sections, refresh the Connect and Voice tabs, and tighten the chat composer/session header for a denser mobile layout. (#44894) Thanks @obviyus.</li>
|
||||
<li>iOS/onboarding: add a first-run welcome pager before gateway setup, stop auto-opening the QR scanner, and show <code>/pair qr</code> instructions on the connect step. (#45054) Thanks @ngutman.</li>
|
||||
<li>Browser/existing-session: add an official Chrome DevTools MCP attach mode for signed-in live Chrome sessions, with docs for <code>chrome://inspect/#remote-debugging</code> enablement and direct backlinks to Chrome’s own setup guides.</li>
|
||||
<li>Browser/agents: add built-in <code>profile="user"</code> for the logged-in host browser and <code>profile="chrome-relay"</code> for the extension relay, so agent browser calls can prefer the real signed-in browser without the extra <code>browserSession</code> selector.</li>
|
||||
<li>Browser/act automation: add batched actions, selector targeting, and delayed clicks for browser act requests with normalized batch dispatch. Thanks @vincentkoc.</li>
|
||||
<li>Docker/timezone override: add <code>OPENCLAW_TZ</code> so <code>docker-setup.sh</code> can pin gateway and CLI containers to a chosen IANA timezone instead of inheriting the daemon default. (#34119) Thanks @Lanfei.</li>
|
||||
<li>Dependencies/pi: bump <code>@mariozechner/pi-agent-core</code>, <code>@mariozechner/pi-ai</code>, <code>@mariozechner/pi-coding-agent</code>, and <code>@mariozechner/pi-tui</code> to <code>0.58.0</code>.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Providers/transport policy: centralize request auth, proxy, TLS, and header shaping across shared HTTP, stream, and websocket paths, block insecure TLS/runtime transport overrides, and keep proxy-hop TLS separate from target mTLS settings. (#59682) Thanks @vincentkoc.</li>
|
||||
<li>Providers/Copilot: classify native GitHub Copilot API hosts in the shared provider endpoint resolver and harden token-derived proxy endpoint parsing so Copilot base URL routing stays centralized and fails closed on malformed hints. (#59644) Thanks @vincentkoc.</li>
|
||||
<li>Providers/streaming headers: centralize default and attribution header merging across OpenAI websocket, embedded-runner, and proxy stream paths so provider-specific headers stay consistent and caller overrides only win where intended. (#59542) Thanks @vincentkoc.</li>
|
||||
<li>Providers/media HTTP: centralize base URL normalization, default auth/header injection, and explicit header override handling across shared OpenAI-compatible audio, Deepgram audio, Gemini media/image, and Moonshot video request paths. (#59469) Thanks @vincentkoc.</li>
|
||||
<li>Providers/OpenAI-compatible routing: centralize native-vs-proxy request policy so hidden attribution and related OpenAI-family defaults only apply on verified native endpoints across stream, websocket, and shared audio HTTP paths. (#59433) Thanks @vincentkoc.</li>
|
||||
<li>Providers/Anthropic routing: centralize native-vs-proxy endpoint classification for direct Anthropic <code>service_tier</code> handling so spoofed or proxied hosts do not inherit native Anthropic defaults. (#59608) Thanks @vincentkoc.</li>
|
||||
<li>Gateway/exec loopback: restore legacy-role fallback for empty paired-device token maps and allow silent local role upgrades so local exec and node clients stop failing with pairing-required errors after <code>2026.3.31</code>. (#59092) Thanks @openperf.</li>
|
||||
<li>Agents/subagents: pin admin-only subagent gateway calls to <code>operator.admin</code> while keeping <code>agent</code> at least privilege, so <code>sessions_spawn</code> no longer dies on loopback scope-upgrade pairing with <code>close(1008) "pairing required"</code>. (#59555) Thanks @openperf.</li>
|
||||
<li>Exec approvals/config: strip invalid <code>security</code>, <code>ask</code>, and <code>askFallback</code> values from <code>~/.openclaw/exec-approvals.json</code> during normalization so malformed policy enums fall back cleanly to the documented defaults instead of corrupting runtime policy resolution. (#59112) Thanks @openperf.</li>
|
||||
<li>Exec approvals/doctor: report host policy sources from the real approvals file path and ignore malformed host override values when attributing effective policy conflicts. (#59367) Thanks @gumadeiras.</li>
|
||||
<li>Exec/runtime: treat <code>tools.exec.host=auto</code> as routing-only, keep implicit no-config exec on sandbox when available or gateway otherwise, and reject per-call host overrides that would bypass the configured sandbox or host target. (#58897) Thanks @vincentkoc.</li>
|
||||
<li>Slack/mrkdwn formatting: add built-in Slack mrkdwn guidance in inbound context so Slack replies stop falling back to generic Markdown patterns that render poorly in Slack. (#59100) Thanks @jadewon.</li>
|
||||
<li>WhatsApp/presence: send <code>unavailable</code> presence on connect in self-chat mode so personal-phone users stop losing all push notifications while the gateway is running. (#59410) Thanks @mcaxtr.</li>
|
||||
<li>WhatsApp/media: add HTML, XML, and CSS to the MIME map and fall back gracefully for unknown media types instead of dropping the attachment. (#51562) Thanks @bobbyt74.</li>
|
||||
<li>Matrix/onboarding: restore guided setup in <code>openclaw channels add</code> and <code>openclaw configure --section channels</code>, while keeping custom plugin wizards on the shared <code>setupWizard</code> seam. (#59462) Thanks @gumadeiras.</li>
|
||||
<li>Matrix/streaming: keep live partial previews for the current assistant block while preserving completed block updates as separate messages when <code>channels.matrix.blockStreaming</code> is enabled. (#59384) Thanks @gumadeiras.</li>
|
||||
<li>Feishu/comment threads: harden document comment-thread delivery so whole-document comments fall back to <code>add_comment</code>, delayed reply lookups retry more reliably, and user-visible replies avoid reasoning/planning spillover. (#59129) Thanks @wittam-01.</li>
|
||||
<li>MS Teams/streaming: strip already-streamed text from fallback block delivery when replies exceed the 4000-character streaming limit so long responses stop duplicating content. (#59297) Thanks @bradgroux.</li>
|
||||
<li>Slack/thread context: filter thread starter and history by the effective conversation allowlist without dropping valid open-room, DM, or group DM context. (#58380) Thanks @jacobtomlinson.</li>
|
||||
<li>Mattermost/probes: route status probes through the SSRF guard and honor <code>allowPrivateNetwork</code> so connectivity checks stay safe for self-hosted Mattermost deployments. (#58529) Thanks @mappel-nv.</li>
|
||||
<li>Zalo/webhook replay: scope replay dedupe key by chat and sender so reused message IDs across different chats or senders no longer collide, and harden metadata reads for partially missing payloads. (#58444)</li>
|
||||
<li>QQBot/structured payloads: restrict local file paths to QQ Bot-owned media storage, block traversal outside that root, reduce path leakage in logs, and keep inline image data URLs working. (#58453) Thanks @jacobtomlinson.</li>
|
||||
<li>Image generation/providers: route OpenAI, MiniMax, and fal image requests through the shared provider HTTP transport path so custom base URLs, guarded private-network routing, and provider request defaults stay aligned with the rest of provider HTTP. Thanks @vincentkoc.</li>
|
||||
<li>Image generation/providers: stop inferring private-network access from configured OpenAI, MiniMax, and fal image base URLs, and cap shared HTTP error-body reads so hostile or misconfigured endpoints fail closed without relaxing SSRF policy or buffering unbounded error payloads. Thanks @vincentkoc.</li>
|
||||
<li>Browser/host inspection: keep static Chrome inspection helpers out of the activated browser runtime so <code>openclaw doctor browser</code> and related checks do not eagerly load the bundled browser plugin. (#59471) Thanks @vincentkoc.</li>
|
||||
<li>Browser/CDP: normalize trailing-dot localhost absolute-form hosts before loopback checks so remote CDP websocket URLs like <code>ws://localhost.:...</code> rewrite back to the configured remote host. (#59236) Thanks @mappel-nv.</li>
|
||||
<li>Agents/output sanitization: strip namespaced <code>antml:thinking</code> blocks from user-visible text so Anthropic-style internal monologue tags do not leak into replies. (#59550) Thanks @obviyus.</li>
|
||||
<li>Kimi Coding/tools: normalize Anthropic tool payloads into the OpenAI-compatible function shape Kimi Coding expects so tool calls stop losing required arguments. (#59440) Thanks @obviyus.</li>
|
||||
<li>Image tool/paths: resolve relative local media paths against the agent <code>workspaceDir</code> instead of <code>process.cwd()</code> so inputs like <code>inbox/receipt.png</code> pass the local-path allowlist reliably. (#57222) Thanks Priyansh Gupta.</li>
|
||||
<li>Podman/launch: remove noisy container output from <code>scripts/run-openclaw-podman.sh</code> and align the Podman install guidance with the quieter startup flow. (#59368) Thanks @sallyom.</li>
|
||||
<li>Plugins/runtime: keep LINE reply directives and browser-backed cleanup/reset flows working even when those plugins are disabled while tightening bundled plugin activation guards. (#59412) Thanks @vincentkoc.</li>
|
||||
<li>ACP/gateway reconnects: keep ACP prompts alive across transient websocket drops while still failing boundedly when reconnect recovery does not complete. (#59473) Thanks @obviyus.</li>
|
||||
<li>ACP/gateway reconnects: reject stale pre-ack ACP prompts after reconnect grace expiry so callers fail cleanly instead of hanging indefinitely when the gateway never confirms the run.</li>
|
||||
<li>Gateway/session kill: enforce HTTP operator scopes on session kill requests and gate authorization before session lookup so unauthenticated callers cannot probe session existence. (#59128) Thanks @jacobtomlinson.</li>
|
||||
<li>MS Teams/logging: format non-<code>Error</code> failures with the shared unknown-error helper so logs stop collapsing caught SDK or Axios objects into <code>[object Object]</code>. (#59321) Thanks @bradgroux.</li>
|
||||
<li>Channels/setup: ignore untrusted workspace channel plugins during setup resolution so a shadowing workspace plugin cannot override built-in channel setup/login flows unless explicitly trusted in config. (#59158) Thanks @mappel-nv.</li>
|
||||
<li>Exec/Windows: restore allowlist enforcement with quote-aware <code>argPattern</code> matching across gateway and node exec, and surface accurate dynamic pre-approved executable hints in the exec tool description. (#56285) Thanks @kpngr.</li>
|
||||
<li>Gateway: prune empty <code>node-pending-work</code> state entries after explicit acknowledgments and natural expiry so the per-node state map no longer grows indefinitely. (#58179) Thanks @gavyngong.</li>
|
||||
<li>Webhooks/secret comparison: replace ad-hoc timing-safe secret comparisons across BlueBubbles, Feishu, Mattermost, Telegram, Twilio, and Zalo webhook handlers with the shared <code>safeEqualSecret</code> helper and reject empty auth tokens in BlueBubbles. (#58432) Thanks @eleqtrizit.</li>
|
||||
<li>OpenShell/mirror: constrain <code>remoteWorkspaceDir</code> and <code>remoteAgentWorkspaceDir</code> to the managed <code>/sandbox</code> and <code>/agent</code> roots, and keep mirror sync from overwriting or removing user-added shell roots during config synchronization. (#58515) Thanks @eleqtrizit.</li>
|
||||
<li>Plugins/activation: preserve explicit, auto-enabled, and default activation provenance plus reason metadata across CLI, gateway bootstrap, and status surfaces so plugin enablement state stays accurate after auto-enable resolution. (#59641) Thanks @vincentkoc.</li>
|
||||
<li>Exec/env: block additional host environment override pivots for package roots, language runtimes, compiler include paths, and credential/config locations so request-scoped exec cannot redirect trusted toolchains or config lookups. (#59233) Thanks @drobison00.</li>
|
||||
<li>Dotenv/workspace overrides: block workspace <code>.env</code> files from overriding <code>OPENCLAW_PINNED_PYTHON</code> and <code>OPENCLAW_PINNED_WRITE_PYTHON</code> so trusted helper interpreters cannot be redirected by repo-local env injection. (#58473) Thanks @eleqtrizit.</li>
|
||||
<li>Plugins/install: accept JSON5 syntax in <code>openclaw.plugin.json</code> and bundle <code>plugin.json</code> manifests during install/validation, so third-party plugins with trailing commas, comments, or unquoted keys no longer fail to install. (#59084) Thanks @singleGanghood.</li>
|
||||
<li>Telegram/exec approvals: rewrite shared <code>/approve … allow-always</code> callback payloads to <code>/approve … always</code> before Telegram button rendering so plugin approval IDs still fit Telegram's <code>callback_data</code> limit and keep the Allow Always action visible. (#59217) Thanks @jameslcowan.</li>
|
||||
<li>Cron/exec timeouts: surface timed-out <code>exec</code> and <code>bash</code> failures in isolated cron runs even when <code>verbose: off</code>, including custom session-target cron jobs, so scheduled runs stop failing silently. (#58247) Thanks @skainguyen1412.</li>
|
||||
<li>Telegram/exec approvals: fall back to the origin session key for async approval followups and keep resume-failure status delivery sanitized so Telegram followups still land without leaking raw exec metadata. (#59351) Thanks @seonang.</li>
|
||||
<li>Node-host/exec approvals: bind <code>pnpm dlx</code> invocations through the approval planner's mutable-script path so the effective runtime command is resolved for approval instead of being left unbound. (#58374)</li>
|
||||
<li>Exec/node hosts: stop forwarding the gateway workspace cwd to remote node exec when no workdir was explicitly requested, so cross-platform node approvals fall back to the node default cwd instead of failing with <code>SYSTEM_RUN_DENIED</code>. (#58977) Thanks @Starhappysh.</li>
|
||||
<li>Exec approvals/channels: decouple initiating-surface approval availability from native delivery enablement so Telegram, Slack, and Discord still expose approvals when approvers exist and native target routing is configured separately. (#59776) Thanks @joelnishanth.</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>macOS/Voice Wake: add the Voice Wake option to trigger Talk Mode. (#58490) Thanks @SmoothExec.</li>
|
||||
<li>Tasks/chat: add <code>/tasks</code> as a chat-native background task board for the current session, with recent task details and agent-local fallback counts when no linked tasks are visible. Related #54226. Thanks @vincentkoc.</li>
|
||||
<li>Web search/SearXNG: add the bundled SearXNG provider plugin for <code>web_search</code> with configurable host support. (#57317) Thanks @cgdusek.</li>
|
||||
<li>Telegram/errors: add configurable <code>errorPolicy</code> and <code>errorCooldownMs</code> controls so Telegram can suppress repeated delivery errors per account, chat, and topic without muting distinct failures. (#51914) Thanks @chinar-amrutkar</li>
|
||||
<li>Gateway/webchat: make <code>chat.history</code> text truncation configurable with <code>gateway.webchat.chatHistoryMaxChars</code> and per-request <code>maxChars</code>, while preserving silent-reply filtering and existing default payload limits. (#58900)</li>
|
||||
<li>Amazon Bedrock/Guardrails: add Bedrock Guardrails support to the bundled provider. (#58588) Thanks @MikeORed.</li>
|
||||
<li>ZAI/models: add <code>glm-5.1</code> and <code>glm-5v-turbo</code> to the bundled Z.AI provider catalog. (#58793) Thanks @tomsun28</li>
|
||||
<li>Agents/default params: add <code>agents.defaults.params</code> for global default provider parameters. (#58548) Thanks @lpender.</li>
|
||||
<li>Agents/failover: cap prompt-side and assistant-side same-provider auth-profile retries for rate-limit failures before cross-provider model fallback, add the <code>auth.cooldowns.rateLimitedProfileRotations</code> knob, and document the new fallback behavior. (#58707) Thanks @Forgely3D</li>
|
||||
<li>Agents/compaction: resolve <code>agents.defaults.compaction.model</code> consistently for manual <code>/compact</code> and other context-engine compaction paths, so engine-owned compaction uses the configured override model across runtime entrypoints. (#56710) Thanks @oliviareid-svg</li>
|
||||
<li>Cron/tools allowlist: add <code>openclaw cron --tools</code> for per-job tool allowlists. (#58504) Thanks @andyk-ms.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Chat/error replies: stop leaking raw provider/runtime failures into external chat channels, return a friendly retry message instead, and add a specific <code>/new</code> hint for Bedrock toolResult/toolUse session mismatches. (#58831) Thanks @ImLukeF.</li>
|
||||
<li>Sessions/model switching: keep <code>/model</code> changes queued behind busy runs instead of interrupting the active turn, and retarget queued followups so later work picks up the new model as soon as the current turn finishes.</li>
|
||||
<li>Web UI/OpenResponses: preserve rewritten stream snapshots in webchat and keep OpenResponses final streamed text aligned when models rewind earlier output. (#58641) Thanks @neeravmakwana</li>
|
||||
<li>Discord/inbound media: pass Discord attachment and sticker downloads through the shared idle-timeout and worker-abort path so slow or stuck inbound media fetches stop hanging message processing. (#58593) Thanks @aquaright1</li>
|
||||
<li>Telegram/retries: keep non-idempotent sends on the strict safe-send path, retry wrapped pre-connect failures, and preserve <code>429</code> / <code>retry_after</code> backoff for safe delivery retries. (#51895) Thanks @chinar-amrutkar</li>
|
||||
<li>Telegram/exec approvals: route topic-aware exec approval followups through Telegram-owned threading and approval-target parsing, so forum-topic approvals stay in the originating topic instead of falling back to the root chat. (#58783)</li>
|
||||
<li>Telegram/local Bot API: preserve media MIME types for absolute-path downloads so local audio files still trigger transcription and other MIME-based handling. (#54603) Thanks @jzakirov</li>
|
||||
<li>Channels/WhatsApp: pass inbound message timestamp to model context so the AI can see when WhatsApp messages were sent. (#58590) Thanks @Maninae</li>
|
||||
<li>QQBot/voice: lazy-load <code>silk-wasm</code> in <code>audio-convert.ts</code> so qqbot still starts when the optional voice dependency is missing, while voice encode/decode degrades gracefully instead of crashing at module load time. (#58829) Thanks @WideLee.</li>
|
||||
<li>Dashboard/chat UI: stop reloading full chat history on every live tool result in dashboard v2 so tool-heavy runs no longer trigger UI freeze/re-render storms while the final event still refreshes persisted history. (#45541) Thanks @BunsDev.</li>
|
||||
<li>Gateway/client requests: reject unanswered gateway RPC calls after a bounded timeout and clear their pending state, so stalled connections no longer leak hanging <code>GatewayClient.request()</code> promises indefinitely.</li>
|
||||
<li>Build/plugin-sdk bundling: bundle plugin-sdk subpath entries in one shared build pass so published packages stop duplicating shared chunks and avoid the recent plugin-sdk memory blow-up. (#45426) Thanks @TarasShyn.</li>
|
||||
<li>Ollama/reasoning visibility: stop promoting native <code>thinking</code> and <code>reasoning</code> fields into final assistant text so local reasoning models no longer leak internal thoughts in normal replies. (#45330) Thanks @xi7ang.</li>
|
||||
<li>Android/onboarding QR scan: switch setup QR scanning to Google Code Scanner so onboarding uses a more reliable scanner instead of the legacy embedded ZXing flow. (#45021) Thanks @obviyus.</li>
|
||||
<li>Browser/existing-session: harden driver validation and session lifecycle so transport errors trigger reconnects while tool-level errors preserve the session, and extract shared ARIA role sets to deduplicate Playwright and Chrome MCP snapshot paths. (#45682) Thanks @odysseus0.</li>
|
||||
<li>Browser/existing-session: accept text-only <code>list_pages</code> and <code>new_page</code> responses from Chrome DevTools MCP so live-session tab discovery and new-tab open flows keep working when the server omits structured page metadata.</li>
|
||||
<li>Control UI/insecure auth: preserve explicit shared token and password auth on plain-HTTP Control UI connects so LAN and reverse-proxy sessions no longer drop shared auth before the first WebSocket handshake. (#45088) Thanks @velvet-shark.</li>
|
||||
<li>Gateway/session reset: preserve <code>lastAccountId</code> and <code>lastThreadId</code> across gateway session resets so replies keep routing back to the same account and thread after <code>/reset</code>. (#44773) Thanks @Lanfei.</li>
|
||||
<li>macOS/onboarding: avoid self-restarting freshly bootstrapped launchd gateways and give new daemon installs longer to become healthy, so <code>openclaw onboard --install-daemon</code> no longer false-fails on slower Macs and fresh VM snapshots.</li>
|
||||
<li>Gateway/status: add <code>openclaw gateway status --require-rpc</code> and clearer Linux non-interactive daemon-install failure reporting so automation can fail hard on probe misses instead of treating a printed RPC error as green.</li>
|
||||
<li>macOS/exec approvals: respect per-agent exec approval settings in the gateway prompter, including allowlist fallback when the native prompt cannot be shown, so gateway-triggered <code>system.run</code> requests follow configured policy instead of always prompting or denying unexpectedly. (#13707) Thanks @sliekens.</li>
|
||||
<li>Telegram/media downloads: thread the same direct or proxy transport policy into SSRF-guarded file fetches so inbound attachments keep working when Telegram falls back between env-proxy and direct networking. (#44639) Thanks @obviyus.</li>
|
||||
<li>Telegram/inbound media IPv4 fallback: retry SSRF-guarded Telegram file downloads once with the same IPv4 fallback policy as Bot API calls so fresh installs on IPv6-broken hosts no longer fail to download inbound images.</li>
|
||||
<li>Windows/gateway install: bound <code>schtasks</code> calls and fall back to the Startup-folder login item when task creation hangs, so native <code>openclaw gateway install</code> fails fast instead of wedging forever on broken Scheduled Task setups.</li>
|
||||
<li>Windows/gateway stop: resolve Startup-folder fallback listeners from the installed <code>gateway.cmd</code> port, so <code>openclaw gateway stop</code> now actually kills fallback-launched gateway processes before restart.</li>
|
||||
<li>Windows/gateway status: reuse the installed service command environment when reading runtime status, so startup-fallback gateways keep reporting the configured port and running state in <code>gateway status --json</code> instead of falling back to <code>gateway port unknown</code>.</li>
|
||||
<li>Windows/gateway auth: stop attaching device identity on local loopback shared-token and password gateway calls, so native Windows agent replies no longer log stale <code>device signature expired</code> fallback noise before succeeding.</li>
|
||||
<li>Discord/gateway startup: treat plain-text and transient <code>/gateway/bot</code> metadata fetch failures as transient startup errors so Discord gateway boot no longer crashes on unhandled rejections. (#44397) Thanks @jalehman.</li>
|
||||
<li>Slack/probe: keep <code>auth.test()</code> bot and team metadata mapping stable while simplifying the probe result path. (#44775) Thanks @Cafexss.</li>
|
||||
<li>Dashboard/chat UI: render oversized plain-text replies as normal paragraphs instead of capped gray code blocks, so long desktop chat responses stay readable without tab-switching refreshes.</li>
|
||||
<li>Dashboard/chat UI: restore the <code>chat-new-messages</code> class on the New messages scroll pill so the button uses its existing compact styling instead of rendering as a full-screen SVG overlay. (#44856) Thanks @Astro-Han.</li>
|
||||
<li>Gateway/Control UI: restore the operator-only device-auth bypass and classify browser connect failures so origin and device-identity problems no longer show up as auth errors in the Control UI and web chat. (#45512) thanks @sallyom.</li>
|
||||
<li>macOS/voice wake: stop crashing wake-word command extraction when speech segment ranges come from a different transcript instance.</li>
|
||||
<li>Discord/allowlists: honor raw <code>guild_id</code> when hydrated guild objects are missing so allowlisted channels and threads like <code>#maintainers</code> no longer get false-dropped before channel allowlist checks.</li>
|
||||
<li>macOS/runtime locator: require Node >=22.16.0 during macOS runtime discovery so the app no longer accepts Node versions that the main runtime guard rejects later. Thanks @sumleo.</li>
|
||||
<li>Agents/custom providers: preserve blank API keys for loopback OpenAI-compatible custom providers by clearing the synthetic Authorization header at runtime, while keeping explicit apiKey and oauth/token config from silently downgrading into fake bearer auth. (#45631) Thanks @xinhuagu.</li>
|
||||
<li>Models/google-vertex Gemini flash-lite normalization: apply existing bare-ID preview normalization to <code>google-vertex</code> model refs and provider configs so <code>google-vertex/gemini-3.1-flash-lite</code> resolves as <code>gemini-3.1-flash-lite-preview</code>. (#42435) thanks @scoootscooob.</li>
|
||||
<li>iMessage/remote attachments: reject unsafe remote attachment paths before spawning SCP, so sender-controlled filenames can no longer inject shell metacharacters into remote media staging. Thanks @lintsinghua.</li>
|
||||
<li>Telegram/webhook auth: validate the Telegram webhook secret before reading or parsing request bodies, so unauthenticated requests are rejected immediately instead of consuming up to 1 MB first. Thanks @space08.</li>
|
||||
<li>Security/device pairing: make bootstrap setup codes single-use so pending device pairing requests cannot be silently replayed and widened to admin before approval. Thanks @tdjackey.</li>
|
||||
<li>Security/external content: strip zero-width and soft-hyphen marker-splitting characters during boundary sanitization so spoofed <code>EXTERNAL_UNTRUSTED_CONTENT</code> markers fall back to the existing hardening path instead of bypassing marker normalization.</li>
|
||||
<li>Security/exec approvals: unwrap more <code>pnpm</code> runtime forms during approval binding, including <code>pnpm --reporter ... exec</code> and direct <code>pnpm node</code> file runs, with matching regression coverage and docs updates.</li>
|
||||
<li>Security/exec approvals: fail closed for Perl <code>-M</code> and <code>-I</code> approval flows so preload and load-path module resolution stays outside approval-backed runtime execution unless the operator uses a broader explicit trust path.</li>
|
||||
<li>Security/exec approvals: recognize PowerShell <code>-File</code> and <code>-f</code> wrapper forms during inline-command extraction so approval and command-analysis paths treat file-based PowerShell launches like the existing <code>-Command</code> variants.</li>
|
||||
<li>Security/exec approvals: unwrap <code>env</code> dispatch wrappers inside shell-segment allowlist resolution on macOS so <code>env FOO=bar /path/to/bin</code> resolves against the effective executable instead of the wrapper token.</li>
|
||||
<li>Security/exec approvals: treat backslash-newline as shell line continuation during macOS shell-chain parsing so line-continued <code>$(</code> substitutions fail closed instead of slipping past command-substitution checks.</li>
|
||||
<li>Security/exec approvals: bind macOS skill auto-allow trust to both executable name and resolved path so same-basename binaries no longer inherit trust from unrelated skill bins.</li>
|
||||
<li>Build/plugin-sdk bundling: bundle plugin-sdk subpath entries in one shared build pass so published packages stop duplicating shared chunks and avoid the recent plugin-sdk memory blow-up. (#45426) Thanks @TarasShyn.</li>
|
||||
<li>Cron/isolated sessions: route nested cron-triggered embedded runner work onto the nested lane so isolated cron jobs no longer deadlock when compaction or other queued inner work runs. Thanks @vincentkoc.</li>
|
||||
<li>Agents/OpenAI-compatible compat overrides: respect explicit user <code>models[].compat</code> opt-ins for non-native <code>openai-completions</code> endpoints so usage-in-streaming capability overrides no longer get forced off when the endpoint actually supports them. (#44432) Thanks @cheapestinference.</li>
|
||||
<li>Agents/Azure OpenAI startup prompts: rephrase the built-in <code>/new</code>, <code>/reset</code>, and post-compaction startup instruction so Azure OpenAI deployments no longer hit HTTP 400 false positives from the content filter. (#43403) Thanks @xingsy97.</li>
|
||||
<li>Agents/memory bootstrap: load only one root memory file, preferring <code>MEMORY.md</code> and using <code>memory.md</code> as a fallback, so case-insensitive Docker mounts no longer inject duplicate memory context. (#26054) Thanks @Lanfei.</li>
|
||||
<li>Agents/compaction: compare post-compaction token sanity checks against full-session pre-compaction totals and skip the check when token estimation fails, so sessions with large bootstrap context keep real token counts instead of falling back to unknown. (#28347) thanks @efe-arv.</li>
|
||||
<li>Agents/compaction: preserve safeguard compaction summary language continuity via default and configurable custom instructions so persona drift is reduced after auto-compaction. (#10456) Thanks @keepitmello.</li>
|
||||
<li>Agents/tool warnings: distinguish gated core tools like <code>apply_patch</code> from plugin-only unknown entries in <code>tools.profile</code> warnings, so unavailable core tools now report current runtime/provider/model/config gating instead of suggesting a missing plugin.</li>
|
||||
<li>Config/validation: accept documented <code>agents.list[].params</code> per-agent overrides in strict config validation so <code>openclaw config validate</code> no longer rejects runtime-supported <code>cacheRetention</code>, <code>temperature</code>, and <code>maxTokens</code> settings. (#41171) Thanks @atian8179.</li>
|
||||
<li>Config/web fetch: restore runtime validation for documented <code>tools.web.fetch.readability</code> and <code>tools.web.fetch.firecrawl</code> settings so valid web fetch configs no longer fail with unrecognized-key errors. (#42583) Thanks @stim64045-spec.</li>
|
||||
<li>Signal/config validation: add <code>channels.signal.groups</code> schema support so per-group <code>requireMention</code>, <code>tools</code>, and <code>toolsBySender</code> overrides no longer get rejected during config validation. (#27199) Thanks @unisone.</li>
|
||||
<li>Config/discovery: accept <code>discovery.wideArea.domain</code> in strict config validation so unicast DNS-SD gateway configs no longer fail with an unrecognized-key error. (#35615) Thanks @ingyukoh.</li>
|
||||
<li>Telegram/media errors: redact Telegram file URLs before building media fetch errors so failed inbound downloads do not leak bot tokens into logs. Thanks @space08.</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.4.2/OpenClaw-2026.4.2.zip" length="25843797" type="application/octet-stream" sparkle:edSignature="bNNXr4BJEU8W7ghXOujLJTYHZL2PL/r/p4llGBw0BFL+46mJ2Bir+IK8XQaCj5zp+O5JSuh5mY+Y/Nrq6TR7Cg=="/>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.3.13/OpenClaw-2026.3.13.zip" length="23640917" type="application/octet-stream" sparkle:edSignature="Me63UHSpFLocTo5Lt7Iqsl0Hq61y3jTcZ9DUkiFl9xQvTE0+ORuqRMFWqPgYwfaKMgcgQmUbrV/uFzEoTIRHBA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.4.1</title>
|
||||
<pubDate>Wed, 01 Apr 2026 17:14:12 +0000</pubDate>
|
||||
<title>2026.3.12</title>
|
||||
<pubDate>Fri, 13 Mar 2026 04:25:50 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026040190</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.4.1</sparkle:shortVersionString>
|
||||
<sparkle:version>2026031290</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.3.12</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.4.1</h2>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.3.12</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Tasks/chat: add <code>/tasks</code> as a chat-native background task board for the current session, with recent task details and agent-local fallback counts when no linked tasks are visible. Related #54226. Thanks @vincentkoc.</li>
|
||||
<li>Web search/SearXNG: add the bundled SearXNG provider plugin for <code>web_search</code> with configurable host support. (#57317) Thanks @cgdusek.</li>
|
||||
<li>Amazon Bedrock/Guardrails: add Bedrock Guardrails support to the bundled provider. (#58588) Thanks @MikeORed.</li>
|
||||
<li>macOS/Voice Wake: add the Voice Wake option to trigger Talk Mode. (#58490) Thanks @SmoothExec.</li>
|
||||
<li>Feishu/comments: add a dedicated Drive comment-event flow with comment-thread context resolution, in-thread replies, and <code>feishu_drive</code> comment actions for document collaboration workflows. (#58497) Thanks @wittam-01.</li>
|
||||
<li>Gateway/webchat: make <code>chat.history</code> text truncation configurable with <code>gateway.webchat.chatHistoryMaxChars</code> and per-request <code>maxChars</code>, while preserving silent-reply filtering and existing default payload limits. (#58900)</li>
|
||||
<li>Agents/default params: add <code>agents.defaults.params</code> for global default provider parameters. (#58548) Thanks @lpender.</li>
|
||||
<li>Agents/failover: cap prompt-side and assistant-side same-provider auth-profile retries for rate-limit failures before cross-provider model fallback, add the <code>auth.cooldowns.rateLimitedProfileRotations</code> knob, and document the new fallback behavior. (#58707) Thanks @Forgely3D</li>
|
||||
<li>Cron/tools allowlist: add <code>openclaw cron --tools</code> for per-job tool allowlists. (#58504) Thanks @andyk-ms.</li>
|
||||
<li>Channels/session routing: move provider-specific session conversation grammar into plugin-owned session-key surfaces, preserving Telegram topic routing and Feishu scoped inheritance across bootstrap, model override, restart, and tool-policy paths.</li>
|
||||
<li>WhatsApp/reactions: add <code>reactionLevel</code> guidance for agent reactions. Thanks @mcaxtr.</li>
|
||||
<li>Telegram/errors: add configurable <code>errorPolicy</code> and <code>errorCooldownMs</code> controls so Telegram can suppress repeated delivery errors per account, chat, and topic without muting distinct failures. (#51914) Thanks @chinar-amrutkar</li>
|
||||
<li>ZAI/models: add <code>glm-5.1</code> and <code>glm-5v-turbo</code> to the bundled Z.AI provider catalog. (#58793) Thanks @tomsun28</li>
|
||||
<li>Agents/compaction: resolve <code>agents.defaults.compaction.model</code> consistently for manual <code>/compact</code> and other context-engine compaction paths, so engine-owned compaction uses the configured override model across runtime entrypoints. (#56710) Thanks @oliviareid-svg</li>
|
||||
<li>Control UI/dashboard-v2: refresh the gateway dashboard with modular overview, chat, config, agent, and session views, plus a command palette, mobile bottom tabs, and richer chat tools like slash commands, search, export, and pinned messages. (#41503) Thanks @BunsDev.</li>
|
||||
<li>OpenAI/GPT-5.4 fast mode: add configurable session-level fast toggles across <code>/fast</code>, TUI, Control UI, and ACP, with per-model config defaults and OpenAI/Codex request shaping.</li>
|
||||
<li>Anthropic/Claude fast mode: map the shared <code>/fast</code> toggle and <code>params.fastMode</code> to direct Anthropic API-key <code>service_tier</code> requests, with live verification for both Anthropic and OpenAI fast-mode tiers.</li>
|
||||
<li>Models/plugins: move Ollama, vLLM, and SGLang onto the provider-plugin architecture, with provider-owned onboarding, discovery, model-picker setup, and post-selection hooks so core provider wiring is more modular.</li>
|
||||
<li>Docs/Kubernetes: Add a starter K8s install path with raw manifests, Kind setup, and deployment docs. Thanks @sallyom @dzianisv @egkristi</li>
|
||||
<li>Agents/subagents: add <code>sessions_yield</code> so orchestrators can end the current turn immediately, skip queued tool work, and carry a hidden follow-up payload into the next session turn. (#36537) thanks @jriff</li>
|
||||
<li>Slack/agent replies: support <code>channelData.slack.blocks</code> in the shared reply delivery path so agents can send Block Kit messages through standard Slack outbound delivery. (#44592) Thanks @vincentkoc.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Chat/error replies: stop leaking raw provider/runtime failures into external chat channels, return a friendly retry message instead, and add a specific <code>/new</code> hint for Bedrock toolResult/toolUse session mismatches. (#58831) Thanks @ImLukeF.</li>
|
||||
<li>Gateway/reload: ignore startup config writes by persisted hash in the config reloader so generated auth tokens and seeded Control UI origins do not trigger a restart loop, while real <code>gateway.auth.*</code> edits still require restart. (#58678) Thanks @yelog</li>
|
||||
<li>Tasks/gateway: keep the task registry maintenance sweep from stalling the gateway event loop under synchronous SQLite pressure, so upgraded gateways stop hanging about a minute after startup. (#58670) Thanks @openperf</li>
|
||||
<li>Tasks/status: hide stale completed background tasks from <code>/status</code> and <code>session_status</code>, prefer live task context, and show recent failures only when no active work remains. (#58661) Thanks @vincentkoc</li>
|
||||
<li>Tasks/gateway: re-check the current task record before maintenance marks runs lost or prunes them, so a task heartbeat or cleanup update that lands during a sweep no longer gets overwritten by stale snapshot state.</li>
|
||||
<li>Exec/approvals: honor <code>exec-approvals.json</code> security defaults when inline or configured tool policy is unset, and keep Slack and Discord native approval handling aligned with inferred approvers and real channel enablement so remote exec stops falling into false approval timeouts and disabled states. Thanks @scoootscooob and @vincentkoc.</li>
|
||||
<li>Exec/approvals: make <code>allow-always</code> persist as durable user-approved trust instead of behaving like <code>allow-once</code>, reuse exact-command trust on shell-wrapper paths that cannot safely persist an executable allowlist entry, keep static allowlist entries from silently bypassing <code>ask:"always"</code>, and require explicit approval when Windows cannot build an allowlist execution plan instead of hard-dead-ending remote exec. Thanks @scoootscooob and @vincentkoc.</li>
|
||||
<li>Exec/cron: resolve isolated cron no-route approval dead-ends from the effective host fallback policy when trusted automation is allowed, and make <code>openclaw doctor</code> warn when <code>tools.exec</code> is broader than <code>~/.openclaw/exec-approvals.json</code> so stricter host-policy conflicts are explicit. Thanks @scoootscooob and @vincentkoc.</li>
|
||||
<li>Sessions/model switching: keep <code>/model</code> changes queued behind busy runs instead of interrupting the active turn, and retarget queued followups so later work picks up the new model as soon as the current turn finishes.</li>
|
||||
<li>Gateway/HTTP: skip failing HTTP request stages so one broken facade no longer forces every HTTP endpoint to return 500. (#58746) Thanks @yelog</li>
|
||||
<li>Gateway/nodes: stop pinning live node commands to the approved node-pair record. Node pairing remains a trust/token flow, while per-node <code>system.run</code> policy stays in that node's exec approvals config. Fixes #58824.</li>
|
||||
<li>WebChat/exec approvals: use native approval UI guidance in agent system prompts instead of telling agents to paste manual <code>/approve</code> commands in webchat sessions. Thanks @vincentkoc.</li>
|
||||
<li>Web UI/OpenResponses: preserve rewritten stream snapshots in webchat and keep OpenResponses final streamed text aligned when models rewind earlier output. (#58641) Thanks @neeravmakwana</li>
|
||||
<li>Discord/inbound media: pass Discord attachment and sticker downloads through the shared idle-timeout and worker-abort path so slow or stuck inbound media fetches stop hanging message processing. (#58593) Thanks @aquaright1</li>
|
||||
<li>Telegram/retries: keep non-idempotent sends on the strict safe-send path, retry wrapped pre-connect failures, and preserve <code>429</code> / <code>retry_after</code> backoff for safe delivery retries. (#51895) Thanks @chinar-amrutkar</li>
|
||||
<li>Telegram/exec approvals: route topic-aware exec approval followups through Telegram-owned threading and approval-target parsing, so forum-topic approvals stay in the originating topic instead of falling back to the root chat. (#58783)</li>
|
||||
<li>Telegram/local Bot API: preserve media MIME types for absolute-path downloads so local audio files still trigger transcription and other MIME-based handling. (#54603) Thanks @jzakirov</li>
|
||||
<li>Channels/WhatsApp: pass inbound message timestamp to model context so the AI can see when WhatsApp messages were sent. (#58590) Thanks @Maninae</li>
|
||||
<li>Channels/QQ Bot: keep <code>/bot-logs</code> export gated behind a truly explicit QQBot allowlist, rejecting wildcard and mixed wildcard entries while preserving the real framework command path. Thanks @vincentkoc.</li>
|
||||
<li>Channels/plugins: keep bundled channel plugins loadable from legacy <code>channels.<id></code> config even under restrictive plugin allowlists, and make <code>openclaw doctor</code> warn only on real plugin blockers instead of misleading setup guidance. (#58873) Thanks @obviyus</li>
|
||||
<li>Plugins/bundled runtimes: restore externalized bundled plugin runtime dependency staging across packed installs, Docker builds, and local runtime staging so bundled plugins keep their declared runtime deps after the 2026.3.31 externalization change. (#58782)</li>
|
||||
<li>LINE/runtime: resolve the packaged runtime contract from the built <code>dist/plugins/runtime</code> layout so LINE channels start correctly again after global npm installs on <code>2026.3.31</code>. (#58799) Thanks @vincentkoc.</li>
|
||||
<li>MiniMax/plugins: auto-enable the bundled MiniMax plugin for API-key auth/config so MiniMax image generation and other plugin-owned capabilities load without manual plugin allowlisting. (#57127) Thanks @tars90percent.</li>
|
||||
<li>Ollama/model picker: show only Ollama models after provider selection in the CLI picker. (#55290) Thanks @Luckymingxuan.</li>
|
||||
<li>CDP/profiles: prefer <code>cdpPort</code> over stale WebSocket URLs so browser automation reconnects cleanly. (#58499) Thanks @Mlightsnow.</li>
|
||||
<li>Media/paths: resolve relative <code>MEDIA</code> paths against the agent workspace so local attachment references keep working. (#58624) Thanks @aquaright1.</li>
|
||||
<li>Memory/session indexing: keep full reindexes from skipping session transcripts when sync is triggered by <code>session-start</code> or <code>watch</code>, so restart-driven reindexes preserve session memory. (#39732) Thanks @upupc</li>
|
||||
<li>Memory/QMD: prefer <code>--mask</code> over <code>--glob</code> when creating QMD collections so default memory collections keep their intended patterns and stop colliding on restart. (#58643) Thanks @GitZhangChi.</li>
|
||||
<li>Subagents/tasks: keep subagent completion and cleanup from crashing when task-registry writes fail, so a corrupt or missing task row no longer takes down the gateway during lifecycle finalization. Thanks @vincentkoc.</li>
|
||||
<li>Sandbox/browser: compare browser runtime inspection against <code>agents.defaults.sandbox.browser.image</code> so <code>openclaw sandbox list --browser</code> stops reporting healthy browser containers as image mismatches. (#58759) Thanks @sandpile.</li>
|
||||
<li>Plugins/install: forward <code>--dangerously-force-unsafe-install</code> through archive and npm-spec plugin installs so the documented override reaches the security scanner on those install paths. (#58879) Thanks @ryanlee-gemini.</li>
|
||||
<li>Auto-reply/commands: strip inbound metadata before slash command detection so wrapped <code>/model</code>, <code>/new</code>, and <code>/status</code> commands are recognized. (#58725) Thanks @Mlightsnow.</li>
|
||||
<li>Agents/Anthropic: preserve thinking blocks and signatures across replay, cache-control patching, and context pruning so compacted Anthropic sessions continue working instead of failing on later turns. (#58916) Thanks @obviyus</li>
|
||||
<li>Agents/failover: unify structured and raw provider error classification so provider-specific <code>400</code>/<code>422</code> payloads no longer get forced into generic format failures before retry, billing, or compaction logic can inspect them. (#58856) Thanks @aaron-he-zhu.</li>
|
||||
<li>Auth profiles/store: coerce misplaced SecretRef objects out of plaintext <code>key</code> and <code>token</code> fields during store load so agents without ACP runtime stop crashing on <code>.trim()</code> after upgrade. (#58923) Thanks @openperf.</li>
|
||||
<li>ACPX/runtime: repair <code>queue owner unavailable</code> session recovery by replacing dead named sessions and resuming the backend session when ACPX exposes a stable session id, so the first ACP prompt no longer inherits a dead handle. (#58669) Thanks @neeravmakwana</li>
|
||||
<li>ACPX/runtime: retry dead-session queue-owner repair without <code>--resume-session</code> when the reported ACPX session id is stale, so recovery still creates a fresh named session instead of failing session init. Thanks @obviyus.</li>
|
||||
<li>Auth/OpenAI Codex: persist plugin-refreshed OAuth credentials to <code>auth-profiles.json</code> before returning them, so rotated Codex refresh tokens survive restart and stop falling into <code>refresh_token_reused</code> loops. (#53082)</li>
|
||||
<li>Discord/gateway: hand reconnect ownership back to Carbon, keep runtime status aligned with close/reconnect state, and force-stop sockets that open without reaching READY so Discord monitors recover promptly instead of waiting on stale health timeouts. (#59019) Thanks @obviyus</li>
|
||||
<li>Security/device pairing: switch <code>/pair</code> and <code>openclaw qr</code> setup codes to short-lived bootstrap tokens so the next release no longer embeds shared gateway credentials in chat or QR pairing payloads. Thanks @lintsinghua.</li>
|
||||
<li>Security/plugins: disable implicit workspace plugin auto-load so cloned repositories cannot execute workspace plugin code without an explicit trust decision. (<code>GHSA-99qw-6mr3-36qr</code>)(#44174) Thanks @lintsinghua and @vincentkoc.</li>
|
||||
<li>Models/Kimi Coding: send <code>anthropic-messages</code> tools in native Anthropic format again so <code>kimi-coding</code> stops degrading tool calls into XML/plain-text pseudo invocations instead of real <code>tool_use</code> blocks. (#38669, #39907, #40552) Thanks @opriz.</li>
|
||||
<li>TUI/chat log: reuse the active assistant message component for the same streaming run so <code>openclaw tui</code> no longer renders duplicate assistant replies. (#35364) Thanks @lisitan.</li>
|
||||
<li>Telegram/model picker: make inline model button selections persist the chosen session model correctly, clear overrides when selecting the configured default, and include effective fallback models in <code>/models</code> button validation. (#40105) Thanks @avirweb.</li>
|
||||
<li>Cron/proactive delivery: keep isolated direct cron sends out of the write-ahead resend queue so transient-send retries do not replay duplicate proactive messages after restart. (#40646) Thanks @openperf and @vincentkoc.</li>
|
||||
<li>Models/Kimi Coding: send the built-in <code>User-Agent: claude-code/0.1.0</code> header by default for <code>kimi-coding</code> while still allowing explicit provider headers to override it, so Kimi Code subscription auth can work without a local header-injection proxy. (#30099) Thanks @Amineelfarssi and @vincentkoc.</li>
|
||||
<li>Models/OpenAI Codex Spark: keep <code>gpt-5.3-codex-spark</code> working on the <code>openai-codex/*</code> path via resolver fallbacks and clearer Codex-only handling, while continuing to suppress the stale direct <code>openai/*</code> Spark row that OpenAI rejects live.</li>
|
||||
<li>Ollama/Kimi Cloud: apply the Moonshot Kimi payload compatibility wrapper to Ollama-hosted Kimi models like <code>kimi-k2.5:cloud</code>, so tool routing no longer breaks when thinking is enabled. (#41519) Thanks @vincentkoc.</li>
|
||||
<li>Moonshot CN API: respect explicit <code>baseUrl</code> (api.moonshot.cn) in implicit provider resolution so platform.moonshot.cn API keys authenticate correctly instead of returning HTTP 401. (#33637) Thanks @chengzhichao-xydt.</li>
|
||||
<li>Kimi Coding/provider config: respect explicit <code>models.providers["kimi-coding"].baseUrl</code> when resolving the implicit provider so custom Kimi Coding endpoints no longer get overwritten by the built-in default. (#36353) Thanks @2233admin.</li>
|
||||
<li>Gateway/main-session routing: keep TUI and other <code>mode:UI</code> main-session sends on the internal surface when <code>deliver</code> is enabled, so replies no longer inherit the session's persisted Telegram/WhatsApp route. (#43918) Thanks @obviyus.</li>
|
||||
<li>BlueBubbles/self-chat echo dedupe: drop reflected duplicate webhook copies only when a matching <code>fromMe</code> event was just seen for the same chat, body, and timestamp, preventing self-chat loops without broad webhook suppression. Related to #32166. (#38442) Thanks @vincentkoc.</li>
|
||||
<li>iMessage/self-chat echo dedupe: drop reflected duplicate copies only when a matching <code>is_from_me</code> event was just seen for the same chat, text, and <code>created_at</code>, preventing self-chat loops without broad text-only suppression. Related to #32166. (#38440) Thanks @vincentkoc.</li>
|
||||
<li>Subagents/completion announce retries: raise the default announce timeout to 90 seconds and stop retrying gateway-timeout failures for externally delivered completion announces, preventing duplicate user-facing completion messages after slow gateway responses. Fixes #41235. Thanks @vasujain00 and @vincentkoc.</li>
|
||||
<li>Mattermost/block streaming: fix duplicate message delivery (one threaded, one top-level) when block streaming is active by excluding <code>replyToId</code> from the block reply dedup key and adding an explicit <code>threading</code> dock to the Mattermost plugin. (#41362) Thanks @mathiasnagler and @vincentkoc.</li>
|
||||
<li>Mattermost/reply media delivery: pass agent-scoped <code>mediaLocalRoots</code> through shared reply delivery so allowed local files upload correctly from button, slash-command, and model-picker replies. (#44021) Thanks @LyleLiu666.</li>
|
||||
<li>macOS/Reminders: add the missing <code>NSRemindersUsageDescription</code> to the bundled app so <code>apple-reminders</code> can trigger the system permission prompt from OpenClaw.app. (#8559) Thanks @dinakars777.</li>
|
||||
<li>Gateway/session discovery: discover disk-only and retired ACP session stores under custom templated <code>session.store</code> roots so ACP reconciliation, session-id/session-label targeting, and run-id fallback keep working after restart. (#44176) thanks @gumadeiras.</li>
|
||||
<li>Plugins/env-scoped roots: fix plugin discovery/load caches and provenance tracking so same-process <code>HOME</code>/<code>OPENCLAW_HOME</code> changes no longer reuse stale plugin state or misreport <code>~/...</code> plugins as untracked. (#44046) thanks @gumadeiras.</li>
|
||||
<li>Models/OpenRouter native ids: canonicalize native OpenRouter model keys across config writes, runtime lookups, fallback management, and <code>models list --plain</code>, and migrate legacy duplicated <code>openrouter/openrouter/...</code> config entries forward on write.</li>
|
||||
<li>Windows/native update: make package installs use the npm update path instead of the git path, carry portable Git into native Windows updates, and mirror the installer's Windows npm env so <code>openclaw update</code> no longer dies early on missing <code>git</code> or <code>node-llama-cpp</code> download setup.</li>
|
||||
<li>Sandbox/write: preserve pinned mutation-helper payload stdin so sandboxed <code>write</code> no longer reports success while creating empty files. (#43876) Thanks @glitch418x.</li>
|
||||
<li>Security/exec approvals: escape invisible Unicode format characters in approval prompts so zero-width command text renders as visible <code>\u{...}</code> escapes instead of spoofing the reviewed command. (<code>GHSA-pcqg-f7rg-xfvv</code>)(#43687) Thanks @EkiXu and @vincentkoc.</li>
|
||||
<li>Hooks/loader: fail closed when workspace hook paths cannot be resolved with <code>realpath</code>, so unreadable or broken internal hook paths are skipped instead of falling back to unresolved imports. (#44437) Thanks @vincentkoc.</li>
|
||||
<li>Hooks/agent deliveries: dedupe repeated hook requests by optional idempotency key so webhook retries can reuse the first run instead of launching duplicate agent executions. (#44438) Thanks @vincentkoc.</li>
|
||||
<li>Security/exec detection: normalize compatibility Unicode and strip invisible formatting code points before obfuscation checks so zero-width and fullwidth command tricks no longer suppress heuristic detection. (<code>GHSA-9r3v-37xh-2cf6</code>)(#44091) Thanks @wooluo and @vincentkoc.</li>
|
||||
<li>Security/exec allowlist: preserve POSIX case sensitivity and keep <code>?</code> within a single path segment so exact-looking allowlist patterns no longer overmatch executables across case or directory boundaries. (<code>GHSA-f8r2-vg7x-gh8m</code>)(#43798) Thanks @zpbrent and @vincentkoc.</li>
|
||||
<li>Security/commands: require sender ownership for <code>/config</code> and <code>/debug</code> so authorized non-owner senders can no longer reach owner-only config and runtime debug surfaces. (<code>GHSA-r7vr-gr74-94p8</code>)(#44305) Thanks @tdjackey and @vincentkoc.</li>
|
||||
<li>Security/gateway auth: clear unbound client-declared scopes on shared-token WebSocket connects so device-less shared-token operators cannot self-declare elevated scopes. (<code>GHSA-rqpp-rjj8-7wv8</code>)(#44306) Thanks @LUOYEcode and @vincentkoc.</li>
|
||||
<li>Security/browser.request: block persistent browser profile create/delete routes from write-scoped <code>browser.request</code> so callers can no longer persist admin-only browser profile changes through the browser control surface. (<code>GHSA-vmhq-cqm9-6p7q</code>)(#43800) Thanks @tdjackey and @vincentkoc.</li>
|
||||
<li>Security/agent: reject public spawned-run lineage fields and keep workspace inheritance on the internal spawned-session path so external <code>agent</code> callers can no longer override the gateway workspace boundary. (<code>GHSA-2rqg-gjgv-84jm</code>)(#43801) Thanks @tdjackey and @vincentkoc.</li>
|
||||
<li>Security/session_status: enforce sandbox session-tree visibility and shared agent-to-agent access guards before reading or mutating target session state, so sandboxed subagents can no longer inspect parent session metadata or write parent model overrides via <code>session_status</code>. (<code>GHSA-wcxr-59v9-rxr8</code>)(#43754) Thanks @tdjackey and @vincentkoc.</li>
|
||||
<li>Security/agent tools: mark <code>nodes</code> as explicitly owner-only and document/test that <code>canvas</code> remains a shared trusted-operator surface unless a real boundary bypass exists.</li>
|
||||
<li>Security/exec approvals: fail closed for Ruby approval flows that use <code>-r</code>, <code>--require</code>, or <code>-I</code> so approval-backed commands no longer bind only the main script while extra local code-loading flags remain outside the reviewed file snapshot.</li>
|
||||
<li>Security/device pairing: cap issued and verified device-token scopes to each paired device's approved scope baseline so stale or overbroad tokens cannot exceed approved access. (<code>GHSA-2pwv-x786-56f8</code>)(#43686) Thanks @tdjackey and @vincentkoc.</li>
|
||||
<li>Docs/onboarding: align the legacy wizard reference and <code>openclaw onboard</code> command docs with the Ollama onboarding flow so all onboarding reference paths now document <code>--auth-choice ollama</code>, Cloud + Local mode, and non-interactive usage. (#43473) Thanks @BruceMacD.</li>
|
||||
<li>Models/secrets: enforce source-managed SecretRef markers in generated <code>models.json</code> so runtime-resolved provider secrets are not persisted when runtime projection is skipped. (#43759) Thanks @joshavant.</li>
|
||||
<li>Security/WebSocket preauth: shorten unauthenticated handshake retention and reject oversized pre-auth frames before application-layer parsing to reduce pre-pairing exposure on unsupported public deployments. (<code>GHSA-jv4g-m82p-2j93</code>)(#44089) (<code>GHSA-xwx2-ppv2-wx98</code>)(#44089) Thanks @ez-lbz and @vincentkoc.</li>
|
||||
<li>Security/proxy attachments: restore the shared media-store size cap for persisted browser proxy files so oversized payloads are rejected instead of overriding the intended 5 MB limit. (<code>GHSA-6rph-mmhp-h7h9</code>)(#43684) Thanks @tdjackey and @vincentkoc.</li>
|
||||
<li>Security/host env: block inherited <code>GIT_EXEC_PATH</code> from sanitized host exec environments so Git helper resolution cannot be steered by host environment state. (<code>GHSA-jf5v-pqgw-gm5m</code>)(#43685) Thanks @zpbrent and @vincentkoc.</li>
|
||||
<li>Security/Feishu webhook: require <code>encryptKey</code> alongside <code>verificationToken</code> in webhook mode so unsigned forged events are rejected instead of being processed with token-only configuration. (<code>GHSA-g353-mgv3-8pcj</code>)(#44087) Thanks @lintsinghua and @vincentkoc.</li>
|
||||
<li>Security/Feishu reactions: preserve looked-up group chat typing and fail closed on ambiguous reaction context so group authorization and mention gating cannot be bypassed through synthetic <code>p2p</code> reactions. (<code>GHSA-m69h-jm2f-2pv8</code>)(#44088) Thanks @zpbrent and @vincentkoc.</li>
|
||||
<li>Security/LINE webhook: require signatures for empty-event POST probes too so unsigned requests no longer confirm webhook reachability with a <code>200</code> response. (<code>GHSA-mhxh-9pjm-w7q5</code>)(#44090) Thanks @TerminalsandCoffee and @vincentkoc.</li>
|
||||
<li>Security/Zalo webhook: rate limit invalid secret guesses before auth so weak webhook secrets cannot be brute-forced through unauthenticated churned requests without pre-auth <code>429</code> responses. (<code>GHSA-5m9r-p9g7-679c</code>)(#44173) Thanks @zpbrent and @vincentkoc.</li>
|
||||
<li>Security/Zalouser groups: require stable group IDs for allowlist auth by default and gate mutable group-name matching behind <code>channels.zalouser.dangerouslyAllowNameMatching</code>. Thanks @zpbrent.</li>
|
||||
<li>Security/Slack and Teams routing: require stable channel and team IDs for allowlist routing by default, with mutable name matching only via each channel's <code>dangerouslyAllowNameMatching</code> break-glass flag.</li>
|
||||
<li>Security/exec approvals: fail closed for ambiguous inline loader and shell-payload script execution, bind the real script after POSIX shell value-taking flags, and unwrap <code>pnpm</code>/<code>npm exec</code>/<code>npx</code> script runners before approval binding. (<code>GHSA-57jw-9722-6rf2</code>)(<code>GHSA-jvqh-rfmh-jh27</code>)(<code>GHSA-x7pp-23xv-mmr4</code>)(<code>GHSA-jc5j-vg4r-j5jx</code>)(#44247) Thanks @tdjackey and @vincentkoc.</li>
|
||||
<li>Doctor/gateway service audit: canonicalize service entrypoint paths before comparing them so symlink-vs-realpath installs no longer trigger false "entrypoint does not match the current install" repair prompts. (#43882) Thanks @ngutman.</li>
|
||||
<li>Doctor/gateway service audit: earlier groundwork for this fix landed in the superseded #28338 branch. Thanks @realriphub.</li>
|
||||
<li>Gateway/session stores: regenerate the Swift push-test protocol models and align Windows native session-store realpath handling so protocol checks and sync session discovery stop drifting on Windows. (#44266) thanks @jalehman.</li>
|
||||
<li>Context engine/session routing: forward optional <code>sessionKey</code> through context-engine lifecycle calls so plugins can see structured routing metadata during bootstrap, assembly, post-turn ingestion, and compaction. (#44157) thanks @jalehman.</li>
|
||||
<li>Agents/failover: classify z.ai <code>network_error</code> stop reasons as retryable timeouts so provider connectivity failures trigger fallback instead of surfacing raw unhandled-stop-reason errors. (#43884) Thanks @hougangdev.</li>
|
||||
<li>Memory/session sync: add mode-aware post-compaction session reindexing with <code>agents.defaults.compaction.postIndexSync</code> plus <code>agents.defaults.memorySearch.sync.sessions.postCompactionForce</code>, so compacted session memory can refresh immediately without forcing every deployment into synchronous reindexing. (#25561) thanks @rodrigouroz.</li>
|
||||
<li>Telegram/model picker: make inline model button selections persist the chosen session model correctly, clear overrides when selecting the configured default, and include effective fallback models in <code>/models</code> button validation. (#40105) Thanks @avirweb.</li>
|
||||
<li>Telegram/native command sync: suppress expected <code>BOT_COMMANDS_TOO_MUCH</code> retry error noise, add a final fallback summary log, and document the difference between command-menu overflow and real Telegram network failures.</li>
|
||||
<li>Mattermost/reply media delivery: pass agent-scoped <code>mediaLocalRoots</code> through shared reply delivery so allowed local files upload correctly from button, slash-command, and model-picker replies. (#44021) Thanks @LyleLiu666.</li>
|
||||
<li>Plugins/env-scoped roots: fix plugin discovery/load caches and provenance tracking so same-process <code>HOME</code>/<code>OPENCLAW_HOME</code> changes no longer reuse stale plugin state or misreport <code>~/...</code> plugins as untracked. (#44046) thanks @gumadeiras.</li>
|
||||
<li>Gateway/session discovery: discover disk-only and retired ACP session stores under custom templated <code>session.store</code> roots so ACP reconciliation, session-id/session-label targeting, and run-id fallback keep working after restart. (#44176) thanks @gumadeiras.</li>
|
||||
<li>Models/OpenRouter native ids: canonicalize native OpenRouter model keys across config writes, runtime lookups, fallback management, and <code>models list --plain</code>, and migrate legacy duplicated <code>openrouter/openrouter/...</code> config entries forward on write.</li>
|
||||
<li>Gateway/hooks: bucket hook auth failures by forwarded client IP behind trusted proxies and warn when <code>hooks.allowedAgentIds</code> leaves hook routing unrestricted.</li>
|
||||
<li>Agents/compaction: skip the post-compaction <code>cache-ttl</code> marker write when a compaction completed in the same attempt, preventing the next turn from immediately triggering a second tiny compaction. (#28548) thanks @MoerAI.</li>
|
||||
<li>Native chat/macOS: add <code>/new</code>, <code>/reset</code>, and <code>/clear</code> reset triggers, keep shared main-session aliases aligned, and ignore stale model-selection completions so native chat state stays in sync across reset and fast model changes. (#10898) Thanks @Nachx639.</li>
|
||||
<li>Agents/compaction safeguard: route missing-model and missing-API-key cancellation warnings through the shared subsystem logger so they land in structured and file logs. (#9974) Thanks @dinakars777.</li>
|
||||
<li>Cron/doctor: stop flagging canonical <code>agentTurn</code> and <code>systemEvent</code> payload kinds as legacy cron storage, while still normalizing whitespace-padded and non-canonical variants. (#44012) Thanks @shuicici.</li>
|
||||
<li>ACP/client final-message delivery: preserve terminal assistant text snapshots before resolving <code>end_turn</code>, so ACP clients no longer drop the last visible reply when the gateway sends the final message body on the terminal chat event. (#17615) Thanks @pjeby.</li>
|
||||
<li>Telegram/Discord status reactions: show a temporary compacting reaction during auto-compaction pauses and restore thinking afterward so the bot no longer appears frozen while context is being compacted. (#35474) thanks @Cypherm.</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.4.1/OpenClaw-2026.4.1.zip" length="25841903" type="application/octet-stream" sparkle:edSignature="0TPiyshScmwDbgs626JU08NOUUFJmIsVFa5g0xmizfl64Fr+IoT4l/dkXarFqbZAJidtj5WN7Bff7fG8ye/7AA=="/>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.3.12/OpenClaw-2026.3.12.zip" length="23628700" type="application/octet-stream" sparkle:edSignature="o6Zdcw36l3I0jUg14H+RBqNwrhuuSsq1WMDi4tBRa1+5TC3VCVdFKZ2hzmH2Xjru9lDEzVMP8v2A6RexSbOCBQ=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.3.31</title>
|
||||
<pubDate>Tue, 31 Mar 2026 21:47:15 +0000</pubDate>
|
||||
<title>2026.3.8-beta.1</title>
|
||||
<pubDate>Mon, 09 Mar 2026 07:19:57 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026033190</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.3.31</sparkle:shortVersionString>
|
||||
<sparkle:version>2026030801</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.3.8-beta.1</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.3.31</h2>
|
||||
<h3>Breaking</h3>
|
||||
<ul>
|
||||
<li>Nodes/exec: remove the duplicated <code>nodes.run</code> shell wrapper from the CLI and agent <code>nodes</code> tool so node shell execution always goes through <code>exec host=node</code>, keeping node-specific capabilities on <code>nodes invoke</code> and the dedicated media/location/notify actions.</li>
|
||||
<li>Plugin SDK: deprecate the legacy provider compat subpaths plus the older bundled provider setup and channel-runtime compatibility shims, emit migration warnings, and keep the current documented <code>openclaw/plugin-sdk/*</code> entrypoints plus local <code>api.ts</code> / <code>runtime-api.ts</code> barrels as the forward path ahead of a future major-release removal.</li>
|
||||
<li>Skills/install and Plugins/install: built-in dangerous-code <code>critical</code> findings and install-time scan failures now fail closed by default, so plugin installs and gateway-backed skill dependency installs that previously succeeded may now require an explicit dangerous override such as <code>--dangerously-force-unsafe-install</code> to proceed.</li>
|
||||
<li>Gateway/auth: <code>trusted-proxy</code> now rejects mixed shared-token configs, and local-direct fallback requires the configured token instead of implicitly authenticating same-host callers. Thanks @zhangning-agent, @jacobtomlinson, and @vincentkoc.</li>
|
||||
<li>Gateway/node commands: node commands now stay disabled until node pairing is approved, so device pairing alone is no longer enough to expose declared node commands. (#57777) Thanks @jacobtomlinson.</li>
|
||||
<li>Gateway/node events: node-originated runs now stay on a reduced trusted surface, so notification-driven or node-triggered flows that previously relied on broader host/session tool access may need adjustment. (#57691) Thanks @jacobtomlinson.</li>
|
||||
</ul>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.3.8-beta.1</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>ACP/plugins: add an explicit default-off ACPX plugin-tools MCP bridge config, document the trust boundary, and harden the built-in bridge packaging/logging path so global installs and stdio MCP sessions work reliably. (#56867) Thanks @joe2643.</li>
|
||||
<li>Agents/LLM: add a configurable idle-stream timeout for embedded runner requests so stalled model streams abort cleanly instead of hanging until the broader run timeout fires. (#55072) Thanks @liuy.</li>
|
||||
<li>Agents/MCP: materialize bundle MCP tools with provider-safe names (<code>serverName__toolName</code>), support optional <code>streamable-http</code> transport selection plus per-server connection timeouts, and preserve real tool results from aborted/error turns unless truncation explicitly drops them. (#49505) Thanks @ziomancer.</li>
|
||||
<li>Android/notifications: add notification-forwarding controls with package filtering, quiet hours, rate limiting, and safer picker behavior for forwarded notification events. (#40175) Thanks @nimbleenigma.</li>
|
||||
<li>Background tasks: turn tasks into a real shared background-run control plane instead of ACP-only bookkeeping by unifying ACP, subagent, cron, and background CLI execution under one SQLite-backed ledger, routing detached lifecycle updates through the executor seam, adding audit/maintenance/status visibility, tightening auto-cleanup and lost-run recovery, improving task awareness in internal status/tool surfaces, and clarifying the split between heartbeat/main-session automation and detached scheduled runs. Thanks @mbelinky and @vincentkoc.</li>
|
||||
<li>Background tasks: add the first linear task flow control surface with <code>openclaw flows list|show|cancel</code>, keep manual multi-task flows separate from one-task auto-sync flows, and surface doctor recovery hints for obviously orphaned or broken flow/task linkage. Thanks @mbelinky and @vincentkoc.</li>
|
||||
<li>Channels/QQ Bot: add QQ Bot as a bundled channel plugin with multi-account setup, SecretRef-aware credentials, slash commands, reminders, and media send/receive support. (#52986) Thanks @sliverp.</li>
|
||||
<li>Diffs: skip unused viewer-versus-file SSR preload work so <code>diffs</code> view-only and file-only runs do less render work while keeping mode outputs aligned. (#57909) thanks @gumadeiras.</li>
|
||||
<li>Tasks: add a minimal SQLite-backed task flow registry plus task-to-flow linkage scaffolding, so orchestrated work can start gaining a first-class parent record without changing current task delivery behavior. Thanks @mbelinky and @vincentkoc.</li>
|
||||
<li>Tasks: persist blocked state on one-task task flows and let the same flow reopen cleanly on retry, so blocked detached work can carry a parent-level reason and continue without fragmenting into a new job. Thanks @mbelinky and @vincentkoc.</li>
|
||||
<li>Tasks: route one-task ACP and subagent updates through a parent task-flow owner context, so detached work can emerge back through the intended parent thread/session instead of speaking only as a raw child task. Thanks @mbelinky and @vincentkoc.</li>
|
||||
<li>LINE/outbound media: add LINE image, video, and audio outbound sends on the LINE-specific delivery path, including explicit preview/tracking handling for videos while keeping generic media sends on the existing image-only route. (#45826) Thanks @masatohoshino.</li>
|
||||
<li>Matrix/history: add optional room history context for Matrix group triggers via <code>channels.matrix.historyLimit</code>, with per-agent watermarks and retry-safe snapshots so failed trigger retries do not drift into newer room messages. (#57022) thanks @chain710.</li>
|
||||
<li>Matrix/network: add explicit <code>channels.matrix.proxy</code> config for routing Matrix traffic through an HTTP(S) proxy, including account-level overrides and matching probe/runtime behavior. (#56931) thanks @patrick-yingxi-pan.</li>
|
||||
<li>Matrix/streaming: add draft streaming so partial Matrix replies update the same message in place instead of sending a new message for each chunk. (#56387) Thanks @jrusz.</li>
|
||||
<li>Matrix/threads: add per-DM <code>threadReplies</code> overrides and keep thread session isolation aligned with the effective room or DM thread policy from the triggering message onward. (#57995) thanks @teconomix.</li>
|
||||
<li>MCP: add remote HTTP/SSE server support for <code>mcp.servers</code> URL configs, including auth headers and safer config redaction for MCP credentials. (#50396) Thanks @dhananjai1729.</li>
|
||||
<li>Memory/QMD: add per-agent <code>memorySearch.qmd.extraCollections</code> so agents can opt into cross-agent session search without flattening every transcript collection into one shared QMD namespace. Thanks @vincentkoc.</li>
|
||||
<li>Microsoft Teams/member info: add a Graph-backed member info action so Teams automations and tools can resolve channel member details directly from Microsoft Graph. (#57528) Thanks @sudie-codes.</li>
|
||||
<li>Nostr/inbound DMs: verify inbound event signatures before pairing or sender-authorization side effects, so forged DM events no longer create pairing requests or trigger reply attempts. Thanks @smaeljaish771 and @vincentkoc.</li>
|
||||
<li>OpenAI/Responses: forward configured <code>text.verbosity</code> across Responses HTTP and WebSocket transports, surface it in <code>/status</code>, and keep per-agent verbosity precedence aligned with runtime behavior. (#47106) Thanks @merc1305 and @vincentkoc.</li>
|
||||
<li>Pi/Codex: add native Codex web search support for embedded Pi runs, including config/docs/wizard coverage and managed-tool suppression when native Codex search is active. (#46579) Thanks @Evizero.</li>
|
||||
<li>Slack/exec approvals: add native Slack approval routing and approver authorization so exec approval prompts can stay in Slack instead of falling back to the Web UI or terminal. Thanks @vincentkoc.</li>
|
||||
<li>TTS: Add structured provider diagnostics and fallback attempt analytics. (#57954) Thanks @joshavant.</li>
|
||||
<li>WhatsApp/reactions: agents can now react with emoji on incoming WhatsApp messages, enabling more natural conversational interactions like acknowledging a photo with ❤️ instead of typing a reply. Thanks @mcaxtr.</li>
|
||||
<li>Agents/BTW: force <code>/btw</code> side questions to disable provider reasoning so Anthropic adaptive-thinking sessions stop failing with <code>No BTW response generated</code>. Fixes #55376. Thanks @Catteres and @vincentkoc.</li>
|
||||
<li>CLI/onboarding: reset the remote gateway URL prompt to the safe loopback default after declining a discovered endpoint, so onboarding does not keep a previously rejected remote URL. (#57828)</li>
|
||||
<li>Agents/exec defaults: honor per-agent <code>tools.exec</code> defaults when no inline directive or session override is present, so configured exec host, security, ask, and node settings actually apply. (#57689)</li>
|
||||
<li>Sandbox/networking: sanitize SSH subprocess env vars through the shared sandbox policy and route marketplace archive downloads plus Ollama discovery, auth, and pull requests through the guarded fetch path so sandboxed execution and remote fetches follow the repo's trust boundaries. (#57848, #57850)</li>
|
||||
<li>CLI/backup: add <code>openclaw backup create</code> and <code>openclaw backup verify</code> for local state archives, including <code>--only-config</code>, <code>--no-include-workspace</code>, manifest/payload validation, and backup guidance in destructive flows. (#40163) thanks @shichangs.</li>
|
||||
<li>macOS/onboarding: add a remote gateway token field for remote mode, preserve existing non-plaintext <code>gateway.remote.token</code> config values until explicitly replaced, and warn when the loaded token shape cannot be used directly from the macOS app. (#40187, supersedes #34614) Thanks @cgdusek.</li>
|
||||
<li>Talk mode: add top-level <code>talk.silenceTimeoutMs</code> config so Talk waits a configurable amount of silence before auto-sending the current transcript, while keeping each platform's existing default pause window when unset. (#39607) Thanks @danodoesdesign. Fixes #17147.</li>
|
||||
<li>TUI: infer the active agent from the current workspace when launched inside a configured agent workspace, while preserving explicit <code>agent:</code> session targets. (#39591) thanks @arceus77-7.</li>
|
||||
<li>Tools/Brave web search: add opt-in <code>tools.web.search.brave.mode: "llm-context"</code> so <code>web_search</code> can call Brave's LLM Context endpoint and return extracted grounding snippets with source metadata, plus config/docs/test coverage. (#33383) Thanks @thirumaleshp.</li>
|
||||
<li>CLI/install: include the short git commit hash in <code>openclaw --version</code> output when metadata is available, and keep installer version checks compatible with the decorated format. (#39712) thanks @sourman.</li>
|
||||
<li>CLI/backup: improve archive naming for date sorting, add config-only backup mode, and harden backup planning, publication, and verification edge cases. (#40163) Thanks @gumadeiras.</li>
|
||||
<li>ACP/Provenance: add optional ACP ingress provenance metadata and visible receipt injection (<code>openclaw acp --provenance off|meta|meta+receipt</code>) so OpenClaw agents can retain and report ACP-origin context with session trace IDs. (#40473) thanks @mbelinky.</li>
|
||||
<li>Tools/web search: alphabetize provider ordering across runtime selection, onboarding/configure pickers, and config metadata, so provider lists stay neutral and multi-key auto-detect now prefers Grok before Kimi. (#40259) thanks @kesku.</li>
|
||||
<li>Docs/Web search: restore $5/month free-credit details, replace defunct "Data for Search"/"Data for AI" plan names with current "Search" plan, and note legacy subscription validity in Brave setup docs. Follows up on #26860. (#40111) Thanks @remusao.</li>
|
||||
<li>Extensions/ACPX tests: move the shared runtime fixture helper from <code>src/runtime-internals/</code> to <code>src/test-utils/</code> so the test-only helper no longer looks like shipped runtime code.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Slack: stop retry-driven duplicate replies when draft-finalization edits fail ambiguously, and log configured allowlisted users/channels by readable name instead of raw IDs.</li>
|
||||
<li>Agents/OpenAI Responses: normalize raw bundled MCP tool schemas on the WebSocket/Responses path so bare-object, object-ish, and top-level union MCP tools no longer get rejected by OpenAI during tool registration. (#58299) Thanks @yelog.</li>
|
||||
<li>ACP/security: replace ACP's dangerous-tool name override with semantic approval classes, so only narrow readonly reads/searches can auto-approve while indirect exec-capable and control-plane tools always require explicit prompt approval. Thanks @vincentkoc.</li>
|
||||
<li>ACP/sessions_spawn: register ACP child runs for completion tracking and lifecycle cleanup, and make registration-failure cleanup explicitly best-effort so callers do not assume an already-started ACP turn was fully aborted. (#40885) Thanks @xaeon2026 and @vincentkoc.</li>
|
||||
<li>ACP/tasks: mark cleanly exited ACP runs as blocked when they end on deterministic write or authorization blockers, and wake the parent session with a follow-up instead of falsely reporting success.</li>
|
||||
<li>ACPX/runtime: derive the bundled ACPX expected version from the extension package metadata instead of hardcoding a separate literal, so plugin-local ACPX installs stop drifting out of health-check parity after version bumps. (#49089) Thanks @jiejiesks and @vincentkoc.</li>
|
||||
<li>Agents/Anthropic failover: treat Anthropic <code>api_error</code> payloads with <code>An unexpected error occurred while processing the response</code> as transient so retry/fallback can engage instead of surfacing a terminal failure. (#57441) Thanks @zijiess and @vincentkoc.</li>
|
||||
<li>Agents/compaction: keep late compaction-retry completions from double-resolving finished compaction futures, so interrupted or timed-out compactions stop surfacing spurious second-completion races. (#57796) Thanks @joshavant.</li>
|
||||
<li>Agents/disabled providers: make disabled providers disappear from default model selection and embedded provider fallback, while letting explicitly pinned disabled providers fail with a clear config error instead of silently taking traffic. (#57735) Thanks @rileybrown-dev and @vincentkoc.</li>
|
||||
<li>Agents/OAuth output: force exec-host OAuth output readers through the gateway fs policy so embedded gateway runs stop crashing when provider auth writes land outside the current sandbox workspace. (#58249) Thanks @joshavant.</li>
|
||||
<li>Agents/system prompt: fix <code>agent.name</code> interpolation in the embedded runtime system prompt and make provider/model fallback text reflect the effective runtime selection after start. (#57625) Thanks @StllrSvr and @vincentkoc.</li>
|
||||
<li>Android/device info: read the app's version metadata from the package manager instead of hidden APIs so Android 15+ onboarding and device info no longer fail to compile or report placeholder values. (#58126) Thanks @L3ER0Y.</li>
|
||||
<li>Android/pairing: stop appending duplicate push receiver entries to <code>gateway-service.conf</code> on repeated QR pairing and keep push registration bounded to the current successful pairing, so Android push delivery stays healthy across re-pair and token rotation. (#58256) Thanks @surrealroad.</li>
|
||||
<li>App install smoke: pin the latest-release lookup to <code>latest</code>, cache the first stable install version across the rerun, and relax prerelease package assertions so the Parallels smoke lane can validate stable-to-main upgrades even when <code>beta</code> moves ahead or the guest starts from an older stable. (#58177) Thanks @vincentkoc.</li>
|
||||
<li>Auth/profiles: keep the last successful config load in memory for the running process and refresh that snapshot on successful writes/reloads, so hot paths stop reparsing <code>openclaw.json</code> between watcher-driven swaps.</li>
|
||||
<li>Config/SecretRef + Control UI: harden SecretRef redaction round-trip restore, block unsafe raw fallback (force Form mode when raw is unavailable), and preflight submitted-config SecretRefs before config write RPC persistence. (#58044) Thanks @joshavant.</li>
|
||||
<li>Config/Telegram: migrate removed <code>channels.telegram.groupMentionsOnly</code> into <code>channels.telegram.groups[\"*\"].requireMention</code> on load so legacy configs no longer crash at startup. (#55336) thanks @jameslcowan.</li>
|
||||
<li>Config/update: stop <code>openclaw doctor</code> write-backs from persisting plugin-injected channel defaults, so <code>openclaw update</code> no longer seeds config keys that later break service refresh validation. (#56834) Thanks @openperf.</li>
|
||||
<li>Control UI/agents: auto-load agent workspace files on initial Files panel open, and populate overview model/workspace/fallbacks from effective runtime agent metadata so defaulted models no longer show as <code>Not set</code>. (#56637) Thanks @dxsx84.</li>
|
||||
<li>Control UI/slash commands: make <code>/steer</code> and <code>/redirect</code> work from the chat command palette with visible pending state for active-run <code>/steer</code>, correct redirected-run tracking, and a single canonical <code>/steer</code> entry in the command menu. (#54625) Thanks @fuller-stack-dev.</li>
|
||||
<li>Cron/announce: preserve all deliverable text payloads for announce mode instead of collapsing to the last chunk, so multi-line cron reports deliver in full to Telegram forum topics.</li>
|
||||
<li>Cron/isolated sessions: carry the full live-session provider, model, and auth-profile selection across retry restarts so cron jobs with model overrides no longer fail or loop on mid-run model-switch requests. (#57972) Thanks @issaba1.</li>
|
||||
<li>Diffs/config: preserve schema-shaped plugin config parsing from <code>diffsPluginConfigSchema.safeParse()</code>, so direct callers keep <code>defaults</code> and <code>security</code> sections instead of receiving flattened tool defaults. (#57904) Thanks @gumadeiras.</li>
|
||||
<li>Diffs: fall back to plain text when <code>lang</code> hints are invalid during diff render and viewer hydration, so bad or stale language values no longer break the diff viewer. (#57902) Thanks @gumadeiras.</li>
|
||||
<li>Discord/voice: enforce the same guild channel and member allowlist checks on spoken voice ingress before transcription, so joined voice channels no longer accept speech from users outside the configured Discord access policy. Thanks @cyjhhh and @vincentkoc.</li>
|
||||
<li>Docker/setup: force BuildKit for local image builds (including sandbox image builds) so <code>./docker-setup.sh</code> no longer fails on <code>RUN --mount=...</code> when hosts default to Docker's legacy builder. (#56681) Thanks @zhanghui-china.</li>
|
||||
<li>Docs/anchors: fix broken English docs links and make Mint anchor audits run against the English-source docs tree. (#57039) thanks @velvet-shark.</li>
|
||||
<li>Doctor/plugins: skip false Matrix legacy-helper warnings when no migration plans exist, and keep bundled <code>enabledByDefault</code> plugins in the gateway startup set. (#57931) Thanks @dinakars777.</li>
|
||||
<li>Exec approvals/macOS: unwrap <code>arch</code> and <code>xcrun</code> before deriving shell payloads and allow-always patterns, so wrapper approvals stay bound to the carried command instead of the outer carrier. Thanks @tdjackey and @vincentkoc.</li>
|
||||
<li>Exec approvals: unwrap <code>caffeinate</code> and <code>sandbox-exec</code> before persisting allow-always trust so later shell payload changes still require a fresh approval. Thanks @tdjackey and @vincentkoc.</li>
|
||||
<li>Exec/approvals: infer Discord and Telegram exec approvers from existing owner config when <code>execApprovals.approvers</code> is unset, extend the default approval window to 30 minutes, and clarify approval-unavailable guidance so approvals do not appear to silently disappear.</li>
|
||||
<li>Pi/TUI: flush message-boundary replies at <code>message_end</code> so turns stop looking stuck until the next nudge when the final reply was already ready. Thanks @vincentkoc.</li>
|
||||
<li>Exec/approvals: keep <code>awk</code> and <code>sed</code> family binaries out of the low-risk <code>safeBins</code> fast path, and stop doctor profile scaffolding from treating them like ordinary custom filters. Thanks @vincentkoc.</li>
|
||||
<li>Exec/env: block proxy, TLS, and Docker endpoint env overrides in host execution so request-scoped commands cannot silently reroute outbound traffic or trust attacker-supplied certificate settings. Thanks @AntAISecurityLab.</li>
|
||||
<li>Exec/env: block Python package index override variables from request-scoped host exec environment sanitization so package fetches cannot be redirected through a caller-supplied index. Thanks @nexrin and @vincentkoc.</li>
|
||||
<li>Exec/node: stop gateway-side workdir fallback from rewriting explicit <code>host=node</code> cwd values to the gateway filesystem, so remote node exec approval and runs keep using the intended node-local directory. (#50961) Thanks @openperf.</li>
|
||||
<li>Exec/runtime: default implicit exec to <code>host=auto</code>, resolve that target to sandbox only when a sandbox runtime exists, keep explicit <code>host=sandbox</code> fail-closed without sandbox, and show <code>/exec</code> effective host state in runtime status/docs.</li>
|
||||
<li>Exec: fail closed when the implicit sandbox host has no sandbox runtime, and stop denied async approval followups from reusing prior command output from the same session. (#56800) Thanks @scoootscooob.</li>
|
||||
<li>Feishu/groups: keep quoted replies and topic bootstrap context aligned with group sender allowlists so only allowlisted thread messages seed agent context. Thanks @AntAISecurityLab and @vincentkoc.</li>
|
||||
<li>Gateway/attachments: offload large inbound images without leaking <code>media://</code> markers into text-only runs, preserve mixed attachment order for model input/transcripts, and fail closed when model image capability cannot be resolved. (#55513) Thanks @Syysean.</li>
|
||||
<li>Gateway/auth: keep shared-auth rate limiting active during WebSocket handshake attempts even when callers also send device-token candidates, so bogus device-token fields no longer suppress shared-secret brute-force tracking. Thanks @kexinoh and @vincentkoc.</li>
|
||||
<li>Gateway/auth: reject mismatched browser <code>Origin</code> headers on trusted-proxy HTTP operator requests while keeping origin-less headless proxy clients working. Thanks @AntAISecurityLab and @vincentkoc.</li>
|
||||
<li>Gateway/device tokens: disconnect active device sessions after token rotation so newly rotated credentials revoke existing live connections immediately instead of waiting for those sockets to close naturally. Thanks @zsxsoft and @vincentkoc.</li>
|
||||
<li>Gateway/health: carry webhook-vs-polling account mode from channel descriptors into runtime snapshots so passive channels like LINE and BlueBubbles skip false stale-socket health failures. (#47488) Thanks @karesansui-u.</li>
|
||||
<li>Gateway/pairing: restore QR bootstrap onboarding handoff so fresh <code>/pair qr</code> iPhone setup can auto-approve the initial node pairing, receive a reusable node device token, and stop retrying with spent bootstrap auth. (#58382) Thanks @ngutman.</li>
|
||||
<li>Gateway/OpenAI compatibility: accept flat Responses API function tool definitions on <code>/v1/responses</code> and preserve <code>strict</code> when normalizing hosted tools into the embedded runner, so spec-compliant clients like Codex no longer fail validation or silently lose strict tool enforcement. Thanks @malaiwah and @vincentkoc.</li>
|
||||
<li>Gateway/OpenAI HTTP: restore default operator scopes for bearer-authenticated requests that omit <code>x-openclaw-scopes</code>, so headless <code>/v1/chat/completions</code> and session-history callers work again after the recent method-scope hardening. (#57596) Thanks @openperf.</li>
|
||||
<li>Gateway/plugins: scope plugin-auth HTTP route runtime clients to read-only access and keep gateway-authenticated plugin routes on write scope, so plugin-owned webhook handlers do not inherit write-capable runtime access by default. Thanks @davidluzsilva and @vincentkoc.</li>
|
||||
<li>Gateway/SecretRef: resolve restart token drift checks with merged service/runtime env sources and hard-fail unsupported mutable SecretRef plus OAuth-profile combinations so restart warnings and policy enforcement match runtime behavior. (#58141) Thanks @joshavant.</li>
|
||||
<li>Gateway/tools HTTP: tighten HTTP tool-invoke authorization so owner-only tools stay off HTTP invoke paths. (#57773) Thanks @jacobtomlinson.</li>
|
||||
<li>Harden async approval followup delivery in webchat-only sessions (#57359) Thanks @joshavant.</li>
|
||||
<li>Heartbeat/auth: prevent exec-event heartbeat runs from inheriting owner-only tool access from the session delivery target, so node exec output stays on the non-owner tool surface even when the target session belongs to the owner. Thanks @AntAISecurityLab and @vincentkoc.</li>
|
||||
<li>Hooks/config: accept runtime channel plugin ids in <code>hooks.mappings[].channel</code> (for example <code>feishu</code>) instead of rejecting non-core channels during config validation. (#56226) Thanks @AiKrai001.</li>
|
||||
<li>Hooks/session routing: rebind hook-triggered <code>agent:</code> session keys to the actual target agent before isolated dispatch so dedicated hook agents keep their own session-scoped tool and plugin identity. Thanks @kexinoh and @vincentkoc.</li>
|
||||
<li>Host exec/env: block additional request-scoped env overrides that can redirect Docker endpoints, trust roots, compiler include paths, package resolution, or Python environment roots during approved host runs. Thanks @tdjackey and @vincentkoc.</li>
|
||||
<li>Image generation/build: write stable runtime alias files into <code>dist/</code> and route provider-auth runtime lookups through those aliases so image-generation providers keep resolving auth/runtime modules after rebuilds instead of crashing on missing hashed chunk files.</li>
|
||||
<li>iOS/Live Activities: mark the <code>ActivityKit</code> import in <code>LiveActivityManager.swift</code> as <code>@preconcurrency</code> so Xcode 26.4 / Swift 6 builds stop failing on strict concurrency checks. (#57180) Thanks @ngutman.</li>
|
||||
<li>LINE/ACP: add current-conversation binding and inbound binding-routing parity so <code>/acp spawn ... --thread here</code>, configured ACP bindings, and active conversation-bound ACP sessions work on LINE like the other conversation channels.</li>
|
||||
<li>LINE/markdown: preserve underscores inside Latin, Cyrillic, and CJK words when stripping markdown, while still removing standalone <code>_italic_</code> markers on the shared text-runtime path used by LINE and TTS. (#47465) Thanks @jackjin1997.</li>
|
||||
<li>Agents/failover: make overloaded same-provider retry count and retry delay configurable via <code>auth.cooldowns</code>, default to one retry with no delay, and document the model-fallback behavior.</li>
|
||||
<li>macOS app/chat UI: route browser proxy through the local node browser service, preserve plain-text paste semantics, strip completed assistant trace/debug wrapper noise from transcripts, refresh permission state after returning from System Settings, and tolerate malformed cron rows in the macOS tab. (#39516) Thanks @Imhermes1.</li>
|
||||
<li>Android/Play distribution: remove self-update, background location, <code>screen.record</code>, and background mic capture from the Android app, narrow the foreground service to <code>dataSync</code> only, and clean up the legacy <code>location.enabledMode=always</code> preference migration. (#39660) Thanks @obviyus.</li>
|
||||
<li>Telegram/DM routing: dedupe inbound Telegram DMs per agent instead of per session key so the same DM cannot trigger duplicate replies when both <code>agent:main:main</code> and <code>agent:main:telegram:direct:<id></code> resolve for one agent. Fixes #40005. Supersedes #40116. (#40519) thanks @obviyus.</li>
|
||||
<li>Cron/Telegram announce delivery: route text-only announce jobs through the real outbound adapters after finalizing descendant output so plain Telegram targets no longer report <code>delivered: true</code> when no message actually reached Telegram. (#40575) thanks @obviyus.</li>
|
||||
<li>Matrix/DM routing: add safer fallback detection for broken <code>m.direct</code> homeservers, honor explicit room bindings over DM classification, and preserve room-bound agent selection for Matrix DM rooms. (#19736) Thanks @derbronko.</li>
|
||||
<li>Feishu/plugin onboarding: clear the short-lived plugin discovery cache before reloading the registry after installing a channel plugin, so onboarding no longer re-prompts to download Feishu immediately after a successful install. Fixes #39642. (#39752) Thanks @GazeKingNuWu.</li>
|
||||
<li>Plugins/channel onboarding: prefer bundled channel plugins over duplicate npm-installed copies during onboarding and release-channel sync, preventing bundled plugins from being shadowed by npm installs with the same plugin ID. (#40092)</li>
|
||||
<li>Config/runtime snapshots: keep secrets-runtime-resolved config and auth-profile snapshots intact after config writes so follow-up reads still see file-backed secret values while picking up the persisted config update. (#37313) thanks @bbblending.</li>
|
||||
<li>Gateway/Control UI: resolve bundled dashboard assets through symlinked global wrappers and auto-detected package roots, while keeping configured and custom roots on the strict hardlink boundary. (#40385) Thanks @LarytheLord.</li>
|
||||
<li>Browser/extension relay: add <code>browser.relayBindHost</code> so the Chrome relay can bind to an explicit non-loopback address for WSL2 and other cross-namespace setups, while preserving loopback-only defaults. (#39364) Thanks @mvanhorn.</li>
|
||||
<li>Browser/CDP: normalize loopback direct WebSocket CDP URLs back to HTTP(S) for <code>/json/*</code> tab operations so local <code>ws://</code> / <code>wss://</code> profiles can still list, focus, open, and close tabs after the new direct-WS support lands. (#31085) Thanks @shrey150.</li>
|
||||
<li>Browser/CDP: rewrite wildcard <code>ws://0.0.0.0</code> and <code>ws://[::]</code> debugger URLs from remote <code>/json/version</code> responses back to the external CDP host/port, fixing Browserless-style container endpoints. (#17760) Thanks @joeharouni.</li>
|
||||
<li>Browser/extension relay: wait briefly for a previously attached Chrome tab to reappear after transient relay drops before failing with <code>tab not found</code>, reducing noisy reconnect flakes. (#32461) Thanks @AaronWander.</li>
|
||||
<li>macOS/Tailscale gateway discovery: keep Tailscale Serve probing alive when other remote gateways are already discovered, prefer direct transport for resolved <code>.ts.net</code> and Tailscale Serve gateways, and set <code>TERM=dumb</code> for GUI-launched Tailscale CLI discovery. (#40167) thanks @ngutman.</li>
|
||||
<li>TUI/theme: detect light terminal backgrounds via <code>COLORFGBG</code> and pick a WCAG AA-compliant light palette, with <code>OPENCLAW_THEME=light|dark</code> override for terminals without auto-detection. (#38636) Thanks @ademczuk and @vincentkoc.</li>
|
||||
<li>Agents/openai-codex: normalize <code>gpt-5.4</code> fallback transport back to <code>openai-codex-responses</code> on <code>chatgpt.com/backend-api</code> when config drifts to the generic OpenAI responses endpoint. (#38736) Thanks @0xsline.</li>
|
||||
<li>Models/openai-codex GPT-5.4 forward-compat: use the GPT-5.4 1,050,000-token context window and 128,000 max tokens for <code>openai-codex/gpt-5.4</code> instead of inheriting stale legacy Codex limits in resolver fallbacks and model listing. (#37876) thanks @yuweuii.</li>
|
||||
<li>Tools/web search: restore Perplexity OpenRouter/Sonar compatibility for legacy <code>OPENROUTER_API_KEY</code>, <code>sk-or-...</code>, and explicit <code>perplexity.baseUrl</code> / <code>model</code> setups while keeping direct Perplexity keys on the native Search API path. (#39937) Thanks @obviyus.</li>
|
||||
<li>Agents/failover: detect Amazon Bedrock <code>Too many tokens per day</code> quota errors as rate limits across fallback, cron retry, and memory embeddings while keeping context-window <code>too many tokens per request</code> errors out of the rate-limit lane. (#39377) Thanks @gambletan.</li>
|
||||
<li>Mattermost replies: keep <code>root_id</code> pinned to the existing thread root when an agent replies inside a thread, while still using reply-target threading for top-level posts. (#27744) thanks @hnykda.</li>
|
||||
<li>Telegram/DM partial streaming: keep DM preview lanes on real message edits instead of native draft materialization so final replies no longer flash a second duplicate copy before collapsing back to one.</li>
|
||||
<li>macOS overlays: fix VoiceWake, Talk, and Notify overlay exclusivity crashes by removing shared <code>inout</code> visibility mutation from <code>OverlayPanelFactory.present</code>, and add a repeated Talk overlay smoke test. (#39275, #39321) Thanks @fellanH.</li>
|
||||
<li>macOS Talk Mode: set the speech recognition request <code>taskHint</code> to <code>.dictation</code> for mic capture, and add regression coverage for the request defaults. (#38445) Thanks @dmiv.</li>
|
||||
<li>macOS release packaging: default <code>scripts/package-mac-app.sh</code> to universal binaries for <code>BUILD_CONFIG=release</code>, and clarify that <code>scripts/package-mac-dist.sh</code> already produces the release zip + DMG. (#33891) Thanks @cgdusek.</li>
|
||||
<li>Hooks/session-memory: keep <code>/new</code> and <code>/reset</code> memory artifacts in the bound agent workspace and align saved reset session keys with that workspace when stale main-agent keys leak into the hook path. (#39875) thanks @rbutera.</li>
|
||||
<li>Sessions/model switch: clear stale cached <code>contextTokens</code> when a session changes models so status and runtime paths recompute against the active model window. (#38044) thanks @yuweuii.</li>
|
||||
<li>ACP/session history: persist transcripts for successful ACP child runs, preserve exact transcript text, record ACP spawned-session lineage, and keep spawn-time transcript-path persistence best-effort so history storage failures do not block execution. (#40137) thanks @mbelinky.</li>
|
||||
<li>Docs/browser: add a layered WSL2 + Windows remote Chrome CDP troubleshooting guide, including Control UI origin pitfalls and extension-relay bind-address guidance. (#39407) Thanks @Owlock.</li>
|
||||
<li>Context engine registry/bundled builds: share the registry state through a <code>globalThis</code> singleton so duplicated bundled module copies can resolve engines registered by each other at runtime, with regression coverage for duplicate-module imports. (#40115) thanks @jalehman.</li>
|
||||
<li>Podman/setup: fix <code>cannot chdir: Permission denied</code> in <code>run_as_user</code> when <code>setup-podman.sh</code> is invoked from a directory the target user cannot access, by wrapping user-switch calls in a subshell that cd's to <code>/tmp</code> with <code>/</code> fallback. (#39435) Thanks @langdon and @jlcbk.</li>
|
||||
<li>Podman/SELinux: auto-detect SELinux enforcing/permissive mode and add <code>:Z</code> relabel to bind mounts in <code>run-openclaw-podman.sh</code> and the Quadlet template, fixing <code>EACCES</code> on Fedora/RHEL hosts. Supports <code>OPENCLAW_BIND_MOUNT_OPTIONS</code> override. (#39449) Thanks @langdon and @githubbzxs.</li>
|
||||
<li>Agents/context-engine plugins: bootstrap runtime plugins once at embedded-run, compaction, and subagent boundaries so plugin-provided context engines and hooks load from the active workspace before runtime resolution. (#40232)</li>
|
||||
<li>Docs/Changelog: correct the contributor credit for the bundled Control UI global-install fix to @LarytheLord. (#40420) Thanks @velvet-shark.</li>
|
||||
<li>Telegram/media downloads: time out only stalled body reads so polling recovers from hung file downloads without aborting slow downloads that are still streaming data. (#40098) thanks @tysoncung.</li>
|
||||
<li>Docker/runtime image: prune dev dependencies, strip build-only dist metadata for smaller Docker images. (#40307) Thanks @vincentkoc.</li>
|
||||
<li>Gateway/restart timeout recovery: exit non-zero when restart-triggered shutdown drains time out so launchd/systemd restart the gateway instead of treating the failed restart as a clean stop. Landed from contributor PR #40380 by @dsantoreis. Thanks @dsantoreis.</li>
|
||||
<li>Gateway/config restart guard: validate config before service start/restart and keep post-SIGUSR1 startup failures from crashing the gateway process, reducing invalid-config restart loops and macOS permission loss. Landed from contributor PR #38699 by @lml2468. Thanks @lml2468.</li>
|
||||
<li>Gateway/launchd respawn detection: treat <code>XPC_SERVICE_NAME</code> as a launchd supervision hint so macOS restarts exit cleanly under launchd instead of attempting detached self-respawn. Landed from contributor PR #20555 by @dimat. Thanks @dimat.</li>
|
||||
<li>Telegram/poll restart cleanup: abort the in-flight Telegram API fetch when shutdown or forced polling restarts stop a runner, preventing stale <code>getUpdates</code> long polls from colliding with the replacement runner. Landed from contributor PR #23950 by @Gkinthecodeland. Thanks @Gkinthecodeland.</li>
|
||||
<li>Cron/restart catch-up staggering: limit immediate missed-job replay on startup and reschedule the deferred remainder from the post-catchup clock so restart bursts do not starve the gateway or silently skip overdue recurring jobs. Landed from contributor PR #18925 by @rexlunae. Thanks @rexlunae.</li>
|
||||
<li>Cron/owner-only tools: pass trusted isolated cron runs into the embedded agent with owner context so <code>cron</code>/<code>gateway</code> tooling remains available after the owner-auth hardening narrowed direct-message ownership inference.</li>
|
||||
<li>Browser/SSRF: block private-network intermediate redirect hops in strict browser navigation flows and fail closed when remote tab-open paths cannot inspect redirect chains. Thanks @zpbrent.</li>
|
||||
<li>MS Teams/authz: keep <code>groupPolicy: "allowlist"</code> enforcing sender allowlists even when a team/channel route allowlist is configured, so route matches no longer widen group access to every sender in that route. Thanks @zpbrent.</li>
|
||||
<li>Security/system.run: bind approved <code>bun</code> and <code>deno run</code> script operands to on-disk file snapshots so post-approval script rewrites are denied before execution.</li>
|
||||
<li>Skills/download installs: pin the validated per-skill tools root before writing downloaded archives, so rebinding the lexical tools path cannot redirect download writes outside the intended tools directory. Thanks @tdjackey.</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.3.31/OpenClaw-2026.3.31.zip" length="25820093" type="application/octet-stream" sparkle:edSignature="NjpuH/j7OaNASEatBTpQ4uQy6+oUNq/lIwjrY69rJfkgGSk3/kU8vgxo9osjSgx034m7TpuZvWyulu57OBsQCg=="/>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.3.8-beta.1/OpenClaw-2026.3.8-beta.1.zip" length="23407015" type="application/octet-stream" sparkle:edSignature="KCqhSmu4b0tHf55RqcQOHorsc55CgBI5BUmK/NTizxNq04INn/7QvsamHYQou9DbB2IW6B2nawBC4nn4au5yDA=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -27,34 +27,14 @@ Status: **extremely alpha**. The app is actively being rebuilt from the ground u
|
||||
|
||||
```bash
|
||||
cd apps/android
|
||||
./gradlew :app:assemblePlayDebug
|
||||
./gradlew :app:installPlayDebug
|
||||
./gradlew :app:testPlayDebugUnitTest
|
||||
./gradlew :app:assembleDebug
|
||||
./gradlew :app:installDebug
|
||||
./gradlew :app:testDebugUnitTest
|
||||
cd ../..
|
||||
bun run android:bundle:release
|
||||
```
|
||||
|
||||
Third-party debug flavor:
|
||||
|
||||
```bash
|
||||
cd apps/android
|
||||
./gradlew :app:assembleThirdPartyDebug
|
||||
./gradlew :app:installThirdPartyDebug
|
||||
./gradlew :app:testThirdPartyDebugUnitTest
|
||||
```
|
||||
|
||||
`bun run android:bundle:release` auto-bumps Android `versionName`/`versionCode` in `apps/android/app/build.gradle.kts`, then builds two signed release bundles:
|
||||
|
||||
- Play build: `apps/android/build/release-bundles/openclaw-<version>-play-release.aab`
|
||||
- Third-party build: `apps/android/build/release-bundles/openclaw-<version>-third-party-release.aab`
|
||||
|
||||
Flavor-specific direct Gradle tasks:
|
||||
|
||||
```bash
|
||||
cd apps/android
|
||||
./gradlew :app:bundlePlayRelease
|
||||
./gradlew :app:bundleThirdPartyRelease
|
||||
```
|
||||
`bun run android:bundle:release` auto-bumps Android `versionName`/`versionCode` in `apps/android/app/build.gradle.kts`, then builds a signed release `.aab`.
|
||||
|
||||
## Kotlin Lint + Format
|
||||
|
||||
@@ -196,48 +176,6 @@ More details: `docs/platforms/android.md`.
|
||||
- `CAMERA` for `camera.snap` and `camera.clip`
|
||||
- `RECORD_AUDIO` for `camera.clip` when `includeAudio=true`
|
||||
|
||||
## Google Play Restricted Permissions
|
||||
|
||||
As of March 19, 2026, these manifest permissions are the main Google Play policy risk for this app:
|
||||
|
||||
- `READ_SMS`
|
||||
- `SEND_SMS`
|
||||
- `READ_CALL_LOG`
|
||||
|
||||
Why these matter:
|
||||
|
||||
- Google Play treats SMS and Call Log access as highly restricted. In most cases, Play only allows them for the default SMS app, default Phone app, default Assistant, or a narrow policy exception.
|
||||
- Review usually involves a `Permissions Declaration Form`, policy justification, and demo video evidence in Play Console.
|
||||
- If we want a Play-safe build, these should be the first permissions removed behind a dedicated product flavor / variant.
|
||||
|
||||
Current OpenClaw Android implication:
|
||||
|
||||
- APK / sideload build can keep SMS and Call Log features.
|
||||
- Google Play build should exclude SMS send/search and Call Log search unless the product is intentionally positioned and approved as a default-handler exception case.
|
||||
- The repo now ships this split as Android product flavors:
|
||||
- `play`: removes `READ_SMS`, `SEND_SMS`, and `READ_CALL_LOG`, and hides SMS / Call Log surfaces in onboarding, settings, and advertised node capabilities.
|
||||
- `thirdParty`: keeps the full permission set and the existing SMS / Call Log functionality.
|
||||
|
||||
Policy links:
|
||||
|
||||
- [Google Play SMS and Call Log policy](https://support.google.com/googleplay/android-developer/answer/10208820?hl=en)
|
||||
- [Google Play sensitive permissions policy hub](https://support.google.com/googleplay/android-developer/answer/16558241)
|
||||
- [Android default handlers guide](https://developer.android.com/guide/topics/permissions/default-handlers)
|
||||
|
||||
Other Play-restricted surfaces to watch if added later:
|
||||
|
||||
- `ACCESS_BACKGROUND_LOCATION`
|
||||
- `MANAGE_EXTERNAL_STORAGE`
|
||||
- `QUERY_ALL_PACKAGES`
|
||||
- `REQUEST_INSTALL_PACKAGES`
|
||||
- `AccessibilityService`
|
||||
|
||||
Reference links:
|
||||
|
||||
- [Background location policy](https://support.google.com/googleplay/android-developer/answer/9799150)
|
||||
- [AccessibilityService policy](https://support.google.com/googleplay/android-developer/answer/10964491?hl=en-GB)
|
||||
- [Photo and Video Permissions policy](https://support.google.com/googleplay/android-developer/answer/14594990)
|
||||
|
||||
## Integration Capability Test (Preconditioned)
|
||||
|
||||
This suite assumes setup is already done manually. It does **not** install/run/pair automatically.
|
||||
|
||||
@@ -65,29 +65,14 @@ android {
|
||||
applicationId = "ai.openclaw.app"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 2026040501
|
||||
versionName = "2026.4.5"
|
||||
versionCode = 2026031400
|
||||
versionName = "2026.3.14"
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
}
|
||||
}
|
||||
|
||||
flavorDimensions += "store"
|
||||
|
||||
productFlavors {
|
||||
create("play") {
|
||||
dimension = "store"
|
||||
buildConfigField("boolean", "OPENCLAW_ENABLE_SMS", "false")
|
||||
buildConfigField("boolean", "OPENCLAW_ENABLE_CALL_LOG", "false")
|
||||
}
|
||||
create("thirdParty") {
|
||||
dimension = "store"
|
||||
buildConfigField("boolean", "OPENCLAW_ENABLE_SMS", "true")
|
||||
buildConfigField("boolean", "OPENCLAW_ENABLE_CALL_LOG", "true")
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
if (hasAndroidReleaseSigning) {
|
||||
@@ -155,13 +140,8 @@ androidComponents {
|
||||
.forEach { output ->
|
||||
val versionName = output.versionName.orNull ?: "0"
|
||||
val buildType = variant.buildType
|
||||
val flavorName = variant.flavorName?.takeIf { it.isNotBlank() }
|
||||
val outputFileName =
|
||||
if (flavorName == null) {
|
||||
"openclaw-$versionName-$buildType.apk"
|
||||
} else {
|
||||
"openclaw-$versionName-$flavorName-$buildType.apk"
|
||||
}
|
||||
|
||||
val outputFileName = "openclaw-$versionName-$buildType.apk"
|
||||
output.outputFileName = outputFileName
|
||||
}
|
||||
}
|
||||
@@ -239,52 +219,44 @@ tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
onVariants(selector().withBuildType("release")) { variant ->
|
||||
val variantName = variant.name
|
||||
val variantNameCapitalized = variantName.replaceFirstChar(Char::titlecase)
|
||||
val stripTaskName = "strip${variantNameCapitalized}DnsjavaServiceDescriptor"
|
||||
val mergeTaskName = "merge${variantNameCapitalized}JavaResource"
|
||||
val minifyTaskName = "minify${variantNameCapitalized}WithR8"
|
||||
val stripReleaseDnsjavaServiceDescriptor =
|
||||
tasks.register("stripReleaseDnsjavaServiceDescriptor") {
|
||||
val mergedJar =
|
||||
layout.buildDirectory.file(
|
||||
"intermediates/merged_java_res/$variantName/$mergeTaskName/base.jar",
|
||||
"intermediates/merged_java_res/release/mergeReleaseJavaResource/base.jar",
|
||||
)
|
||||
|
||||
val stripTask =
|
||||
tasks.register(stripTaskName) {
|
||||
inputs.file(mergedJar)
|
||||
outputs.file(mergedJar)
|
||||
inputs.file(mergedJar)
|
||||
outputs.file(mergedJar)
|
||||
|
||||
doLast {
|
||||
val jarFile = mergedJar.get().asFile
|
||||
if (!jarFile.exists()) {
|
||||
return@doLast
|
||||
}
|
||||
|
||||
val unpackDir = temporaryDir.resolve("merged-java-res")
|
||||
delete(unpackDir)
|
||||
copy {
|
||||
from(zipTree(jarFile))
|
||||
into(unpackDir)
|
||||
exclude(dnsjavaInetAddressResolverService)
|
||||
}
|
||||
delete(jarFile)
|
||||
ant.invokeMethod(
|
||||
"zip",
|
||||
mapOf(
|
||||
"destfile" to jarFile.absolutePath,
|
||||
"basedir" to unpackDir.absolutePath,
|
||||
),
|
||||
)
|
||||
}
|
||||
doLast {
|
||||
val jarFile = mergedJar.get().asFile
|
||||
if (!jarFile.exists()) {
|
||||
return@doLast
|
||||
}
|
||||
|
||||
tasks.matching { it.name == mergeTaskName }.configureEach {
|
||||
finalizedBy(stripTask)
|
||||
}
|
||||
tasks.matching { it.name == minifyTaskName }.configureEach {
|
||||
dependsOn(stripTask)
|
||||
val unpackDir = temporaryDir.resolve("merged-java-res")
|
||||
delete(unpackDir)
|
||||
copy {
|
||||
from(zipTree(jarFile))
|
||||
into(unpackDir)
|
||||
exclude(dnsjavaInetAddressResolverService)
|
||||
}
|
||||
delete(jarFile)
|
||||
ant.invokeMethod(
|
||||
"zip",
|
||||
mapOf(
|
||||
"destfile" to jarFile.absolutePath,
|
||||
"basedir" to unpackDir.absolutePath,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.matching { it.name == "stripReleaseDnsjavaServiceDescriptor" }.configureEach {
|
||||
dependsOn("mergeReleaseJavaResource")
|
||||
}
|
||||
|
||||
tasks.matching { it.name == "minifyReleaseWithR8" }.configureEach {
|
||||
dependsOn(stripReleaseDnsjavaServiceDescriptor)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.SEND_SMS" />
|
||||
<uses-permission android:name="android.permission.READ_SMS" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
|
||||
<uses-permission
|
||||
@@ -20,7 +19,6 @@
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
|
||||
<uses-permission android:name="android.permission.READ_CALL_LOG" />
|
||||
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
||||
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
||||
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
|
||||
@@ -31,13 +29,6 @@
|
||||
android:name="android.hardware.telephony"
|
||||
android:required="false" />
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:name=".NodeApp"
|
||||
android:allowBackup="true"
|
||||
@@ -76,17 +67,10 @@
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|uiMode|density|keyboard|keyboardHidden|navigation">
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.ASSIST" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
import android.content.Intent
|
||||
|
||||
const val actionAskOpenClaw = "ai.openclaw.app.action.ASK_OPENCLAW"
|
||||
const val extraAssistantPrompt = "prompt"
|
||||
|
||||
enum class HomeDestination {
|
||||
Connect,
|
||||
Chat,
|
||||
Voice,
|
||||
Screen,
|
||||
Settings,
|
||||
}
|
||||
|
||||
data class AssistantLaunchRequest(
|
||||
val source: String,
|
||||
val prompt: String?,
|
||||
val autoSend: Boolean,
|
||||
)
|
||||
|
||||
fun parseAssistantLaunchIntent(intent: Intent?): AssistantLaunchRequest? {
|
||||
val action = intent?.action ?: return null
|
||||
return when (action) {
|
||||
Intent.ACTION_ASSIST ->
|
||||
AssistantLaunchRequest(
|
||||
source = "assist",
|
||||
prompt = null,
|
||||
autoSend = false,
|
||||
)
|
||||
|
||||
actionAskOpenClaw -> {
|
||||
val prompt = intent.getStringExtra(extraAssistantPrompt)?.trim()?.ifEmpty { null }
|
||||
AssistantLaunchRequest(
|
||||
source = "app_action",
|
||||
prompt = prompt,
|
||||
autoSend = prompt != null,
|
||||
)
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -18,14 +18,14 @@ import kotlinx.coroutines.launch
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val viewModel: MainViewModel by viewModels()
|
||||
private lateinit var permissionRequester: PermissionRequester
|
||||
private var didAttachRuntimeUi = false
|
||||
private var didStartNodeService = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
handleAssistantIntent(intent)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
permissionRequester = PermissionRequester(this)
|
||||
viewModel.camera.attachLifecycleOwner(this)
|
||||
viewModel.camera.attachPermissionRequester(permissionRequester)
|
||||
viewModel.sms.attachPermissionRequester(permissionRequester)
|
||||
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
@@ -39,20 +39,6 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
viewModel.runtimeInitialized.collect { ready ->
|
||||
if (!ready || didAttachRuntimeUi) return@collect
|
||||
viewModel.attachRuntimeUi(owner = this@MainActivity, permissionRequester = permissionRequester)
|
||||
didAttachRuntimeUi = true
|
||||
if (!didStartNodeService) {
|
||||
NodeForegroundService.start(this@MainActivity)
|
||||
didStartNodeService = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setContent {
|
||||
OpenClawTheme {
|
||||
Surface(modifier = Modifier) {
|
||||
@@ -60,6 +46,9 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keep startup path lean: start foreground service after first frame.
|
||||
window.decorView.post { NodeForegroundService.start(this) }
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
@@ -71,15 +60,4 @@ class MainActivity : ComponentActivity() {
|
||||
viewModel.setForeground(false)
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: android.content.Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
handleAssistantIntent(intent)
|
||||
}
|
||||
|
||||
private fun handleAssistantIntent(intent: android.content.Intent?) {
|
||||
val request = parseAssistantLaunchIntent(intent) ?: return
|
||||
viewModel.handleAssistantLaunch(request)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,380 +2,209 @@ package ai.openclaw.app
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import ai.openclaw.app.chat.ChatMessage
|
||||
import ai.openclaw.app.chat.ChatPendingToolCall
|
||||
import ai.openclaw.app.chat.ChatSessionEntry
|
||||
import ai.openclaw.app.chat.OutgoingAttachment
|
||||
import ai.openclaw.app.gateway.GatewayEndpoint
|
||||
import ai.openclaw.app.chat.OutgoingAttachment
|
||||
import ai.openclaw.app.node.CameraCaptureManager
|
||||
import ai.openclaw.app.node.CanvasController
|
||||
import ai.openclaw.app.node.SmsManager
|
||||
import ai.openclaw.app.voice.VoiceConversationEntry
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
private val nodeApp = app as NodeApp
|
||||
private val prefs = nodeApp.prefs
|
||||
private val runtimeRef = MutableStateFlow<NodeRuntime?>(null)
|
||||
private var foreground = true
|
||||
private val _requestedHomeDestination = MutableStateFlow<HomeDestination?>(null)
|
||||
val requestedHomeDestination: StateFlow<HomeDestination?> = _requestedHomeDestination
|
||||
private val _chatDraft = MutableStateFlow<String?>(null)
|
||||
val chatDraft: StateFlow<String?> = _chatDraft
|
||||
private val _pendingAssistantAutoSend = MutableStateFlow<String?>(null)
|
||||
val pendingAssistantAutoSend: StateFlow<String?> = _pendingAssistantAutoSend
|
||||
private val runtime: NodeRuntime = (app as NodeApp).runtime
|
||||
|
||||
private fun ensureRuntime(): NodeRuntime {
|
||||
runtimeRef.value?.let { return it }
|
||||
val runtime = nodeApp.ensureRuntime()
|
||||
runtime.setForeground(foreground)
|
||||
runtimeRef.value = runtime
|
||||
return runtime
|
||||
}
|
||||
val canvas: CanvasController = runtime.canvas
|
||||
val canvasCurrentUrl: StateFlow<String?> = runtime.canvas.currentUrl
|
||||
val canvasA2uiHydrated: StateFlow<Boolean> = runtime.canvasA2uiHydrated
|
||||
val canvasRehydratePending: StateFlow<Boolean> = runtime.canvasRehydratePending
|
||||
val canvasRehydrateErrorText: StateFlow<String?> = runtime.canvasRehydrateErrorText
|
||||
val camera: CameraCaptureManager = runtime.camera
|
||||
val sms: SmsManager = runtime.sms
|
||||
|
||||
private fun <T> runtimeState(
|
||||
initial: T,
|
||||
selector: (NodeRuntime) -> StateFlow<T>,
|
||||
): StateFlow<T> =
|
||||
runtimeRef
|
||||
.flatMapLatest { runtime -> runtime?.let(selector) ?: flowOf(initial) }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, initial)
|
||||
val gateways: StateFlow<List<GatewayEndpoint>> = runtime.gateways
|
||||
val discoveryStatusText: StateFlow<String> = runtime.discoveryStatusText
|
||||
|
||||
val runtimeInitialized: StateFlow<Boolean> =
|
||||
runtimeRef
|
||||
.flatMapLatest { runtime -> flowOf(runtime != null) }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
||||
val isConnected: StateFlow<Boolean> = runtime.isConnected
|
||||
val isNodeConnected: StateFlow<Boolean> = runtime.nodeConnected
|
||||
val statusText: StateFlow<String> = runtime.statusText
|
||||
val serverName: StateFlow<String?> = runtime.serverName
|
||||
val remoteAddress: StateFlow<String?> = runtime.remoteAddress
|
||||
val pendingGatewayTrust: StateFlow<NodeRuntime.GatewayTrustPrompt?> = runtime.pendingGatewayTrust
|
||||
val isForeground: StateFlow<Boolean> = runtime.isForeground
|
||||
val seamColorArgb: StateFlow<Long> = runtime.seamColorArgb
|
||||
val mainSessionKey: StateFlow<String> = runtime.mainSessionKey
|
||||
|
||||
val canvasCurrentUrl: StateFlow<String?> = runtimeState(initial = null) { it.canvas.currentUrl }
|
||||
val canvasA2uiHydrated: StateFlow<Boolean> = runtimeState(initial = false) { it.canvasA2uiHydrated }
|
||||
val canvasRehydratePending: StateFlow<Boolean> = runtimeState(initial = false) { it.canvasRehydratePending }
|
||||
val canvasRehydrateErrorText: StateFlow<String?> = runtimeState(initial = null) { it.canvasRehydrateErrorText }
|
||||
val cameraHud: StateFlow<CameraHudState?> = runtime.cameraHud
|
||||
val cameraFlashToken: StateFlow<Long> = runtime.cameraFlashToken
|
||||
|
||||
val gateways: StateFlow<List<GatewayEndpoint>> = runtimeState(initial = emptyList()) { it.gateways }
|
||||
val discoveryStatusText: StateFlow<String> = runtimeState(initial = "Searching…") { it.discoveryStatusText }
|
||||
val notificationForwardingEnabled: StateFlow<Boolean> = prefs.notificationForwardingEnabled
|
||||
val notificationForwardingMode: StateFlow<NotificationPackageFilterMode> =
|
||||
prefs.notificationForwardingMode
|
||||
val notificationForwardingPackages: StateFlow<Set<String>> = prefs.notificationForwardingPackages
|
||||
val notificationForwardingQuietHoursEnabled: StateFlow<Boolean> =
|
||||
prefs.notificationForwardingQuietHoursEnabled
|
||||
val notificationForwardingQuietStart: StateFlow<String> = prefs.notificationForwardingQuietStart
|
||||
val notificationForwardingQuietEnd: StateFlow<String> = prefs.notificationForwardingQuietEnd
|
||||
val notificationForwardingMaxEventsPerMinute: StateFlow<Int> =
|
||||
prefs.notificationForwardingMaxEventsPerMinute
|
||||
val notificationForwardingSessionKey: StateFlow<String?> = prefs.notificationForwardingSessionKey
|
||||
val instanceId: StateFlow<String> = runtime.instanceId
|
||||
val displayName: StateFlow<String> = runtime.displayName
|
||||
val cameraEnabled: StateFlow<Boolean> = runtime.cameraEnabled
|
||||
val locationMode: StateFlow<LocationMode> = runtime.locationMode
|
||||
val locationPreciseEnabled: StateFlow<Boolean> = runtime.locationPreciseEnabled
|
||||
val preventSleep: StateFlow<Boolean> = runtime.preventSleep
|
||||
val micEnabled: StateFlow<Boolean> = runtime.micEnabled
|
||||
val micCooldown: StateFlow<Boolean> = runtime.micCooldown
|
||||
val micStatusText: StateFlow<String> = runtime.micStatusText
|
||||
val micLiveTranscript: StateFlow<String?> = runtime.micLiveTranscript
|
||||
val micIsListening: StateFlow<Boolean> = runtime.micIsListening
|
||||
val micQueuedMessages: StateFlow<List<String>> = runtime.micQueuedMessages
|
||||
val micConversation: StateFlow<List<VoiceConversationEntry>> = runtime.micConversation
|
||||
val micInputLevel: StateFlow<Float> = runtime.micInputLevel
|
||||
val micIsSending: StateFlow<Boolean> = runtime.micIsSending
|
||||
val speakerEnabled: StateFlow<Boolean> = runtime.speakerEnabled
|
||||
val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled
|
||||
val manualHost: StateFlow<String> = runtime.manualHost
|
||||
val manualPort: StateFlow<Int> = runtime.manualPort
|
||||
val manualTls: StateFlow<Boolean> = runtime.manualTls
|
||||
val gatewayToken: StateFlow<String> = runtime.gatewayToken
|
||||
val onboardingCompleted: StateFlow<Boolean> = runtime.onboardingCompleted
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = runtime.canvasDebugStatusEnabled
|
||||
|
||||
val isConnected: StateFlow<Boolean> = runtimeState(initial = false) { it.isConnected }
|
||||
val isNodeConnected: StateFlow<Boolean> = runtimeState(initial = false) { it.nodeConnected }
|
||||
val statusText: StateFlow<String> = runtimeState(initial = "Offline") { it.statusText }
|
||||
val serverName: StateFlow<String?> = runtimeState(initial = null) { it.serverName }
|
||||
val remoteAddress: StateFlow<String?> = runtimeState(initial = null) { it.remoteAddress }
|
||||
val pendingGatewayTrust: StateFlow<NodeRuntime.GatewayTrustPrompt?> = runtimeState(initial = null) { it.pendingGatewayTrust }
|
||||
val seamColorArgb: StateFlow<Long> = runtimeState(initial = 0xFF0EA5E9) { it.seamColorArgb }
|
||||
val mainSessionKey: StateFlow<String> = runtimeState(initial = "main") { it.mainSessionKey }
|
||||
|
||||
val cameraHud: StateFlow<CameraHudState?> = runtimeState(initial = null) { it.cameraHud }
|
||||
val cameraFlashToken: StateFlow<Long> = runtimeState(initial = 0L) { it.cameraFlashToken }
|
||||
|
||||
val instanceId: StateFlow<String> = prefs.instanceId
|
||||
val displayName: StateFlow<String> = prefs.displayName
|
||||
val cameraEnabled: StateFlow<Boolean> = prefs.cameraEnabled
|
||||
val locationMode: StateFlow<LocationMode> = prefs.locationMode
|
||||
val locationPreciseEnabled: StateFlow<Boolean> = prefs.locationPreciseEnabled
|
||||
val preventSleep: StateFlow<Boolean> = prefs.preventSleep
|
||||
val manualEnabled: StateFlow<Boolean> = prefs.manualEnabled
|
||||
val manualHost: StateFlow<String> = prefs.manualHost
|
||||
val manualPort: StateFlow<Int> = prefs.manualPort
|
||||
val manualTls: StateFlow<Boolean> = prefs.manualTls
|
||||
val gatewayToken: StateFlow<String> = prefs.gatewayToken
|
||||
val gatewayBootstrapToken: StateFlow<String> = prefs.gatewayBootstrapToken
|
||||
val onboardingCompleted: StateFlow<Boolean> = prefs.onboardingCompleted
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
|
||||
val speakerEnabled: StateFlow<Boolean> = prefs.speakerEnabled
|
||||
val micEnabled: StateFlow<Boolean> = prefs.talkEnabled
|
||||
|
||||
val micCooldown: StateFlow<Boolean> = runtimeState(initial = false) { it.micCooldown }
|
||||
val micStatusText: StateFlow<String> = runtimeState(initial = "Mic off") { it.micStatusText }
|
||||
val micLiveTranscript: StateFlow<String?> = runtimeState(initial = null) { it.micLiveTranscript }
|
||||
val micIsListening: StateFlow<Boolean> = runtimeState(initial = false) { it.micIsListening }
|
||||
val micQueuedMessages: StateFlow<List<String>> = runtimeState(initial = emptyList()) { it.micQueuedMessages }
|
||||
val micConversation: StateFlow<List<VoiceConversationEntry>> = runtimeState(initial = emptyList()) { it.micConversation }
|
||||
val micInputLevel: StateFlow<Float> = runtimeState(initial = 0f) { it.micInputLevel }
|
||||
val micIsSending: StateFlow<Boolean> = runtimeState(initial = false) { it.micIsSending }
|
||||
|
||||
val chatSessionKey: StateFlow<String> = runtimeState(initial = "main") { it.chatSessionKey }
|
||||
val chatSessionId: StateFlow<String?> = runtimeState(initial = null) { it.chatSessionId }
|
||||
val chatMessages: StateFlow<List<ChatMessage>> = runtimeState(initial = emptyList()) { it.chatMessages }
|
||||
val chatError: StateFlow<String?> = runtimeState(initial = null) { it.chatError }
|
||||
val chatHealthOk: StateFlow<Boolean> = runtimeState(initial = false) { it.chatHealthOk }
|
||||
val chatThinkingLevel: StateFlow<String> = runtimeState(initial = "off") { it.chatThinkingLevel }
|
||||
val chatStreamingAssistantText: StateFlow<String?> = runtimeState(initial = null) { it.chatStreamingAssistantText }
|
||||
val chatPendingToolCalls: StateFlow<List<ChatPendingToolCall>> = runtimeState(initial = emptyList()) { it.chatPendingToolCalls }
|
||||
val chatSessions: StateFlow<List<ChatSessionEntry>> = runtimeState(initial = emptyList()) { it.chatSessions }
|
||||
val pendingRunCount: StateFlow<Int> = runtimeState(initial = 0) { it.pendingRunCount }
|
||||
|
||||
init {
|
||||
if (prefs.onboardingCompleted.value) {
|
||||
ensureRuntime()
|
||||
}
|
||||
}
|
||||
|
||||
val canvas: CanvasController
|
||||
get() = ensureRuntime().canvas
|
||||
|
||||
val camera: CameraCaptureManager
|
||||
get() = ensureRuntime().camera
|
||||
|
||||
val sms: SmsManager
|
||||
get() = ensureRuntime().sms
|
||||
|
||||
fun attachRuntimeUi(owner: LifecycleOwner, permissionRequester: PermissionRequester) {
|
||||
val runtime = runtimeRef.value ?: return
|
||||
runtime.camera.attachLifecycleOwner(owner)
|
||||
runtime.camera.attachPermissionRequester(permissionRequester)
|
||||
runtime.sms.attachPermissionRequester(permissionRequester)
|
||||
}
|
||||
val chatSessionKey: StateFlow<String> = runtime.chatSessionKey
|
||||
val chatSessionId: StateFlow<String?> = runtime.chatSessionId
|
||||
val chatMessages = runtime.chatMessages
|
||||
val chatError: StateFlow<String?> = runtime.chatError
|
||||
val chatHealthOk: StateFlow<Boolean> = runtime.chatHealthOk
|
||||
val chatThinkingLevel: StateFlow<String> = runtime.chatThinkingLevel
|
||||
val chatStreamingAssistantText: StateFlow<String?> = runtime.chatStreamingAssistantText
|
||||
val chatPendingToolCalls = runtime.chatPendingToolCalls
|
||||
val chatSessions = runtime.chatSessions
|
||||
val pendingRunCount: StateFlow<Int> = runtime.pendingRunCount
|
||||
|
||||
fun setForeground(value: Boolean) {
|
||||
foreground = value
|
||||
val runtime =
|
||||
if (value && prefs.onboardingCompleted.value) {
|
||||
ensureRuntime()
|
||||
} else {
|
||||
runtimeRef.value
|
||||
}
|
||||
runtime?.setForeground(value)
|
||||
runtime.setForeground(value)
|
||||
}
|
||||
|
||||
fun setDisplayName(value: String) {
|
||||
prefs.setDisplayName(value)
|
||||
runtime.setDisplayName(value)
|
||||
}
|
||||
|
||||
fun setCameraEnabled(value: Boolean) {
|
||||
prefs.setCameraEnabled(value)
|
||||
runtime.setCameraEnabled(value)
|
||||
}
|
||||
|
||||
fun setLocationMode(mode: LocationMode) {
|
||||
prefs.setLocationMode(mode)
|
||||
runtime.setLocationMode(mode)
|
||||
}
|
||||
|
||||
fun setLocationPreciseEnabled(value: Boolean) {
|
||||
prefs.setLocationPreciseEnabled(value)
|
||||
runtime.setLocationPreciseEnabled(value)
|
||||
}
|
||||
|
||||
fun setPreventSleep(value: Boolean) {
|
||||
prefs.setPreventSleep(value)
|
||||
runtime.setPreventSleep(value)
|
||||
}
|
||||
|
||||
fun setManualEnabled(value: Boolean) {
|
||||
prefs.setManualEnabled(value)
|
||||
runtime.setManualEnabled(value)
|
||||
}
|
||||
|
||||
fun setManualHost(value: String) {
|
||||
prefs.setManualHost(value)
|
||||
runtime.setManualHost(value)
|
||||
}
|
||||
|
||||
fun setManualPort(value: Int) {
|
||||
prefs.setManualPort(value)
|
||||
runtime.setManualPort(value)
|
||||
}
|
||||
|
||||
fun setManualTls(value: Boolean) {
|
||||
prefs.setManualTls(value)
|
||||
runtime.setManualTls(value)
|
||||
}
|
||||
|
||||
fun setGatewayToken(value: String) {
|
||||
prefs.setGatewayToken(value)
|
||||
runtime.setGatewayToken(value)
|
||||
}
|
||||
|
||||
fun setGatewayBootstrapToken(value: String) {
|
||||
prefs.setGatewayBootstrapToken(value)
|
||||
runtime.setGatewayBootstrapToken(value)
|
||||
}
|
||||
|
||||
fun setGatewayPassword(value: String) {
|
||||
prefs.setGatewayPassword(value)
|
||||
runtime.setGatewayPassword(value)
|
||||
}
|
||||
|
||||
fun setOnboardingCompleted(value: Boolean) {
|
||||
if (value) {
|
||||
ensureRuntime()
|
||||
}
|
||||
prefs.setOnboardingCompleted(value)
|
||||
runtime.setOnboardingCompleted(value)
|
||||
}
|
||||
|
||||
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
||||
prefs.setCanvasDebugStatusEnabled(value)
|
||||
}
|
||||
|
||||
fun setNotificationForwardingEnabled(value: Boolean) {
|
||||
ensureRuntime().setNotificationForwardingEnabled(value)
|
||||
}
|
||||
|
||||
fun setNotificationForwardingMode(mode: NotificationPackageFilterMode) {
|
||||
ensureRuntime().setNotificationForwardingMode(mode)
|
||||
}
|
||||
|
||||
fun setNotificationForwardingPackagesCsv(csv: String) {
|
||||
val packages =
|
||||
csv
|
||||
.split(',')
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
ensureRuntime().setNotificationForwardingPackages(packages)
|
||||
}
|
||||
|
||||
fun setNotificationForwardingQuietHours(
|
||||
enabled: Boolean,
|
||||
start: String,
|
||||
end: String,
|
||||
): Boolean {
|
||||
return ensureRuntime().setNotificationForwardingQuietHours(enabled = enabled, start = start, end = end)
|
||||
}
|
||||
|
||||
fun setNotificationForwardingMaxEventsPerMinute(value: Int) {
|
||||
ensureRuntime().setNotificationForwardingMaxEventsPerMinute(value)
|
||||
}
|
||||
|
||||
fun setNotificationForwardingSessionKey(value: String?) {
|
||||
ensureRuntime().setNotificationForwardingSessionKey(value)
|
||||
runtime.setCanvasDebugStatusEnabled(value)
|
||||
}
|
||||
|
||||
fun setVoiceScreenActive(active: Boolean) {
|
||||
ensureRuntime().setVoiceScreenActive(active)
|
||||
}
|
||||
|
||||
fun handleAssistantLaunch(request: AssistantLaunchRequest) {
|
||||
_requestedHomeDestination.value = HomeDestination.Chat
|
||||
if (request.autoSend) {
|
||||
_pendingAssistantAutoSend.value = request.prompt
|
||||
_chatDraft.value = null
|
||||
return
|
||||
}
|
||||
_pendingAssistantAutoSend.value = null
|
||||
_chatDraft.value = request.prompt
|
||||
}
|
||||
|
||||
fun clearRequestedHomeDestination() {
|
||||
_requestedHomeDestination.value = null
|
||||
}
|
||||
|
||||
fun clearChatDraft() {
|
||||
_chatDraft.value = null
|
||||
}
|
||||
|
||||
fun clearPendingAssistantAutoSend() {
|
||||
_pendingAssistantAutoSend.value = null
|
||||
runtime.setVoiceScreenActive(active)
|
||||
}
|
||||
|
||||
fun setMicEnabled(enabled: Boolean) {
|
||||
ensureRuntime().setMicEnabled(enabled)
|
||||
runtime.setMicEnabled(enabled)
|
||||
}
|
||||
|
||||
fun setSpeakerEnabled(enabled: Boolean) {
|
||||
ensureRuntime().setSpeakerEnabled(enabled)
|
||||
runtime.setSpeakerEnabled(enabled)
|
||||
}
|
||||
|
||||
fun refreshGatewayConnection() {
|
||||
ensureRuntime().refreshGatewayConnection()
|
||||
runtime.refreshGatewayConnection()
|
||||
}
|
||||
|
||||
fun connect(endpoint: GatewayEndpoint) {
|
||||
ensureRuntime().connect(endpoint)
|
||||
}
|
||||
|
||||
fun connect(
|
||||
endpoint: GatewayEndpoint,
|
||||
token: String?,
|
||||
bootstrapToken: String?,
|
||||
password: String?,
|
||||
) {
|
||||
ensureRuntime().connect(
|
||||
endpoint,
|
||||
NodeRuntime.GatewayConnectAuth(
|
||||
token = token,
|
||||
bootstrapToken = bootstrapToken,
|
||||
password = password,
|
||||
),
|
||||
)
|
||||
runtime.connect(endpoint)
|
||||
}
|
||||
|
||||
fun connectManual() {
|
||||
ensureRuntime().connectManual()
|
||||
runtime.connectManual()
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
runtimeRef.value?.disconnect()
|
||||
runtime.disconnect()
|
||||
}
|
||||
|
||||
fun acceptGatewayTrustPrompt() {
|
||||
runtimeRef.value?.acceptGatewayTrustPrompt()
|
||||
runtime.acceptGatewayTrustPrompt()
|
||||
}
|
||||
|
||||
fun declineGatewayTrustPrompt() {
|
||||
runtimeRef.value?.declineGatewayTrustPrompt()
|
||||
runtime.declineGatewayTrustPrompt()
|
||||
}
|
||||
|
||||
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
|
||||
ensureRuntime().handleCanvasA2UIActionFromWebView(payloadJson)
|
||||
}
|
||||
|
||||
fun isTrustedCanvasActionUrl(rawUrl: String?): Boolean {
|
||||
return ensureRuntime().isTrustedCanvasActionUrl(rawUrl)
|
||||
runtime.handleCanvasA2UIActionFromWebView(payloadJson)
|
||||
}
|
||||
|
||||
fun requestCanvasRehydrate(source: String = "screen_tab") {
|
||||
ensureRuntime().requestCanvasRehydrate(source = source, force = true)
|
||||
runtime.requestCanvasRehydrate(source = source, force = true)
|
||||
}
|
||||
|
||||
fun refreshHomeCanvasOverviewIfConnected() {
|
||||
ensureRuntime().refreshHomeCanvasOverviewIfConnected()
|
||||
runtime.refreshHomeCanvasOverviewIfConnected()
|
||||
}
|
||||
|
||||
fun loadChat(sessionKey: String) {
|
||||
ensureRuntime().loadChat(sessionKey)
|
||||
runtime.loadChat(sessionKey)
|
||||
}
|
||||
|
||||
fun refreshChat() {
|
||||
ensureRuntime().refreshChat()
|
||||
runtime.refreshChat()
|
||||
}
|
||||
|
||||
fun refreshChatSessions(limit: Int? = null) {
|
||||
ensureRuntime().refreshChatSessions(limit = limit)
|
||||
runtime.refreshChatSessions(limit = limit)
|
||||
}
|
||||
|
||||
fun setChatThinkingLevel(level: String) {
|
||||
ensureRuntime().setChatThinkingLevel(level)
|
||||
runtime.setChatThinkingLevel(level)
|
||||
}
|
||||
|
||||
fun switchChatSession(sessionKey: String) {
|
||||
ensureRuntime().switchChatSession(sessionKey)
|
||||
runtime.switchChatSession(sessionKey)
|
||||
}
|
||||
|
||||
fun abortChat() {
|
||||
ensureRuntime().abortChat()
|
||||
runtime.abortChat()
|
||||
}
|
||||
|
||||
fun sendChat(message: String, thinking: String, attachments: List<OutgoingAttachment>) {
|
||||
ensureRuntime().sendChat(message = message, thinking = thinking, attachments = attachments)
|
||||
}
|
||||
|
||||
suspend fun sendChatAwaitAcceptance(
|
||||
message: String,
|
||||
thinking: String,
|
||||
attachments: List<OutgoingAttachment>,
|
||||
): Boolean {
|
||||
return ensureRuntime().sendChatAwaitAcceptance(
|
||||
message = message,
|
||||
thinking = thinking,
|
||||
attachments = attachments,
|
||||
)
|
||||
runtime.sendChat(message = message, thinking = thinking, attachments = attachments)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,18 +4,7 @@ import android.app.Application
|
||||
import android.os.StrictMode
|
||||
|
||||
class NodeApp : Application() {
|
||||
val prefs: SecurePrefs by lazy { SecurePrefs(this) }
|
||||
|
||||
@Volatile private var runtimeInstance: NodeRuntime? = null
|
||||
|
||||
fun ensureRuntime(): NodeRuntime {
|
||||
runtimeInstance?.let { return it }
|
||||
return synchronized(this) {
|
||||
runtimeInstance ?: NodeRuntime(this, prefs).also { runtimeInstance = it }
|
||||
}
|
||||
}
|
||||
|
||||
fun peekRuntime(): NodeRuntime? = runtimeInstance
|
||||
val runtime: NodeRuntime by lazy { NodeRuntime(this) }
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
@@ -28,11 +28,7 @@ class NodeForegroundService : Service() {
|
||||
val initial = buildNotification(title = "OpenClaw Node", text = "Starting…")
|
||||
startForegroundWithTypes(notification = initial)
|
||||
|
||||
val runtime = (application as NodeApp).peekRuntime()
|
||||
if (runtime == null) {
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
val runtime = (application as NodeApp).runtime
|
||||
notificationJob =
|
||||
scope.launch {
|
||||
combine(
|
||||
@@ -63,7 +59,7 @@ class NodeForegroundService : Service() {
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_STOP -> {
|
||||
(application as NodeApp).peekRuntime()?.disconnect()
|
||||
(application as NodeApp).runtime.disconnect()
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
@@ -16,8 +16,6 @@ import ai.openclaw.app.gateway.DeviceIdentityStore
|
||||
import ai.openclaw.app.gateway.GatewayDiscovery
|
||||
import ai.openclaw.app.gateway.GatewayEndpoint
|
||||
import ai.openclaw.app.gateway.GatewaySession
|
||||
import ai.openclaw.app.gateway.GatewayTlsProbeFailure
|
||||
import ai.openclaw.app.gateway.GatewayTlsProbeResult
|
||||
import ai.openclaw.app.gateway.probeGatewayTlsFingerprint
|
||||
import ai.openclaw.app.node.*
|
||||
import ai.openclaw.app.protocol.OpenClawCanvasA2UIAction
|
||||
@@ -26,6 +24,7 @@ import ai.openclaw.app.voice.TalkModeManager
|
||||
import ai.openclaw.app.voice.VoiceConversationEntry
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -35,6 +34,7 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
@@ -43,19 +43,11 @@ import kotlinx.serialization.json.buildJsonObject
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
class NodeRuntime(
|
||||
context: Context,
|
||||
val prefs: SecurePrefs = SecurePrefs(context.applicationContext),
|
||||
private val tlsFingerprintProbe: suspend (String, Int) -> GatewayTlsProbeResult = ::probeGatewayTlsFingerprint,
|
||||
) {
|
||||
data class GatewayConnectAuth(
|
||||
val token: String?,
|
||||
val bootstrapToken: String?,
|
||||
val password: String?,
|
||||
)
|
||||
|
||||
class NodeRuntime(context: Context) {
|
||||
private val appContext = context.applicationContext
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
val prefs = SecurePrefs(appContext)
|
||||
private val deviceAuthStore = DeviceAuthStore(prefs)
|
||||
val canvas = CanvasController()
|
||||
val camera = CameraCaptureManager(appContext)
|
||||
@@ -71,7 +63,6 @@ class NodeRuntime(
|
||||
|
||||
private val identityStore = DeviceIdentityStore(appContext)
|
||||
private var connectedEndpoint: GatewayEndpoint? = null
|
||||
private var activeGatewayAuth: GatewayConnectAuth? = null
|
||||
|
||||
private val cameraHandler: CameraHandler = CameraHandler(
|
||||
appContext = appContext,
|
||||
@@ -97,8 +88,6 @@ class NodeRuntime(
|
||||
|
||||
private val deviceHandler: DeviceHandler = DeviceHandler(
|
||||
appContext = appContext,
|
||||
smsEnabled = BuildConfig.OPENCLAW_ENABLE_SMS,
|
||||
callLogEnabled = BuildConfig.OPENCLAW_ENABLE_CALL_LOG,
|
||||
)
|
||||
|
||||
private val notificationsHandler: NotificationsHandler = NotificationsHandler(
|
||||
@@ -121,10 +110,6 @@ class NodeRuntime(
|
||||
appContext = appContext,
|
||||
)
|
||||
|
||||
private val callLogHandler: CallLogHandler = CallLogHandler(
|
||||
appContext = appContext,
|
||||
)
|
||||
|
||||
private val motionHandler: MotionHandler = MotionHandler(
|
||||
appContext = appContext,
|
||||
)
|
||||
@@ -147,10 +132,7 @@ class NodeRuntime(
|
||||
voiceWakeMode = { VoiceWakeMode.Off },
|
||||
motionActivityAvailable = { motionHandler.isActivityAvailable() },
|
||||
motionPedometerAvailable = { motionHandler.isPedometerAvailable() },
|
||||
sendSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canSendSms() },
|
||||
readSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canReadSms() },
|
||||
smsSearchPossible = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.hasTelephonyFeature() },
|
||||
callLogAvailable = { BuildConfig.OPENCLAW_ENABLE_CALL_LOG },
|
||||
smsAvailable = { sms.canSendSms() },
|
||||
hasRecordAudioPermission = { hasRecordAudioPermission() },
|
||||
manualTls = { manualTls.value },
|
||||
)
|
||||
@@ -169,15 +151,10 @@ class NodeRuntime(
|
||||
smsHandler = smsHandlerImpl,
|
||||
a2uiHandler = a2uiHandler,
|
||||
debugHandler = debugHandler,
|
||||
callLogHandler = callLogHandler,
|
||||
isForeground = { _isForeground.value },
|
||||
cameraEnabled = { cameraEnabled.value },
|
||||
locationEnabled = { locationMode.value != LocationMode.Off },
|
||||
sendSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canSendSms() },
|
||||
readSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canReadSms() },
|
||||
smsFeatureEnabled = { BuildConfig.OPENCLAW_ENABLE_SMS },
|
||||
smsTelephonyAvailable = { sms.hasTelephonyFeature() },
|
||||
callLogAvailable = { BuildConfig.OPENCLAW_ENABLE_CALL_LOG },
|
||||
smsAvailable = { sms.canSendSms() },
|
||||
debugBuild = { BuildConfig.DEBUG },
|
||||
refreshNodeCanvasCapability = { nodeSession.refreshNodeCanvasCapability() },
|
||||
onCanvasA2uiPush = {
|
||||
@@ -193,7 +170,6 @@ class NodeRuntime(
|
||||
data class GatewayTrustPrompt(
|
||||
val endpoint: GatewayEndpoint,
|
||||
val fingerprintSha256: String,
|
||||
val auth: GatewayConnectAuth,
|
||||
)
|
||||
|
||||
private val _isConnected = MutableStateFlow(false)
|
||||
@@ -207,12 +183,7 @@ class NodeRuntime(
|
||||
private val _pendingGatewayTrust = MutableStateFlow<GatewayTrustPrompt?>(null)
|
||||
val pendingGatewayTrust: StateFlow<GatewayTrustPrompt?> = _pendingGatewayTrust.asStateFlow()
|
||||
|
||||
private fun resolveNodeMainSessionKey(agentId: String? = gatewayDefaultAgentId): String {
|
||||
val deviceId = identityStore.loadOrCreate().deviceId
|
||||
return buildNodeMainSessionKey(deviceId, agentId)
|
||||
}
|
||||
|
||||
private val _mainSessionKey = MutableStateFlow(resolveNodeMainSessionKey())
|
||||
private val _mainSessionKey = MutableStateFlow("main")
|
||||
val mainSessionKey: StateFlow<String> = _mainSessionKey.asStateFlow()
|
||||
|
||||
private val cameraHudSeq = AtomicLong(0)
|
||||
@@ -260,7 +231,7 @@ class NodeRuntime(
|
||||
_serverName.value = name
|
||||
_remoteAddress.value = remote
|
||||
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
||||
syncMainSessionKey(resolveAgentIdFromMainSessionKey(mainSessionKey))
|
||||
applyMainSessionKey(mainSessionKey)
|
||||
updateStatus()
|
||||
micCapture.onGatewayConnectionChanged(true)
|
||||
scope.launch {
|
||||
@@ -276,6 +247,9 @@ class NodeRuntime(
|
||||
_serverName.value = null
|
||||
_remoteAddress.value = null
|
||||
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
||||
if (!isCanonicalMainSessionKey(_mainSessionKey.value)) {
|
||||
_mainSessionKey.value = "main"
|
||||
}
|
||||
chat.applyMainSessionKey(resolveMainSessionKey())
|
||||
chat.onDisconnected(message)
|
||||
updateStatus()
|
||||
@@ -300,11 +274,6 @@ class NodeRuntime(
|
||||
_canvasRehydrateErrorText.value = null
|
||||
updateStatus()
|
||||
showLocalCanvasOnConnect()
|
||||
val endpoint = connectedEndpoint
|
||||
val auth = activeGatewayAuth
|
||||
if (endpoint != null && auth != null) {
|
||||
maybeStartOperatorSessionAfterNodeConnect(endpoint, auth)
|
||||
}
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
_nodeConnected.value = false
|
||||
@@ -339,11 +308,9 @@ class NodeRuntime(
|
||||
session = operatorSession,
|
||||
json = json,
|
||||
supportsChatSubscribe = false,
|
||||
).also {
|
||||
it.applyMainSessionKey(_mainSessionKey.value)
|
||||
}
|
||||
)
|
||||
private val voiceReplySpeakerLazy: Lazy<TalkModeManager> = lazy {
|
||||
// Reuse the existing TalkMode speech engine for native Android TTS playback
|
||||
// Reuse the existing TalkMode speech engine (ElevenLabs + deterministic system-TTS fallback)
|
||||
// without enabling the legacy talk capture loop.
|
||||
TalkModeManager(
|
||||
context = appContext,
|
||||
@@ -351,8 +318,6 @@ class NodeRuntime(
|
||||
session = operatorSession,
|
||||
supportsChatSubscribe = false,
|
||||
isConnected = { operatorConnected },
|
||||
onBeforeSpeak = { micCapture.pauseForTts() },
|
||||
onAfterSpeak = { micCapture.resumeAfterTts() },
|
||||
).also { speaker ->
|
||||
speaker.setPlaybackEnabled(prefs.speakerEnabled.value)
|
||||
}
|
||||
@@ -381,10 +346,11 @@ class NodeRuntime(
|
||||
parseChatSendRunId(response) ?: idempotencyKey
|
||||
},
|
||||
speakAssistantReply = { text ->
|
||||
// Voice-tab replies should speak through the dedicated reply speaker.
|
||||
// Relying on talkMode.ttsOnAllResponses here can drop playback if the
|
||||
// chat-event path misses the terminal event for this turn.
|
||||
voiceReplySpeaker.speakAssistantReply(text)
|
||||
// Skip if TalkModeManager is handling TTS (ttsOnAllResponses) to avoid
|
||||
// double-speaking the same assistant reply from both pipelines.
|
||||
if (!talkMode.ttsOnAllResponses) {
|
||||
voiceReplySpeaker.speakAssistantReply(text)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -423,20 +389,16 @@ class NodeRuntime(
|
||||
session = operatorSession,
|
||||
supportsChatSubscribe = true,
|
||||
isConnected = { operatorConnected },
|
||||
onBeforeSpeak = { micCapture.pauseForTts() },
|
||||
onAfterSpeak = { micCapture.resumeAfterTts() },
|
||||
)
|
||||
}
|
||||
|
||||
private fun syncMainSessionKey(agentId: String?) {
|
||||
val resolvedKey = resolveNodeMainSessionKey(agentId)
|
||||
// Always push the resolved session key into TalkMode, even when the
|
||||
// state flow value is unchanged, so lazy TalkMode instances do not
|
||||
// stay on the default "main" session key.
|
||||
talkMode.setMainSessionKey(resolvedKey)
|
||||
if (_mainSessionKey.value == resolvedKey) return
|
||||
_mainSessionKey.value = resolvedKey
|
||||
chat.applyMainSessionKey(resolvedKey)
|
||||
private fun applyMainSessionKey(candidate: String?) {
|
||||
val trimmed = normalizeMainKey(candidate) ?: return
|
||||
if (isCanonicalMainSessionKey(_mainSessionKey.value)) return
|
||||
if (_mainSessionKey.value == trimmed) return
|
||||
_mainSessionKey.value = trimmed
|
||||
talkMode.setMainSessionKey(trimmed)
|
||||
chat.applyMainSessionKey(trimmed)
|
||||
updateHomeCanvasState()
|
||||
}
|
||||
|
||||
@@ -559,17 +521,6 @@ class NodeRuntime(
|
||||
fun setOnboardingCompleted(value: Boolean) = prefs.setOnboardingCompleted(value)
|
||||
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
|
||||
val notificationForwardingEnabled: StateFlow<Boolean> = prefs.notificationForwardingEnabled
|
||||
val notificationForwardingMode: StateFlow<NotificationPackageFilterMode> =
|
||||
prefs.notificationForwardingMode
|
||||
val notificationForwardingPackages: StateFlow<Set<String>> = prefs.notificationForwardingPackages
|
||||
val notificationForwardingQuietHoursEnabled: StateFlow<Boolean> =
|
||||
prefs.notificationForwardingQuietHoursEnabled
|
||||
val notificationForwardingQuietStart: StateFlow<String> = prefs.notificationForwardingQuietStart
|
||||
val notificationForwardingQuietEnd: StateFlow<String> = prefs.notificationForwardingQuietEnd
|
||||
val notificationForwardingMaxEventsPerMinute: StateFlow<Int> =
|
||||
prefs.notificationForwardingMaxEventsPerMinute
|
||||
val notificationForwardingSessionKey: StateFlow<String?> = prefs.notificationForwardingSessionKey
|
||||
|
||||
private var didAutoConnect = false
|
||||
|
||||
@@ -595,11 +546,12 @@ class NodeRuntime(
|
||||
|
||||
scope.launch {
|
||||
prefs.talkEnabled.collect { enabled ->
|
||||
// MicCaptureManager handles STT + send to gateway, while the dedicated
|
||||
// reply speaker handles TTS for assistant replies in the voice tab.
|
||||
// MicCaptureManager handles STT + send to gateway.
|
||||
// TalkModeManager plays TTS on assistant responses.
|
||||
micCapture.setMicEnabled(enabled)
|
||||
if (enabled) {
|
||||
talkMode.ttsOnAllResponses = false
|
||||
// Mic on = user is on voice screen and wants TTS responses.
|
||||
talkMode.ttsOnAllResponses = true
|
||||
scope.launch { talkMode.ensureChatSubscribed() }
|
||||
}
|
||||
externalAudioCaptureActive.value = enabled
|
||||
@@ -608,8 +560,43 @@ class NodeRuntime(
|
||||
|
||||
scope.launch(Dispatchers.Default) {
|
||||
gateways.collect { list ->
|
||||
seedLastDiscoveredGateway(list)
|
||||
autoConnectIfNeeded()
|
||||
if (list.isNotEmpty()) {
|
||||
// Security: don't let an unauthenticated discovery feed continuously steer autoconnect.
|
||||
// UX parity with iOS: only set once when unset.
|
||||
if (lastDiscoveredStableId.value.trim().isEmpty()) {
|
||||
prefs.setLastDiscoveredStableId(list.first().stableId)
|
||||
}
|
||||
}
|
||||
|
||||
if (didAutoConnect) return@collect
|
||||
if (_isConnected.value) return@collect
|
||||
|
||||
if (manualEnabled.value) {
|
||||
val host = manualHost.value.trim()
|
||||
val port = manualPort.value
|
||||
if (host.isNotEmpty() && port in 1..65535) {
|
||||
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
|
||||
if (!manualTls.value) return@collect
|
||||
val stableId = GatewayEndpoint.manual(host = host, port = port).stableId
|
||||
val storedFingerprint = prefs.loadGatewayTlsFingerprint(stableId)?.trim().orEmpty()
|
||||
if (storedFingerprint.isEmpty()) return@collect
|
||||
|
||||
didAutoConnect = true
|
||||
connect(GatewayEndpoint.manual(host = host, port = port))
|
||||
}
|
||||
return@collect
|
||||
}
|
||||
|
||||
val targetStableId = lastDiscoveredStableId.value.trim()
|
||||
if (targetStableId.isEmpty()) return@collect
|
||||
val target = list.firstOrNull { it.stableId == targetStableId } ?: return@collect
|
||||
|
||||
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
|
||||
val storedFingerprint = prefs.loadGatewayTlsFingerprint(target.stableId)?.trim().orEmpty()
|
||||
if (storedFingerprint.isEmpty()) return@collect
|
||||
|
||||
didAutoConnect = true
|
||||
connect(target)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -634,53 +621,11 @@ class NodeRuntime(
|
||||
|
||||
fun setForeground(value: Boolean) {
|
||||
_isForeground.value = value
|
||||
if (value) {
|
||||
reconnectPreferredGatewayOnForeground()
|
||||
} else {
|
||||
if (!value) {
|
||||
stopActiveVoiceSession()
|
||||
}
|
||||
}
|
||||
|
||||
private fun seedLastDiscoveredGateway(list: List<GatewayEndpoint>) {
|
||||
if (list.isEmpty()) return
|
||||
if (lastDiscoveredStableId.value.trim().isNotEmpty()) return
|
||||
prefs.setLastDiscoveredStableId(list.first().stableId)
|
||||
}
|
||||
|
||||
private fun resolvePreferredGatewayEndpoint(): GatewayEndpoint? {
|
||||
if (manualEnabled.value) {
|
||||
val host = manualHost.value.trim()
|
||||
val port = manualPort.value
|
||||
if (host.isEmpty() || port !in 1..65535) return null
|
||||
return GatewayEndpoint.manual(host = host, port = port)
|
||||
}
|
||||
|
||||
val targetStableId = lastDiscoveredStableId.value.trim()
|
||||
if (targetStableId.isEmpty()) return null
|
||||
val endpoint = gateways.value.firstOrNull { it.stableId == targetStableId } ?: return null
|
||||
val storedFingerprint = prefs.loadGatewayTlsFingerprint(endpoint.stableId)?.trim().orEmpty()
|
||||
if (storedFingerprint.isEmpty()) return null
|
||||
return endpoint
|
||||
}
|
||||
|
||||
private fun autoConnectIfNeeded() {
|
||||
if (didAutoConnect) return
|
||||
if (_isConnected.value) return
|
||||
val endpoint = resolvePreferredGatewayEndpoint() ?: return
|
||||
didAutoConnect = true
|
||||
connect(endpoint)
|
||||
}
|
||||
|
||||
private fun reconnectPreferredGatewayOnForeground() {
|
||||
if (_isConnected.value) return
|
||||
if (_pendingGatewayTrust.value != null) return
|
||||
if (connectedEndpoint != null) {
|
||||
refreshGatewayConnection()
|
||||
return
|
||||
}
|
||||
resolvePreferredGatewayEndpoint()?.let(::connect)
|
||||
}
|
||||
|
||||
fun setDisplayName(value: String) {
|
||||
prefs.setDisplayName(value)
|
||||
}
|
||||
@@ -721,34 +666,6 @@ class NodeRuntime(
|
||||
prefs.setCanvasDebugStatusEnabled(value)
|
||||
}
|
||||
|
||||
fun setNotificationForwardingEnabled(value: Boolean) {
|
||||
prefs.setNotificationForwardingEnabled(value)
|
||||
}
|
||||
|
||||
fun setNotificationForwardingMode(mode: NotificationPackageFilterMode) {
|
||||
prefs.setNotificationForwardingMode(mode)
|
||||
}
|
||||
|
||||
fun setNotificationForwardingPackages(packages: List<String>) {
|
||||
prefs.setNotificationForwardingPackages(packages)
|
||||
}
|
||||
|
||||
fun setNotificationForwardingQuietHours(
|
||||
enabled: Boolean,
|
||||
start: String,
|
||||
end: String,
|
||||
): Boolean {
|
||||
return prefs.setNotificationForwardingQuietHours(enabled = enabled, start = start, end = end)
|
||||
}
|
||||
|
||||
fun setNotificationForwardingMaxEventsPerMinute(value: Int) {
|
||||
prefs.setNotificationForwardingMaxEventsPerMinute(value)
|
||||
}
|
||||
|
||||
fun setNotificationForwardingSessionKey(value: String?) {
|
||||
prefs.setNotificationForwardingSessionKey(value)
|
||||
}
|
||||
|
||||
fun setVoiceScreenActive(active: Boolean) {
|
||||
if (!active) {
|
||||
stopActiveVoiceSession()
|
||||
@@ -760,8 +677,8 @@ class NodeRuntime(
|
||||
prefs.setTalkEnabled(value)
|
||||
if (value) {
|
||||
// Tapping mic on interrupts any active TTS (barge-in)
|
||||
stopVoicePlayback()
|
||||
talkMode.ttsOnAllResponses = false
|
||||
talkMode.stopTts()
|
||||
talkMode.ttsOnAllResponses = true
|
||||
scope.launch { talkMode.ensureChatSubscribed() }
|
||||
}
|
||||
micCapture.setMicEnabled(value)
|
||||
@@ -776,25 +693,18 @@ class NodeRuntime(
|
||||
if (voiceReplySpeakerLazy.isInitialized()) {
|
||||
voiceReplySpeaker.setPlaybackEnabled(value)
|
||||
}
|
||||
// Keep TalkMode in sync so any active Talk playback also respects speaker mute.
|
||||
// Keep TalkMode in sync so speaker mute works when ttsOnAllResponses is active.
|
||||
talkMode.setPlaybackEnabled(value)
|
||||
}
|
||||
|
||||
private fun stopActiveVoiceSession() {
|
||||
talkMode.ttsOnAllResponses = false
|
||||
stopVoicePlayback()
|
||||
talkMode.stopTts()
|
||||
micCapture.setMicEnabled(false)
|
||||
prefs.setTalkEnabled(false)
|
||||
externalAudioCaptureActive.value = false
|
||||
}
|
||||
|
||||
private fun stopVoicePlayback() {
|
||||
talkMode.stopTts()
|
||||
if (voiceReplySpeakerLazy.isInitialized()) {
|
||||
voiceReplySpeaker.stopTts()
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshGatewayConnection() {
|
||||
val endpoint =
|
||||
connectedEndpoint ?: run {
|
||||
@@ -803,68 +713,41 @@ class NodeRuntime(
|
||||
}
|
||||
operatorStatusText = "Connecting…"
|
||||
updateStatus()
|
||||
connectWithAuth(endpoint = endpoint, auth = resolveGatewayConnectAuth(), reconnect = true)
|
||||
}
|
||||
|
||||
private fun connectWithAuth(
|
||||
endpoint: GatewayEndpoint,
|
||||
auth: GatewayConnectAuth,
|
||||
reconnect: Boolean = false,
|
||||
) {
|
||||
activeGatewayAuth = auth
|
||||
val token = prefs.loadGatewayToken()
|
||||
val bootstrapToken = prefs.loadGatewayBootstrapToken()
|
||||
val password = prefs.loadGatewayPassword()
|
||||
val tls = connectionManager.resolveTlsParams(endpoint)
|
||||
val operatorAuth =
|
||||
resolveOperatorSessionConnectAuth(
|
||||
auth = auth,
|
||||
storedOperatorToken = loadStoredRoleDeviceToken("operator"),
|
||||
)
|
||||
if (operatorAuth == null) {
|
||||
operatorConnected = false
|
||||
operatorStatusText = "Offline"
|
||||
operatorSession.disconnect()
|
||||
updateStatus()
|
||||
} else {
|
||||
operatorSession.connect(
|
||||
endpoint,
|
||||
operatorAuth.token,
|
||||
operatorAuth.bootstrapToken,
|
||||
operatorAuth.password,
|
||||
connectionManager.buildOperatorConnectOptions(),
|
||||
tls,
|
||||
)
|
||||
}
|
||||
operatorSession.connect(
|
||||
endpoint,
|
||||
token,
|
||||
bootstrapToken,
|
||||
password,
|
||||
connectionManager.buildOperatorConnectOptions(),
|
||||
tls,
|
||||
)
|
||||
nodeSession.connect(
|
||||
endpoint,
|
||||
auth.token,
|
||||
auth.bootstrapToken,
|
||||
auth.password,
|
||||
token,
|
||||
bootstrapToken,
|
||||
password,
|
||||
connectionManager.buildNodeConnectOptions(),
|
||||
tls,
|
||||
)
|
||||
if (reconnect && operatorAuth != null) {
|
||||
operatorSession.reconnect()
|
||||
}
|
||||
if (reconnect) {
|
||||
nodeSession.reconnect()
|
||||
}
|
||||
operatorSession.reconnect()
|
||||
nodeSession.reconnect()
|
||||
}
|
||||
|
||||
private fun beginConnect(
|
||||
endpoint: GatewayEndpoint,
|
||||
auth: GatewayConnectAuth,
|
||||
) {
|
||||
fun connect(endpoint: GatewayEndpoint) {
|
||||
val tls = connectionManager.resolveTlsParams(endpoint)
|
||||
if (tls?.required == true && tls.expectedFingerprint.isNullOrBlank()) {
|
||||
// First-time TLS: capture fingerprint, ask user to verify out-of-band, then store and connect.
|
||||
_statusText.value = "Verify gateway TLS fingerprint…"
|
||||
scope.launch {
|
||||
val tlsProbe = tlsFingerprintProbe(endpoint.host, endpoint.port)
|
||||
val fp = tlsProbe.fingerprintSha256 ?: run {
|
||||
_statusText.value = gatewayTlsProbeFailureMessage(tlsProbe.failure)
|
||||
val fp = probeGatewayTlsFingerprint(endpoint.host, endpoint.port) ?: run {
|
||||
_statusText.value = "Failed: can't read TLS fingerprint"
|
||||
return@launch
|
||||
}
|
||||
_pendingGatewayTrust.value =
|
||||
GatewayTrustPrompt(endpoint = endpoint, fingerprintSha256 = fp, auth = auth)
|
||||
_pendingGatewayTrust.value = GatewayTrustPrompt(endpoint = endpoint, fingerprintSha256 = fp)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -873,34 +756,32 @@ class NodeRuntime(
|
||||
operatorStatusText = "Connecting…"
|
||||
nodeStatusText = "Connecting…"
|
||||
updateStatus()
|
||||
connectWithAuth(endpoint = endpoint, auth = auth)
|
||||
}
|
||||
|
||||
fun connect(endpoint: GatewayEndpoint) {
|
||||
beginConnect(endpoint = endpoint, auth = resolveGatewayConnectAuth())
|
||||
}
|
||||
|
||||
fun connect(
|
||||
endpoint: GatewayEndpoint,
|
||||
auth: GatewayConnectAuth,
|
||||
) {
|
||||
beginConnect(endpoint = endpoint, auth = resolveGatewayConnectAuth(auth))
|
||||
}
|
||||
|
||||
internal fun resolveGatewayConnectAuth(explicitAuth: GatewayConnectAuth? = null): GatewayConnectAuth {
|
||||
return explicitAuth
|
||||
?: GatewayConnectAuth(
|
||||
token = prefs.loadGatewayToken(),
|
||||
bootstrapToken = prefs.loadGatewayBootstrapToken(),
|
||||
password = prefs.loadGatewayPassword(),
|
||||
)
|
||||
val token = prefs.loadGatewayToken()
|
||||
val bootstrapToken = prefs.loadGatewayBootstrapToken()
|
||||
val password = prefs.loadGatewayPassword()
|
||||
operatorSession.connect(
|
||||
endpoint,
|
||||
token,
|
||||
bootstrapToken,
|
||||
password,
|
||||
connectionManager.buildOperatorConnectOptions(),
|
||||
tls,
|
||||
)
|
||||
nodeSession.connect(
|
||||
endpoint,
|
||||
token,
|
||||
bootstrapToken,
|
||||
password,
|
||||
connectionManager.buildNodeConnectOptions(),
|
||||
tls,
|
||||
)
|
||||
}
|
||||
|
||||
fun acceptGatewayTrustPrompt() {
|
||||
val prompt = _pendingGatewayTrust.value ?: return
|
||||
_pendingGatewayTrust.value = null
|
||||
prefs.saveGatewayTlsFingerprint(prompt.endpoint.stableId, prompt.fingerprintSha256)
|
||||
beginConnect(endpoint = prompt.endpoint, auth = prompt.auth)
|
||||
connect(prompt.endpoint)
|
||||
}
|
||||
|
||||
fun declineGatewayTrustPrompt() {
|
||||
@@ -908,15 +789,6 @@ class NodeRuntime(
|
||||
_statusText.value = "Offline"
|
||||
}
|
||||
|
||||
private fun gatewayTlsProbeFailureMessage(failure: GatewayTlsProbeFailure?): String {
|
||||
return when (failure) {
|
||||
GatewayTlsProbeFailure.TLS_UNAVAILABLE ->
|
||||
"Failed: this host requires wss:// or Tailscale Serve. No TLS endpoint detected."
|
||||
GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE, null ->
|
||||
"Failed: couldn't reach the secure gateway endpoint for this host."
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasRecordAudioPermission(): Boolean {
|
||||
return (
|
||||
ContextCompat.checkSelfPermission(appContext, Manifest.permission.RECORD_AUDIO) ==
|
||||
@@ -934,38 +806,8 @@ class NodeRuntime(
|
||||
connect(GatewayEndpoint.manual(host = host, port = port))
|
||||
}
|
||||
|
||||
private fun loadStoredRoleDeviceToken(role: String): String? {
|
||||
val deviceId = identityStore.loadOrCreate().deviceId
|
||||
return deviceAuthStore.loadToken(deviceId, role)
|
||||
}
|
||||
|
||||
private fun maybeStartOperatorSessionAfterNodeConnect(
|
||||
endpoint: GatewayEndpoint,
|
||||
auth: GatewayConnectAuth,
|
||||
) {
|
||||
if (operatorConnected || operatorStatusText == "Connecting…") {
|
||||
return
|
||||
}
|
||||
val operatorAuth =
|
||||
resolveOperatorSessionConnectAuth(
|
||||
auth = auth,
|
||||
storedOperatorToken = loadStoredRoleDeviceToken("operator"),
|
||||
) ?: return
|
||||
operatorStatusText = "Connecting…"
|
||||
updateStatus()
|
||||
operatorSession.connect(
|
||||
endpoint,
|
||||
operatorAuth.token,
|
||||
operatorAuth.bootstrapToken,
|
||||
operatorAuth.password,
|
||||
connectionManager.buildOperatorConnectOptions(),
|
||||
connectionManager.resolveTlsParams(endpoint),
|
||||
)
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
connectedEndpoint = null
|
||||
activeGatewayAuth = null
|
||||
_pendingGatewayTrust.value = null
|
||||
operatorSession.disconnect()
|
||||
nodeSession.disconnect()
|
||||
@@ -1043,10 +885,6 @@ class NodeRuntime(
|
||||
}
|
||||
}
|
||||
|
||||
fun isTrustedCanvasActionUrl(rawUrl: String?): Boolean {
|
||||
return a2uiHandler.isTrustedCanvasActionUrl(rawUrl)
|
||||
}
|
||||
|
||||
fun loadChat(sessionKey: String) {
|
||||
val key = sessionKey.trim().ifEmpty { resolveMainSessionKey() }
|
||||
chat.load(key)
|
||||
@@ -1076,14 +914,6 @@ class NodeRuntime(
|
||||
chat.sendMessage(message = message, thinkingLevel = thinking, attachments = attachments)
|
||||
}
|
||||
|
||||
suspend fun sendChatAwaitAcceptance(
|
||||
message: String,
|
||||
thinking: String,
|
||||
attachments: List<OutgoingAttachment>,
|
||||
): Boolean {
|
||||
return chat.sendMessageAwaitAcceptance(message = message, thinkingLevel = thinking, attachments = attachments)
|
||||
}
|
||||
|
||||
private fun handleGatewayEvent(event: String, payloadJson: String?) {
|
||||
micCapture.handleGatewayEvent(event, payloadJson)
|
||||
talkMode.handleGatewayEvent(event, payloadJson)
|
||||
@@ -1107,7 +937,9 @@ class NodeRuntime(
|
||||
val config = root?.get("config").asObjectOrNull()
|
||||
val ui = config?.get("ui").asObjectOrNull()
|
||||
val raw = ui?.get("seamColor").asStringOrNull()?.trim()
|
||||
syncMainSessionKey(gatewayDefaultAgentId)
|
||||
val sessionCfg = config?.get("session").asObjectOrNull()
|
||||
val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull())
|
||||
applyMainSessionKey(mainKey)
|
||||
|
||||
val parsed = parseHexColorArgb(raw)
|
||||
_seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB
|
||||
@@ -1140,7 +972,7 @@ class NodeRuntime(
|
||||
|
||||
gatewayDefaultAgentId = defaultAgentId.ifEmpty { null }
|
||||
gatewayAgents = agents
|
||||
syncMainSessionKey(resolveAgentIdFromMainSessionKey(mainKey) ?: gatewayDefaultAgentId)
|
||||
applyMainSessionKey(mainKey)
|
||||
updateHomeCanvasState()
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
@@ -1301,49 +1133,6 @@ class NodeRuntime(
|
||||
|
||||
}
|
||||
|
||||
internal fun resolveOperatorSessionConnectAuth(
|
||||
auth: NodeRuntime.GatewayConnectAuth,
|
||||
storedOperatorToken: String?,
|
||||
): NodeRuntime.GatewayConnectAuth? {
|
||||
val explicitToken = auth.token?.trim()?.takeIf { it.isNotEmpty() }
|
||||
if (explicitToken != null) {
|
||||
return NodeRuntime.GatewayConnectAuth(
|
||||
token = explicitToken,
|
||||
bootstrapToken = null,
|
||||
password = null,
|
||||
)
|
||||
}
|
||||
|
||||
val explicitPassword = auth.password?.trim()?.takeIf { it.isNotEmpty() }
|
||||
if (explicitPassword != null) {
|
||||
return NodeRuntime.GatewayConnectAuth(
|
||||
token = null,
|
||||
bootstrapToken = null,
|
||||
password = explicitPassword,
|
||||
)
|
||||
}
|
||||
|
||||
val storedToken = storedOperatorToken?.trim()?.takeIf { it.isNotEmpty() }
|
||||
if (storedToken != null) {
|
||||
// Bootstrap can seed the operator token, but operator should reconnect
|
||||
// through the stored device-token path rather than bootstrap auth itself.
|
||||
return NodeRuntime.GatewayConnectAuth(
|
||||
token = null,
|
||||
bootstrapToken = null,
|
||||
password = null,
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
internal fun shouldConnectOperatorSession(
|
||||
auth: NodeRuntime.GatewayConnectAuth,
|
||||
storedOperatorToken: String?,
|
||||
): Boolean {
|
||||
return resolveOperatorSessionConnectAuth(auth, storedOperatorToken) != null
|
||||
}
|
||||
|
||||
private enum class HomeCanvasGatewayState {
|
||||
Connected,
|
||||
Connecting,
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
|
||||
enum class NotificationPackageFilterMode(val rawValue: String) {
|
||||
Allowlist("allowlist"),
|
||||
Blocklist("blocklist"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromRawValue(raw: String?): NotificationPackageFilterMode {
|
||||
return entries.firstOrNull { it.rawValue == raw?.trim()?.lowercase() } ?: Blocklist
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal data class NotificationForwardingPolicy(
|
||||
val enabled: Boolean,
|
||||
val mode: NotificationPackageFilterMode,
|
||||
val packages: Set<String>,
|
||||
val quietHoursEnabled: Boolean,
|
||||
val quietStart: String,
|
||||
val quietEnd: String,
|
||||
val maxEventsPerMinute: Int,
|
||||
val sessionKey: String?,
|
||||
)
|
||||
|
||||
internal fun NotificationForwardingPolicy.allowsPackage(packageName: String): Boolean {
|
||||
val normalized = packageName.trim()
|
||||
if (normalized.isEmpty()) {
|
||||
return false
|
||||
}
|
||||
return when (mode) {
|
||||
NotificationPackageFilterMode.Allowlist -> packages.contains(normalized)
|
||||
NotificationPackageFilterMode.Blocklist -> !packages.contains(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun NotificationForwardingPolicy.isWithinQuietHours(
|
||||
nowEpochMs: Long,
|
||||
zoneId: ZoneId = ZoneId.systemDefault(),
|
||||
): Boolean {
|
||||
if (!quietHoursEnabled) {
|
||||
return false
|
||||
}
|
||||
val startMinutes = parseLocalHourMinute(quietStart) ?: return false
|
||||
val endMinutes = parseLocalHourMinute(quietEnd) ?: return false
|
||||
if (startMinutes == endMinutes) {
|
||||
return true
|
||||
}
|
||||
val now =
|
||||
Instant.ofEpochMilli(nowEpochMs)
|
||||
.atZone(zoneId)
|
||||
.toLocalTime()
|
||||
val nowMinutes = now.hour * 60 + now.minute
|
||||
return if (startMinutes < endMinutes) {
|
||||
nowMinutes in startMinutes until endMinutes
|
||||
} else {
|
||||
nowMinutes >= startMinutes || nowMinutes < endMinutes
|
||||
}
|
||||
}
|
||||
|
||||
private val localHourMinuteRegex = Regex("""^([01]\d|2[0-3]):([0-5]\d)$""")
|
||||
|
||||
internal fun normalizeLocalHourMinute(raw: String): String? {
|
||||
val trimmed = raw.trim()
|
||||
val match = localHourMinuteRegex.matchEntire(trimmed) ?: return null
|
||||
return "${match.groupValues[1]}:${match.groupValues[2]}"
|
||||
}
|
||||
|
||||
internal fun parseLocalHourMinute(raw: String): Int? {
|
||||
val normalized = normalizeLocalHourMinute(raw) ?: return null
|
||||
val parts = normalized.split(':')
|
||||
val hour = parts[0].toInt()
|
||||
val minute = parts[1].toInt()
|
||||
return hour * 60 + minute
|
||||
}
|
||||
|
||||
internal class NotificationBurstLimiter {
|
||||
private val lock = Any()
|
||||
private var windowStartMs: Long = -1L
|
||||
private var eventsInWindow: Int = 0
|
||||
|
||||
fun allow(nowEpochMs: Long, maxEventsPerMinute: Int): Boolean {
|
||||
if (maxEventsPerMinute <= 0) {
|
||||
return false
|
||||
}
|
||||
val currentWindow = nowEpochMs - (nowEpochMs % 60_000L)
|
||||
synchronized(lock) {
|
||||
if (currentWindow != windowStartMs) {
|
||||
windowStartMs = currentWindow
|
||||
eventsInWindow = 0
|
||||
}
|
||||
if (eventsInWindow >= maxEventsPerMinute) {
|
||||
return false
|
||||
}
|
||||
eventsInWindow += 1
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,6 @@ import android.content.pm.PackageManager
|
||||
import android.content.Intent
|
||||
import android.Manifest
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.Settings
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.activity.ComponentActivity
|
||||
@@ -13,21 +11,17 @@ import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class PermissionRequester(private val activity: ComponentActivity) {
|
||||
private val mutex = Mutex()
|
||||
private var pending: CompletableDeferred<Map<String, Boolean>>? = null
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
private val launcher: ActivityResultLauncher<Array<String>> =
|
||||
activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
|
||||
@@ -92,84 +86,32 @@ class PermissionRequester(private val activity: ComponentActivity) {
|
||||
|
||||
private suspend fun showRationaleDialog(permissions: List<String>): Boolean =
|
||||
withContext(Dispatchers.Main) {
|
||||
if (activity.isFinishing || activity.isDestroyed) {
|
||||
return@withContext false
|
||||
}
|
||||
suspendCancellableCoroutine { cont ->
|
||||
val lifecycle = activity.lifecycle
|
||||
var dialog: AlertDialog? = null
|
||||
var observer: LifecycleEventObserver? = null
|
||||
val finished = AtomicBoolean(false)
|
||||
val removeObserver = {
|
||||
observer?.let(lifecycle::removeObserver)
|
||||
observer = null
|
||||
}
|
||||
fun finish(result: Boolean?) {
|
||||
if (!finished.compareAndSet(false, true)) return
|
||||
removeObserver()
|
||||
dialog?.dismiss()
|
||||
if (result != null) {
|
||||
cont.resume(result)
|
||||
}
|
||||
}
|
||||
val actualObserver =
|
||||
LifecycleEventObserver { _, event ->
|
||||
if (event != Lifecycle.Event.ON_DESTROY) return@LifecycleEventObserver
|
||||
finish(false)
|
||||
}
|
||||
observer = actualObserver
|
||||
lifecycle.addObserver(actualObserver)
|
||||
cont.invokeOnCancellation {
|
||||
mainHandler.post {
|
||||
finish(null)
|
||||
}
|
||||
}
|
||||
dialog =
|
||||
AlertDialog.Builder(activity)
|
||||
.setTitle("Permission required")
|
||||
.setMessage(buildRationaleMessage(permissions))
|
||||
.setPositiveButton("Continue") { _, _ -> finish(true) }
|
||||
.setNegativeButton("Not now") { _, _ -> finish(false) }
|
||||
.setOnCancelListener { finish(false) }
|
||||
.show()
|
||||
AlertDialog.Builder(activity)
|
||||
.setTitle("Permission required")
|
||||
.setMessage(buildRationaleMessage(permissions))
|
||||
.setPositiveButton("Continue") { _, _ -> cont.resume(true) }
|
||||
.setNegativeButton("Not now") { _, _ -> cont.resume(false) }
|
||||
.setOnCancelListener { cont.resume(false) }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun showSettingsDialog(permissions: List<String>) =
|
||||
withContext(Dispatchers.Main) {
|
||||
if (activity.isFinishing || activity.isDestroyed) return@withContext
|
||||
val lifecycle = activity.lifecycle
|
||||
var dialog: AlertDialog? = null
|
||||
var observer: LifecycleEventObserver? = null
|
||||
val removeObserver = {
|
||||
observer?.let(lifecycle::removeObserver)
|
||||
observer = null
|
||||
private fun showSettingsDialog(permissions: List<String>) {
|
||||
AlertDialog.Builder(activity)
|
||||
.setTitle("Enable permission in Settings")
|
||||
.setMessage(buildSettingsMessage(permissions))
|
||||
.setPositiveButton("Open Settings") { _, _ ->
|
||||
val intent =
|
||||
Intent(
|
||||
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.fromParts("package", activity.packageName, null),
|
||||
)
|
||||
activity.startActivity(intent)
|
||||
}
|
||||
val actualObserver =
|
||||
LifecycleEventObserver { _, event ->
|
||||
if (event != Lifecycle.Event.ON_DESTROY) return@LifecycleEventObserver
|
||||
removeObserver()
|
||||
dialog?.dismiss()
|
||||
}
|
||||
observer = actualObserver
|
||||
lifecycle.addObserver(actualObserver)
|
||||
dialog =
|
||||
AlertDialog.Builder(activity)
|
||||
.setTitle("Enable permission in Settings")
|
||||
.setMessage(buildSettingsMessage(permissions))
|
||||
.setPositiveButton("Open Settings") { _, _ ->
|
||||
if (activity.isFinishing || activity.isDestroyed) return@setPositiveButton
|
||||
val intent =
|
||||
Intent(
|
||||
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.fromParts("package", activity.packageName, null),
|
||||
)
|
||||
activity.startActivity(intent)
|
||||
}
|
||||
.setNegativeButton("Cancel", null)
|
||||
.setOnDismissListener { removeObserver() }
|
||||
.show()
|
||||
}
|
||||
.setNegativeButton("Cancel", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun buildRationaleMessage(permissions: List<String>): String {
|
||||
val labels = permissions.map { permissionLabel(it) }
|
||||
@@ -185,16 +127,7 @@ class PermissionRequester(private val activity: ComponentActivity) {
|
||||
when (permission) {
|
||||
Manifest.permission.CAMERA -> "Camera"
|
||||
Manifest.permission.RECORD_AUDIO -> "Microphone"
|
||||
Manifest.permission.SEND_SMS -> "Send SMS"
|
||||
Manifest.permission.READ_SMS -> "Read SMS"
|
||||
Manifest.permission.READ_CONTACTS -> "Read Contacts"
|
||||
Manifest.permission.WRITE_CONTACTS -> "Write Contacts"
|
||||
Manifest.permission.READ_CALENDAR -> "Read Calendar"
|
||||
Manifest.permission.WRITE_CALENDAR -> "Write Calendar"
|
||||
Manifest.permission.READ_CALL_LOG -> "Read Call Log"
|
||||
Manifest.permission.ACTIVITY_RECOGNITION -> "Motion Activity"
|
||||
Manifest.permission.READ_MEDIA_IMAGES -> "Photos"
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE -> "Photos"
|
||||
Manifest.permission.SEND_SMS -> "SMS"
|
||||
else -> permission
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,17 +26,6 @@ class SecurePrefs(
|
||||
private const val voiceWakeModeKey = "voiceWake.mode"
|
||||
private const val plainPrefsName = "openclaw.node"
|
||||
private const val securePrefsName = "openclaw.node.secure"
|
||||
private const val notificationsForwardingEnabledKey = "notifications.forwarding.enabled"
|
||||
private const val defaultNotificationForwardingEnabled = false
|
||||
private const val notificationsForwardingModeKey = "notifications.forwarding.mode"
|
||||
private const val notificationsForwardingPackagesKey = "notifications.forwarding.packages"
|
||||
private const val notificationsForwardingQuietHoursEnabledKey =
|
||||
"notifications.forwarding.quietHoursEnabled"
|
||||
private const val notificationsForwardingQuietStartKey = "notifications.forwarding.quietStart"
|
||||
private const val notificationsForwardingQuietEndKey = "notifications.forwarding.quietEnd"
|
||||
private const val notificationsForwardingMaxEventsPerMinuteKey =
|
||||
"notifications.forwarding.maxEventsPerMinute"
|
||||
private const val notificationsForwardingSessionKeyKey = "notifications.forwarding.sessionKey"
|
||||
}
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
@@ -107,55 +96,6 @@ class SecurePrefs(
|
||||
MutableStateFlow(plainPrefs.getBoolean("canvas.debugStatusEnabled", false))
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = _canvasDebugStatusEnabled
|
||||
|
||||
private val _notificationForwardingEnabled =
|
||||
MutableStateFlow(plainPrefs.getBoolean(notificationsForwardingEnabledKey, defaultNotificationForwardingEnabled))
|
||||
val notificationForwardingEnabled: StateFlow<Boolean> = _notificationForwardingEnabled
|
||||
|
||||
private val _notificationForwardingMode =
|
||||
MutableStateFlow(
|
||||
NotificationPackageFilterMode.fromRawValue(
|
||||
plainPrefs.getString(notificationsForwardingModeKey, null),
|
||||
),
|
||||
)
|
||||
val notificationForwardingMode: StateFlow<NotificationPackageFilterMode> = _notificationForwardingMode
|
||||
|
||||
private val _notificationForwardingPackages = MutableStateFlow(loadNotificationForwardingPackages())
|
||||
val notificationForwardingPackages: StateFlow<Set<String>> = _notificationForwardingPackages
|
||||
|
||||
private val storedQuietStart =
|
||||
normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietStartKey, "22:00").orEmpty())
|
||||
?: "22:00"
|
||||
private val storedQuietEnd =
|
||||
normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietEndKey, "07:00").orEmpty())
|
||||
?: "07:00"
|
||||
private val storedQuietHoursEnabled =
|
||||
plainPrefs.getBoolean(notificationsForwardingQuietHoursEnabledKey, false) &&
|
||||
normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietStartKey, "22:00").orEmpty()) != null &&
|
||||
normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietEndKey, "07:00").orEmpty()) != null
|
||||
|
||||
private val _notificationForwardingQuietHoursEnabled =
|
||||
MutableStateFlow(storedQuietHoursEnabled)
|
||||
val notificationForwardingQuietHoursEnabled: StateFlow<Boolean> = _notificationForwardingQuietHoursEnabled
|
||||
|
||||
private val _notificationForwardingQuietStart = MutableStateFlow(storedQuietStart)
|
||||
val notificationForwardingQuietStart: StateFlow<String> = _notificationForwardingQuietStart
|
||||
|
||||
private val _notificationForwardingQuietEnd = MutableStateFlow(storedQuietEnd)
|
||||
val notificationForwardingQuietEnd: StateFlow<String> = _notificationForwardingQuietEnd
|
||||
|
||||
private val _notificationForwardingMaxEventsPerMinute =
|
||||
MutableStateFlow(plainPrefs.getInt(notificationsForwardingMaxEventsPerMinuteKey, 20).coerceAtLeast(1))
|
||||
val notificationForwardingMaxEventsPerMinute: StateFlow<Int> = _notificationForwardingMaxEventsPerMinute
|
||||
|
||||
private val _notificationForwardingSessionKey =
|
||||
MutableStateFlow(
|
||||
plainPrefs
|
||||
.getString(notificationsForwardingSessionKeyKey, "")
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() },
|
||||
)
|
||||
val notificationForwardingSessionKey: StateFlow<String?> = _notificationForwardingSessionKey
|
||||
|
||||
private val _wakeWords = MutableStateFlow(loadWakeWords())
|
||||
val wakeWords: StateFlow<List<String>> = _wakeWords
|
||||
|
||||
@@ -245,114 +185,6 @@ class SecurePrefs(
|
||||
_canvasDebugStatusEnabled.value = value
|
||||
}
|
||||
|
||||
internal fun getNotificationForwardingPolicy(appPackageName: String): NotificationForwardingPolicy {
|
||||
val modeRaw = plainPrefs.getString(notificationsForwardingModeKey, null)
|
||||
val mode = NotificationPackageFilterMode.fromRawValue(modeRaw)
|
||||
|
||||
val configuredPackages = loadNotificationForwardingPackages()
|
||||
val normalizedAppPackage = appPackageName.trim()
|
||||
val defaultBlockedPackages =
|
||||
if (normalizedAppPackage.isNotEmpty()) setOf(normalizedAppPackage) else emptySet()
|
||||
|
||||
val packages =
|
||||
when (mode) {
|
||||
NotificationPackageFilterMode.Allowlist -> configuredPackages
|
||||
NotificationPackageFilterMode.Blocklist -> configuredPackages + defaultBlockedPackages
|
||||
}
|
||||
|
||||
val maxEvents = plainPrefs.getInt(notificationsForwardingMaxEventsPerMinuteKey, 20)
|
||||
val quietStart =
|
||||
normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietStartKey, "22:00").orEmpty())
|
||||
?: "22:00"
|
||||
val quietEnd =
|
||||
normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietEndKey, "07:00").orEmpty())
|
||||
?: "07:00"
|
||||
val sessionKey =
|
||||
plainPrefs
|
||||
.getString(notificationsForwardingSessionKeyKey, "")
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
val quietHoursEnabled =
|
||||
plainPrefs.getBoolean(notificationsForwardingQuietHoursEnabledKey, false) &&
|
||||
normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietStartKey, "22:00").orEmpty()) != null &&
|
||||
normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietEndKey, "07:00").orEmpty()) != null
|
||||
|
||||
return NotificationForwardingPolicy(
|
||||
enabled = plainPrefs.getBoolean(notificationsForwardingEnabledKey, defaultNotificationForwardingEnabled),
|
||||
mode = mode,
|
||||
packages = packages,
|
||||
quietHoursEnabled = quietHoursEnabled,
|
||||
quietStart = quietStart,
|
||||
quietEnd = quietEnd,
|
||||
maxEventsPerMinute = maxEvents.coerceAtLeast(1),
|
||||
sessionKey = sessionKey,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun setNotificationForwardingEnabled(value: Boolean) {
|
||||
plainPrefs.edit { putBoolean(notificationsForwardingEnabledKey, value) }
|
||||
_notificationForwardingEnabled.value = value
|
||||
}
|
||||
|
||||
internal fun setNotificationForwardingMode(mode: NotificationPackageFilterMode) {
|
||||
plainPrefs.edit { putString(notificationsForwardingModeKey, mode.rawValue) }
|
||||
_notificationForwardingMode.value = mode
|
||||
}
|
||||
|
||||
internal fun setNotificationForwardingPackages(packages: List<String>) {
|
||||
val sanitized =
|
||||
packages
|
||||
.asSequence()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.toSet()
|
||||
.toList()
|
||||
.sorted()
|
||||
val encoded = JsonArray(sanitized.map { JsonPrimitive(it) }).toString()
|
||||
plainPrefs.edit { putString(notificationsForwardingPackagesKey, encoded) }
|
||||
_notificationForwardingPackages.value = sanitized.toSet()
|
||||
}
|
||||
|
||||
internal fun setNotificationForwardingQuietHours(
|
||||
enabled: Boolean,
|
||||
start: String,
|
||||
end: String,
|
||||
): Boolean {
|
||||
if (!enabled) {
|
||||
plainPrefs.edit { putBoolean(notificationsForwardingQuietHoursEnabledKey, false) }
|
||||
_notificationForwardingQuietHoursEnabled.value = false
|
||||
return true
|
||||
}
|
||||
val normalizedStart = normalizeLocalHourMinute(start) ?: return false
|
||||
val normalizedEnd = normalizeLocalHourMinute(end) ?: return false
|
||||
plainPrefs.edit {
|
||||
putBoolean(notificationsForwardingQuietHoursEnabledKey, enabled)
|
||||
putString(notificationsForwardingQuietStartKey, normalizedStart)
|
||||
putString(notificationsForwardingQuietEndKey, normalizedEnd)
|
||||
}
|
||||
_notificationForwardingQuietHoursEnabled.value = enabled
|
||||
_notificationForwardingQuietStart.value = normalizedStart
|
||||
_notificationForwardingQuietEnd.value = normalizedEnd
|
||||
return true
|
||||
}
|
||||
|
||||
internal fun setNotificationForwardingMaxEventsPerMinute(value: Int) {
|
||||
val normalized = value.coerceAtLeast(1)
|
||||
plainPrefs.edit {
|
||||
putInt(notificationsForwardingMaxEventsPerMinuteKey, normalized)
|
||||
}
|
||||
_notificationForwardingMaxEventsPerMinute.value = normalized
|
||||
}
|
||||
|
||||
internal fun setNotificationForwardingSessionKey(value: String?) {
|
||||
val normalized = value?.trim()?.takeIf { it.isNotEmpty() }
|
||||
plainPrefs.edit {
|
||||
putString(notificationsForwardingSessionKeyKey, normalized.orEmpty())
|
||||
}
|
||||
_notificationForwardingSessionKey.value = normalized
|
||||
}
|
||||
|
||||
fun loadGatewayToken(): String? {
|
||||
val manual =
|
||||
_gatewayToken.value.trim().ifEmpty {
|
||||
@@ -476,28 +308,6 @@ class SecurePrefs(
|
||||
_speakerEnabled.value = value
|
||||
}
|
||||
|
||||
private fun loadNotificationForwardingPackages(): Set<String> {
|
||||
val raw = plainPrefs.getString(notificationsForwardingPackagesKey, null)?.trim()
|
||||
if (raw.isNullOrEmpty()) {
|
||||
return emptySet()
|
||||
}
|
||||
return try {
|
||||
val element = json.parseToJsonElement(raw)
|
||||
val array = element as? JsonArray ?: return emptySet()
|
||||
array
|
||||
.mapNotNull { item ->
|
||||
when (item) {
|
||||
is JsonNull -> null
|
||||
is JsonPrimitive -> item.content.trim().takeIf { it.isNotEmpty() }
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
.toSet()
|
||||
} catch (_: Throwable) {
|
||||
emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadVoiceWakeMode(): VoiceWakeMode {
|
||||
val raw = plainPrefs.getString(voiceWakeModeKey, null)
|
||||
val resolved = VoiceWakeMode.fromRawValue(raw)
|
||||
|
||||
@@ -11,14 +11,3 @@ internal fun isCanonicalMainSessionKey(raw: String?): Boolean {
|
||||
if (trimmed == "global") return true
|
||||
return trimmed.startsWith("agent:")
|
||||
}
|
||||
|
||||
internal fun resolveAgentIdFromMainSessionKey(raw: String?): String? {
|
||||
val trimmed = raw?.trim().orEmpty()
|
||||
if (!trimmed.startsWith("agent:")) return null
|
||||
return trimmed.removePrefix("agent:").substringBefore(':').trim().ifEmpty { null }
|
||||
}
|
||||
|
||||
internal fun buildNodeMainSessionKey(deviceId: String, agentId: String?): String {
|
||||
val resolvedAgentId = agentId?.trim().orEmpty().ifEmpty { "main" }
|
||||
return "agent:$resolvedAgentId:node-${deviceId.take(12)}"
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ class ChatController(
|
||||
private val json: Json,
|
||||
private val supportsChatSubscribe: Boolean,
|
||||
) {
|
||||
private var appliedMainSessionKey = "main"
|
||||
private val _sessionKey = MutableStateFlow("main")
|
||||
val sessionKey: StateFlow<String> = _sessionKey.asStateFlow()
|
||||
|
||||
@@ -74,28 +73,22 @@ class ChatController(
|
||||
}
|
||||
|
||||
fun load(sessionKey: String) {
|
||||
val key = normalizeRequestedSessionKey(sessionKey)
|
||||
val key = sessionKey.trim().ifEmpty { "main" }
|
||||
_sessionKey.value = key
|
||||
scope.launch { bootstrap(forceHealth = true, refreshSessions = true) }
|
||||
scope.launch { bootstrap(forceHealth = true) }
|
||||
}
|
||||
|
||||
fun applyMainSessionKey(mainSessionKey: String) {
|
||||
val trimmed = mainSessionKey.trim()
|
||||
if (trimmed.isEmpty()) return
|
||||
val nextState =
|
||||
applyMainSessionKey(
|
||||
currentSessionKey = normalizeRequestedSessionKey(_sessionKey.value),
|
||||
appliedMainSessionKey = appliedMainSessionKey,
|
||||
nextMainSessionKey = trimmed,
|
||||
)
|
||||
appliedMainSessionKey = nextState.appliedMainSessionKey
|
||||
if (_sessionKey.value == nextState.currentSessionKey) return
|
||||
_sessionKey.value = nextState.currentSessionKey
|
||||
scope.launch { bootstrap(forceHealth = true, refreshSessions = true) }
|
||||
if (_sessionKey.value == trimmed) return
|
||||
if (_sessionKey.value != "main") return
|
||||
_sessionKey.value = trimmed
|
||||
scope.launch { bootstrap(forceHealth = true) }
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
scope.launch { bootstrap(forceHealth = true, refreshSessions = true) }
|
||||
scope.launch { bootstrap(forceHealth = true) }
|
||||
}
|
||||
|
||||
fun refreshSessions(limit: Int? = null) {
|
||||
@@ -109,20 +102,11 @@ class ChatController(
|
||||
}
|
||||
|
||||
fun switchSession(sessionKey: String) {
|
||||
val key = normalizeRequestedSessionKey(sessionKey)
|
||||
val key = sessionKey.trim()
|
||||
if (key.isEmpty()) return
|
||||
if (key == _sessionKey.value) return
|
||||
_sessionKey.value = key
|
||||
// Keep the thread switch path lean: history + health are needed immediately,
|
||||
// but the session list is usually unchanged and can refresh on explicit pull-to-refresh.
|
||||
scope.launch { bootstrap(forceHealth = true, refreshSessions = false) }
|
||||
}
|
||||
|
||||
private fun normalizeRequestedSessionKey(sessionKey: String): String {
|
||||
val key = sessionKey.trim()
|
||||
if (key.isEmpty()) return appliedMainSessionKey
|
||||
if (key == "main" && appliedMainSessionKey != "main") return appliedMainSessionKey
|
||||
return key
|
||||
scope.launch { bootstrap(forceHealth = true) }
|
||||
}
|
||||
|
||||
fun sendMessage(
|
||||
@@ -130,25 +114,11 @@ class ChatController(
|
||||
thinkingLevel: String,
|
||||
attachments: List<OutgoingAttachment>,
|
||||
) {
|
||||
scope.launch {
|
||||
sendMessageAwaitAcceptance(
|
||||
message = message,
|
||||
thinkingLevel = thinkingLevel,
|
||||
attachments = attachments,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendMessageAwaitAcceptance(
|
||||
message: String,
|
||||
thinkingLevel: String,
|
||||
attachments: List<OutgoingAttachment>,
|
||||
): Boolean {
|
||||
val trimmed = message.trim()
|
||||
if (trimmed.isEmpty() && attachments.isEmpty()) return false
|
||||
if (trimmed.isEmpty() && attachments.isEmpty()) return
|
||||
if (!_healthOk.value) {
|
||||
_errorText.value = "Gateway health not OK; cannot send"
|
||||
return false
|
||||
return
|
||||
}
|
||||
|
||||
val runId = UUID.randomUUID().toString()
|
||||
@@ -191,45 +161,45 @@ class ChatController(
|
||||
pendingToolCallsById.clear()
|
||||
publishPendingToolCalls()
|
||||
|
||||
return try {
|
||||
val params =
|
||||
buildJsonObject {
|
||||
put("sessionKey", JsonPrimitive(sessionKey))
|
||||
put("message", JsonPrimitive(text))
|
||||
put("thinking", JsonPrimitive(thinking))
|
||||
put("timeoutMs", JsonPrimitive(30_000))
|
||||
put("idempotencyKey", JsonPrimitive(runId))
|
||||
if (attachments.isNotEmpty()) {
|
||||
put(
|
||||
"attachments",
|
||||
JsonArray(
|
||||
attachments.map { att ->
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive(att.type))
|
||||
put("mimeType", JsonPrimitive(att.mimeType))
|
||||
put("fileName", JsonPrimitive(att.fileName))
|
||||
put("content", JsonPrimitive(att.base64))
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
scope.launch {
|
||||
try {
|
||||
val params =
|
||||
buildJsonObject {
|
||||
put("sessionKey", JsonPrimitive(sessionKey))
|
||||
put("message", JsonPrimitive(text))
|
||||
put("thinking", JsonPrimitive(thinking))
|
||||
put("timeoutMs", JsonPrimitive(30_000))
|
||||
put("idempotencyKey", JsonPrimitive(runId))
|
||||
if (attachments.isNotEmpty()) {
|
||||
put(
|
||||
"attachments",
|
||||
JsonArray(
|
||||
attachments.map { att ->
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive(att.type))
|
||||
put("mimeType", JsonPrimitive(att.mimeType))
|
||||
put("fileName", JsonPrimitive(att.fileName))
|
||||
put("content", JsonPrimitive(att.base64))
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
val res = session.request("chat.send", params.toString())
|
||||
val actualRunId = parseRunId(res) ?: runId
|
||||
if (actualRunId != runId) {
|
||||
clearPendingRun(runId)
|
||||
armPendingRunTimeout(actualRunId)
|
||||
synchronized(pendingRuns) {
|
||||
pendingRuns.add(actualRunId)
|
||||
_pendingRunCount.value = pendingRuns.size
|
||||
}
|
||||
}
|
||||
val res = session.request("chat.send", params.toString())
|
||||
val actualRunId = parseRunId(res) ?: runId
|
||||
if (actualRunId != runId) {
|
||||
} catch (err: Throwable) {
|
||||
clearPendingRun(runId)
|
||||
armPendingRunTimeout(actualRunId)
|
||||
synchronized(pendingRuns) {
|
||||
pendingRuns.add(actualRunId)
|
||||
_pendingRunCount.value = pendingRuns.size
|
||||
}
|
||||
_errorText.value = err.message
|
||||
}
|
||||
true
|
||||
} catch (err: Throwable) {
|
||||
clearPendingRun(runId)
|
||||
_errorText.value = err.message
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,7 +249,7 @@ class ChatController(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun bootstrap(forceHealth: Boolean, refreshSessions: Boolean) {
|
||||
private suspend fun bootstrap(forceHealth: Boolean) {
|
||||
_errorText.value = null
|
||||
_healthOk.value = false
|
||||
clearPendingRuns()
|
||||
@@ -295,15 +265,13 @@ class ChatController(
|
||||
}
|
||||
|
||||
val historyJson = session.request("chat.history", """{"sessionKey":"$key"}""")
|
||||
val history = parseHistory(historyJson, sessionKey = key, previousMessages = _messages.value)
|
||||
val history = parseHistory(historyJson, sessionKey = key)
|
||||
_messages.value = history.messages
|
||||
_sessionId.value = history.sessionId
|
||||
history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it }
|
||||
|
||||
pollHealthIfNeeded(force = forceHealth)
|
||||
if (refreshSessions) {
|
||||
fetchSessions(limit = 50)
|
||||
}
|
||||
fetchSessions(limit = 50)
|
||||
} catch (err: Throwable) {
|
||||
_errorText.value = err.message
|
||||
}
|
||||
@@ -368,7 +336,7 @@ class ChatController(
|
||||
try {
|
||||
val historyJson =
|
||||
session.request("chat.history", """{"sessionKey":"${_sessionKey.value}"}""")
|
||||
val history = parseHistory(historyJson, sessionKey = _sessionKey.value, previousMessages = _messages.value)
|
||||
val history = parseHistory(historyJson, sessionKey = _sessionKey.value)
|
||||
_messages.value = history.messages
|
||||
_sessionId.value = history.sessionId
|
||||
history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it }
|
||||
@@ -482,11 +450,7 @@ class ChatController(
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseHistory(
|
||||
historyJson: String,
|
||||
sessionKey: String,
|
||||
previousMessages: List<ChatMessage>,
|
||||
): ChatHistory {
|
||||
private fun parseHistory(historyJson: String, sessionKey: String): ChatHistory {
|
||||
val root = json.parseToJsonElement(historyJson).asObjectOrNull() ?: return ChatHistory(sessionKey, null, null, emptyList())
|
||||
val sid = root["sessionId"].asStringOrNull()
|
||||
val thinkingLevel = root["thinkingLevel"].asStringOrNull()
|
||||
@@ -506,12 +470,7 @@ class ChatController(
|
||||
)
|
||||
}
|
||||
|
||||
return ChatHistory(
|
||||
sessionKey = sessionKey,
|
||||
sessionId = sid,
|
||||
thinkingLevel = thinkingLevel,
|
||||
messages = reconcileMessageIds(previous = previousMessages, incoming = messages),
|
||||
)
|
||||
return ChatHistory(sessionKey = sessionKey, sessionId = sid, thinkingLevel = thinkingLevel, messages = messages)
|
||||
}
|
||||
|
||||
private fun parseMessageContent(el: JsonElement): ChatMessageContent? {
|
||||
@@ -560,69 +519,6 @@ class ChatController(
|
||||
}
|
||||
}
|
||||
|
||||
internal data class MainSessionState(
|
||||
val currentSessionKey: String,
|
||||
val appliedMainSessionKey: String,
|
||||
)
|
||||
|
||||
internal fun applyMainSessionKey(
|
||||
currentSessionKey: String,
|
||||
appliedMainSessionKey: String,
|
||||
nextMainSessionKey: String,
|
||||
): MainSessionState {
|
||||
if (currentSessionKey == appliedMainSessionKey) {
|
||||
return MainSessionState(
|
||||
currentSessionKey = nextMainSessionKey,
|
||||
appliedMainSessionKey = nextMainSessionKey,
|
||||
)
|
||||
}
|
||||
return MainSessionState(
|
||||
currentSessionKey = currentSessionKey,
|
||||
appliedMainSessionKey = nextMainSessionKey,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun reconcileMessageIds(previous: List<ChatMessage>, incoming: List<ChatMessage>): List<ChatMessage> {
|
||||
if (previous.isEmpty() || incoming.isEmpty()) return incoming
|
||||
|
||||
val idsByKey = LinkedHashMap<String, ArrayDeque<String>>()
|
||||
for (message in previous) {
|
||||
val key = messageIdentityKey(message) ?: continue
|
||||
idsByKey.getOrPut(key) { ArrayDeque() }.addLast(message.id)
|
||||
}
|
||||
|
||||
return incoming.map { message ->
|
||||
val key = messageIdentityKey(message) ?: return@map message
|
||||
val ids = idsByKey[key] ?: return@map message
|
||||
val reusedId = ids.removeFirstOrNull() ?: return@map message
|
||||
if (ids.isEmpty()) {
|
||||
idsByKey.remove(key)
|
||||
}
|
||||
if (reusedId == message.id) return@map message
|
||||
message.copy(id = reusedId)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun messageIdentityKey(message: ChatMessage): String? {
|
||||
val role = message.role.trim().lowercase()
|
||||
if (role.isEmpty()) return null
|
||||
|
||||
val timestamp = message.timestampMs?.toString().orEmpty()
|
||||
val contentFingerprint =
|
||||
message.content.joinToString(separator = "\u001E") { part ->
|
||||
listOf(
|
||||
part.type.trim().lowercase(),
|
||||
part.text?.trim().orEmpty(),
|
||||
part.mimeType?.trim()?.lowercase().orEmpty(),
|
||||
part.fileName?.trim().orEmpty(),
|
||||
part.base64?.hashCode()?.toString().orEmpty(),
|
||||
).joinToString(separator = "\u001F")
|
||||
}
|
||||
|
||||
if (timestamp.isEmpty() && contentFingerprint.isEmpty()) return null
|
||||
return listOf(role, timestamp, contentFingerprint).joinToString(separator = "|")
|
||||
}
|
||||
|
||||
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
|
||||
private fun JsonElement?.asArrayOrNull(): JsonArray? = this as? JsonArray
|
||||
|
||||
@@ -1,92 +1,32 @@
|
||||
package ai.openclaw.app.gateway
|
||||
|
||||
import ai.openclaw.app.SecurePrefs
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
data class DeviceAuthEntry(
|
||||
val token: String,
|
||||
val role: String,
|
||||
val scopes: List<String>,
|
||||
val updatedAtMs: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class PersistedDeviceAuthMetadata(
|
||||
val scopes: List<String> = emptyList(),
|
||||
val updatedAtMs: Long = 0L,
|
||||
)
|
||||
|
||||
interface DeviceAuthTokenStore {
|
||||
fun loadEntry(deviceId: String, role: String): DeviceAuthEntry?
|
||||
fun loadToken(deviceId: String, role: String): String? = loadEntry(deviceId, role)?.token
|
||||
fun saveToken(deviceId: String, role: String, token: String, scopes: List<String> = emptyList())
|
||||
fun loadToken(deviceId: String, role: String): String?
|
||||
fun saveToken(deviceId: String, role: String, token: String)
|
||||
fun clearToken(deviceId: String, role: String)
|
||||
}
|
||||
|
||||
class DeviceAuthStore(private val prefs: SecurePrefs) : DeviceAuthTokenStore {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
override fun loadEntry(deviceId: String, role: String): DeviceAuthEntry? {
|
||||
override fun loadToken(deviceId: String, role: String): String? {
|
||||
val key = tokenKey(deviceId, role)
|
||||
val token = prefs.getString(key)?.trim()?.takeIf { it.isNotEmpty() } ?: return null
|
||||
val normalizedRole = normalizeRole(role)
|
||||
val metadata =
|
||||
prefs.getString(metadataKey(deviceId, role))
|
||||
?.let { raw ->
|
||||
runCatching { json.decodeFromString<PersistedDeviceAuthMetadata>(raw) }.getOrNull()
|
||||
}
|
||||
return DeviceAuthEntry(
|
||||
token = token,
|
||||
role = normalizedRole,
|
||||
scopes = metadata?.scopes ?: emptyList(),
|
||||
updatedAtMs = metadata?.updatedAtMs ?: 0L,
|
||||
)
|
||||
return prefs.getString(key)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
override fun saveToken(deviceId: String, role: String, token: String, scopes: List<String>) {
|
||||
val normalizedScopes = normalizeScopes(scopes)
|
||||
override fun saveToken(deviceId: String, role: String, token: String) {
|
||||
val key = tokenKey(deviceId, role)
|
||||
prefs.putString(key, token.trim())
|
||||
prefs.putString(
|
||||
metadataKey(deviceId, role),
|
||||
json.encodeToString(
|
||||
PersistedDeviceAuthMetadata(
|
||||
scopes = normalizedScopes,
|
||||
updatedAtMs = System.currentTimeMillis(),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun clearToken(deviceId: String, role: String) {
|
||||
val key = tokenKey(deviceId, role)
|
||||
prefs.remove(key)
|
||||
prefs.remove(metadataKey(deviceId, role))
|
||||
}
|
||||
|
||||
private fun tokenKey(deviceId: String, role: String): String {
|
||||
val normalizedDevice = normalizeDeviceId(deviceId)
|
||||
val normalizedRole = normalizeRole(role)
|
||||
val normalizedDevice = deviceId.trim().lowercase()
|
||||
val normalizedRole = role.trim().lowercase()
|
||||
return "gateway.deviceToken.$normalizedDevice.$normalizedRole"
|
||||
}
|
||||
|
||||
private fun metadataKey(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()
|
||||
|
||||
private fun normalizeRole(role: String): String = role.trim().lowercase()
|
||||
|
||||
private fun normalizeScopes(scopes: List<String>): List<String> {
|
||||
return scopes
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.distinct()
|
||||
.sorted()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
package ai.openclaw.app.gateway
|
||||
|
||||
import android.os.Build
|
||||
import java.net.InetAddress
|
||||
import java.util.Locale
|
||||
|
||||
internal fun isLoopbackGatewayHost(
|
||||
rawHost: String?,
|
||||
allowEmulatorBridgeAlias: Boolean = isAndroidEmulatorRuntime(),
|
||||
): Boolean {
|
||||
var host =
|
||||
rawHost
|
||||
?.trim()
|
||||
?.lowercase(Locale.US)
|
||||
?.trim('[', ']')
|
||||
.orEmpty()
|
||||
if (host.endsWith(".")) {
|
||||
host = host.dropLast(1)
|
||||
}
|
||||
val zoneIndex = host.indexOf('%')
|
||||
if (zoneIndex >= 0) return false
|
||||
if (host.isEmpty()) return false
|
||||
if (host == "localhost") return true
|
||||
if (allowEmulatorBridgeAlias && host == "10.0.2.2") return true
|
||||
|
||||
parseIpv4Address(host)?.let { ipv4 ->
|
||||
return ipv4.first() == 127.toByte()
|
||||
}
|
||||
if (!host.contains(':') || !host.all(::isIpv6LiteralChar)) return false
|
||||
|
||||
val address = runCatching { InetAddress.getByName(host) }.getOrNull()?.address ?: return false
|
||||
if (address.size == 4) {
|
||||
return address[0] == 127.toByte()
|
||||
}
|
||||
if (address.size != 16) return false
|
||||
// `::1` is 15 zero bytes followed by `0x01`.
|
||||
val isIpv6Loopback = address.copyOfRange(0, 15).all { it == 0.toByte() } && address[15] == 1.toByte()
|
||||
if (isIpv6Loopback) return true
|
||||
|
||||
val isMappedIpv4 =
|
||||
address.copyOfRange(0, 10).all { it == 0.toByte() } &&
|
||||
address[10] == 0xFF.toByte() &&
|
||||
address[11] == 0xFF.toByte()
|
||||
return isMappedIpv4 && address[12] == 127.toByte()
|
||||
}
|
||||
|
||||
internal fun isPrivateLanGatewayHost(
|
||||
rawHost: String?,
|
||||
allowEmulatorBridgeAlias: Boolean = isAndroidEmulatorRuntime(),
|
||||
): Boolean {
|
||||
var host =
|
||||
rawHost
|
||||
?.trim()
|
||||
?.lowercase(Locale.US)
|
||||
?.trim('[', ']')
|
||||
.orEmpty()
|
||||
if (host.endsWith(".")) {
|
||||
host = host.dropLast(1)
|
||||
}
|
||||
val zoneIndex = host.indexOf('%')
|
||||
if (zoneIndex >= 0) {
|
||||
host = host.substring(0, zoneIndex)
|
||||
}
|
||||
if (host.isEmpty()) return false
|
||||
if (isLoopbackGatewayHost(host, allowEmulatorBridgeAlias = allowEmulatorBridgeAlias)) return true
|
||||
if (host.endsWith(".local")) return true
|
||||
if (!host.contains('.') && !host.contains(':')) return true
|
||||
|
||||
parseIpv4Address(host)?.let { ipv4 ->
|
||||
val first = ipv4[0].toInt() and 0xff
|
||||
val second = ipv4[1].toInt() and 0xff
|
||||
return when {
|
||||
first == 10 -> true
|
||||
first == 172 && second in 16..31 -> true
|
||||
first == 192 && second == 168 -> true
|
||||
first == 169 && second == 254 -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
if (!host.contains(':') || !host.all(::isIpv6LiteralChar)) return false
|
||||
|
||||
val address = runCatching { InetAddress.getByName(host) }.getOrNull() ?: return false
|
||||
return when {
|
||||
address.isLinkLocalAddress -> true
|
||||
address.isSiteLocalAddress -> true
|
||||
else -> {
|
||||
val bytes = address.address
|
||||
bytes.size == 16 && (bytes[0].toInt() and 0xfe) == 0xfc
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isAndroidEmulatorRuntime(): Boolean {
|
||||
val fingerprint = Build.FINGERPRINT?.lowercase(Locale.US).orEmpty()
|
||||
val model = Build.MODEL?.lowercase(Locale.US).orEmpty()
|
||||
val manufacturer = Build.MANUFACTURER?.lowercase(Locale.US).orEmpty()
|
||||
val brand = Build.BRAND?.lowercase(Locale.US).orEmpty()
|
||||
val device = Build.DEVICE?.lowercase(Locale.US).orEmpty()
|
||||
val product = Build.PRODUCT?.lowercase(Locale.US).orEmpty()
|
||||
|
||||
return fingerprint.contains("generic") ||
|
||||
fingerprint.contains("robolectric") ||
|
||||
model.contains("emulator") ||
|
||||
model.contains("sdk_gphone") ||
|
||||
manufacturer.contains("genymotion") ||
|
||||
(brand.contains("generic") && device.contains("generic")) ||
|
||||
product.contains("sdk_gphone") ||
|
||||
product.contains("emulator") ||
|
||||
product.contains("simulator")
|
||||
}
|
||||
|
||||
private fun parseIpv4Address(host: String): ByteArray? {
|
||||
val parts = host.split('.')
|
||||
if (parts.size != 4) return null
|
||||
val bytes = ByteArray(4)
|
||||
for ((index, part) in parts.withIndex()) {
|
||||
val value = part.toIntOrNull() ?: return null
|
||||
if (value !in 0..255) return null
|
||||
bytes[index] = value.toByte()
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
private fun isIpv6LiteralChar(char: Char): Boolean = char in '0'..'9' || char in 'a'..'f' || char == ':' || char == '.'
|
||||
@@ -64,7 +64,6 @@ data class GatewayConnectErrorDetails(
|
||||
val code: String?,
|
||||
val canRetryWithDeviceToken: Boolean,
|
||||
val recommendedNextStep: String?,
|
||||
val reason: String? = null,
|
||||
)
|
||||
|
||||
private data class SelectedConnectAuth(
|
||||
@@ -117,8 +116,6 @@ class GatewaySession(
|
||||
val details: GatewayConnectErrorDetails? = null,
|
||||
)
|
||||
|
||||
data class RpcResult(val ok: Boolean, val payloadJson: String?, val error: ErrorShape?)
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val writeLock = Mutex()
|
||||
private val pending = ConcurrentHashMap<String, CompletableDeferred<RpcResponse>>()
|
||||
@@ -184,10 +181,17 @@ class GatewaySession(
|
||||
|
||||
suspend fun sendNodeEvent(event: String, payloadJson: String?): Boolean {
|
||||
val conn = currentConnection ?: return false
|
||||
val parsedPayload = payloadJson?.let { parseJsonOrNull(it) }
|
||||
val params =
|
||||
buildJsonObject {
|
||||
put("event", JsonPrimitive(event))
|
||||
put("payloadJSON", JsonPrimitive(payloadJson ?: "{}"))
|
||||
if (parsedPayload != null) {
|
||||
put("payload", parsedPayload)
|
||||
} else if (payloadJson != null) {
|
||||
put("payloadJSON", JsonPrimitive(payloadJson))
|
||||
} else {
|
||||
put("payloadJSON", JsonNull)
|
||||
}
|
||||
}
|
||||
try {
|
||||
conn.request("node.event", params, timeoutMs = 8_000)
|
||||
@@ -199,13 +203,6 @@ class GatewaySession(
|
||||
}
|
||||
|
||||
suspend fun request(method: String, paramsJson: String?, timeoutMs: Long = 15_000): String {
|
||||
val res = requestDetailed(method = method, paramsJson = paramsJson, timeoutMs = timeoutMs)
|
||||
if (res.ok) return res.payloadJson ?: ""
|
||||
val err = res.error
|
||||
throw IllegalStateException("${err?.code ?: "UNAVAILABLE"}: ${err?.message ?: "request failed"}")
|
||||
}
|
||||
|
||||
suspend fun requestDetailed(method: String, paramsJson: String?, timeoutMs: Long = 15_000): RpcResult {
|
||||
val conn = currentConnection ?: throw IllegalStateException("not connected")
|
||||
val params =
|
||||
if (paramsJson.isNullOrBlank()) {
|
||||
@@ -214,7 +211,9 @@ class GatewaySession(
|
||||
json.parseToJsonElement(paramsJson)
|
||||
}
|
||||
val res = conn.request(method, params, timeoutMs)
|
||||
return RpcResult(ok = res.ok, payloadJson = res.payloadJson, error = res.error)
|
||||
if (res.ok) return res.payloadJson ?: ""
|
||||
val err = res.error
|
||||
throw IllegalStateException("${err?.code ?: "UNAVAILABLE"}: ${err?.message ?: "request failed"}")
|
||||
}
|
||||
|
||||
suspend fun refreshNodeCanvasCapability(timeoutMs: Long = 8_000): Boolean {
|
||||
@@ -276,10 +275,16 @@ class GatewaySession(
|
||||
private var socket: WebSocket? = null
|
||||
private val loggerTag = "OpenClawGateway"
|
||||
|
||||
val remoteAddress: String = formatGatewayAuthority(endpoint.host, endpoint.port)
|
||||
val remoteAddress: String =
|
||||
if (endpoint.host.contains(":")) {
|
||||
"[${endpoint.host}]:${endpoint.port}"
|
||||
} else {
|
||||
"${endpoint.host}:${endpoint.port}"
|
||||
}
|
||||
|
||||
suspend fun connect() {
|
||||
val url = buildGatewayWebSocketUrl(endpoint.host, endpoint.port, tls != null)
|
||||
val scheme = if (tls != null) "wss" else "ws"
|
||||
val url = "$scheme://${endpoint.host}:${endpoint.port}"
|
||||
val request = Request.Builder().url(url).build()
|
||||
socket = client.newWebSocket(request, Listener())
|
||||
try {
|
||||
@@ -426,63 +431,11 @@ class GatewaySession(
|
||||
}
|
||||
throw GatewayConnectFailure(error)
|
||||
}
|
||||
handleConnectSuccess(res, identity.deviceId, selectedAuth.authSource)
|
||||
handleConnectSuccess(res, identity.deviceId)
|
||||
connectDeferred.complete(Unit)
|
||||
}
|
||||
|
||||
private fun shouldPersistBootstrapHandoffTokens(authSource: GatewayConnectAuthSource): Boolean {
|
||||
if (authSource != GatewayConnectAuthSource.BOOTSTRAP_TOKEN) return false
|
||||
if (isLoopbackGatewayHost(endpoint.host)) return true
|
||||
return tls != null
|
||||
}
|
||||
|
||||
private fun filteredBootstrapHandoffScopes(role: String, scopes: List<String>): List<String>? {
|
||||
return when (role.trim()) {
|
||||
"node" -> emptyList()
|
||||
"operator" -> {
|
||||
val allowedOperatorScopes =
|
||||
setOf(
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
)
|
||||
scopes.filter { allowedOperatorScopes.contains(it) }.distinct().sorted()
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun persistBootstrapHandoffToken(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
token: String,
|
||||
scopes: List<String>,
|
||||
) {
|
||||
val filteredScopes = filteredBootstrapHandoffScopes(role, scopes) ?: return
|
||||
deviceAuthStore.saveToken(deviceId, role, token, filteredScopes)
|
||||
}
|
||||
|
||||
private fun persistIssuedDeviceToken(
|
||||
authSource: GatewayConnectAuthSource,
|
||||
deviceId: String,
|
||||
role: String,
|
||||
token: String,
|
||||
scopes: List<String>,
|
||||
) {
|
||||
if (authSource == GatewayConnectAuthSource.BOOTSTRAP_TOKEN) {
|
||||
if (!shouldPersistBootstrapHandoffTokens(authSource)) return
|
||||
persistBootstrapHandoffToken(deviceId, role, token, scopes)
|
||||
return
|
||||
}
|
||||
deviceAuthStore.saveToken(deviceId, role, token, scopes)
|
||||
}
|
||||
|
||||
private fun handleConnectSuccess(
|
||||
res: RpcResponse,
|
||||
deviceId: String,
|
||||
authSource: GatewayConnectAuthSource,
|
||||
) {
|
||||
private fun handleConnectSuccess(res: RpcResponse, deviceId: String) {
|
||||
val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload")
|
||||
val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: throw IllegalStateException("connect failed")
|
||||
pendingDeviceTokenRetry = false
|
||||
@@ -492,27 +445,8 @@ class GatewaySession(
|
||||
val authObj = obj["auth"].asObjectOrNull()
|
||||
val deviceToken = authObj?.get("deviceToken").asStringOrNull()
|
||||
val authRole = authObj?.get("role").asStringOrNull() ?: options.role
|
||||
val authScopes =
|
||||
authObj?.get("scopes").asArrayOrNull()
|
||||
?.mapNotNull { it.asStringOrNull() }
|
||||
?: emptyList()
|
||||
if (!deviceToken.isNullOrBlank()) {
|
||||
persistIssuedDeviceToken(authSource, deviceId, authRole, deviceToken, authScopes)
|
||||
}
|
||||
if (shouldPersistBootstrapHandoffTokens(authSource)) {
|
||||
authObj?.get("deviceTokens").asArrayOrNull()
|
||||
?.mapNotNull { it.asObjectOrNull() }
|
||||
?.forEach { tokenEntry ->
|
||||
val handoffToken = tokenEntry["deviceToken"].asStringOrNull()
|
||||
val handoffRole = tokenEntry["role"].asStringOrNull()
|
||||
val handoffScopes =
|
||||
tokenEntry["scopes"].asArrayOrNull()
|
||||
?.mapNotNull { it.asStringOrNull() }
|
||||
?: emptyList()
|
||||
if (!handoffToken.isNullOrBlank() && !handoffRole.isNullOrBlank()) {
|
||||
persistBootstrapHandoffToken(deviceId, handoffRole, handoffToken, handoffScopes)
|
||||
}
|
||||
}
|
||||
deviceAuthStore.saveToken(deviceId, authRole, deviceToken)
|
||||
}
|
||||
val rawCanvas = obj["canvasHostUrl"].asStringOrNull()
|
||||
canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint, isTlsConnection = tls != null)
|
||||
@@ -639,7 +573,6 @@ class GatewaySession(
|
||||
code = it["code"].asStringOrNull(),
|
||||
canRetryWithDeviceToken = it["canRetryWithDeviceToken"].asBooleanOrNull() == true,
|
||||
recommendedNextStep = it["recommendedNextStep"].asStringOrNull(),
|
||||
reason = it["reason"].asStringOrNull(),
|
||||
)
|
||||
}
|
||||
ErrorShape(code, msg, details)
|
||||
@@ -826,7 +759,7 @@ class GatewaySession(
|
||||
|
||||
// If raw URL is a non-loopback address and this connection uses TLS,
|
||||
// normalize scheme/port to the endpoint we actually connected to.
|
||||
if (trimmed.isNotBlank() && host.isNotBlank() && !isLoopbackGatewayHost(host)) {
|
||||
if (trimmed.isNotBlank() && host.isNotBlank() && !isLoopbackHost(host)) {
|
||||
val needsTlsRewrite =
|
||||
isTlsConnection &&
|
||||
(
|
||||
@@ -855,7 +788,7 @@ class GatewaySession(
|
||||
|
||||
private fun buildCanvasUrl(host: String, scheme: String, port: Int, suffix: String): String {
|
||||
val loweredScheme = scheme.lowercase()
|
||||
val formattedHost = formatGatewayAuthorityHost(host)
|
||||
val formattedHost = if (host.contains(":")) "[${host}]" else host
|
||||
val portSuffix = if ((loweredScheme == "https" && port == 443) || (loweredScheme == "http" && port == 80)) "" else ":$port"
|
||||
return "$loweredScheme://$formattedHost$portSuffix$suffix"
|
||||
}
|
||||
@@ -868,6 +801,15 @@ class GatewaySession(
|
||||
return "$path$query$fragment"
|
||||
}
|
||||
|
||||
private fun isLoopbackHost(raw: String?): Boolean {
|
||||
val host = raw?.trim()?.lowercase().orEmpty()
|
||||
if (host.isEmpty()) return false
|
||||
if (host == "localhost") return true
|
||||
if (host == "::1") return true
|
||||
if (host == "0.0.0.0" || host == "::") return true
|
||||
return host.startsWith("127.")
|
||||
}
|
||||
|
||||
private fun selectConnectAuth(
|
||||
endpoint: GatewayEndpoint,
|
||||
tls: GatewayTlsParams?,
|
||||
@@ -956,31 +898,15 @@ class GatewaySession(
|
||||
endpoint: GatewayEndpoint,
|
||||
tls: GatewayTlsParams?,
|
||||
): Boolean {
|
||||
if (isLoopbackGatewayHost(endpoint.host)) {
|
||||
if (isLoopbackHost(endpoint.host)) {
|
||||
return true
|
||||
}
|
||||
return tls?.expectedFingerprint?.trim()?.isNotEmpty() == true
|
||||
}
|
||||
}
|
||||
|
||||
internal fun buildGatewayWebSocketUrl(host: String, port: Int, useTls: Boolean): String {
|
||||
val scheme = if (useTls) "wss" else "ws"
|
||||
return "$scheme://${formatGatewayAuthority(host, port)}"
|
||||
}
|
||||
|
||||
internal fun formatGatewayAuthority(host: String, port: Int): String {
|
||||
return "${formatGatewayAuthorityHost(host)}:$port"
|
||||
}
|
||||
|
||||
private fun formatGatewayAuthorityHost(host: String): String {
|
||||
val normalizedHost = host.trim().trim('[', ']')
|
||||
return if (normalizedHost.contains(":")) "[${normalizedHost}]" else normalizedHost
|
||||
}
|
||||
|
||||
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
|
||||
private fun JsonElement?.asArrayOrNull(): JsonArray? = this as? JsonArray
|
||||
|
||||
private fun JsonElement?.asStringOrNull(): String? =
|
||||
when (this) {
|
||||
is JsonNull -> null
|
||||
|
||||
@@ -3,11 +3,7 @@ package ai.openclaw.app.gateway
|
||||
import android.annotation.SuppressLint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.EOFException
|
||||
import java.net.ConnectException
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.security.cert.CertificateException
|
||||
@@ -16,7 +12,6 @@ import java.util.Locale
|
||||
import javax.net.ssl.HttpsURLConnection
|
||||
import javax.net.ssl.HostnameVerifier
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLException
|
||||
import javax.net.ssl.SSLParameters
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
import javax.net.ssl.SNIHostName
|
||||
@@ -37,16 +32,6 @@ data class GatewayTlsConfig(
|
||||
val hostnameVerifier: HostnameVerifier,
|
||||
)
|
||||
|
||||
enum class GatewayTlsProbeFailure {
|
||||
TLS_UNAVAILABLE,
|
||||
ENDPOINT_UNREACHABLE,
|
||||
}
|
||||
|
||||
data class GatewayTlsProbeResult(
|
||||
val fingerprintSha256: String? = null,
|
||||
val failure: GatewayTlsProbeFailure? = null,
|
||||
)
|
||||
|
||||
fun buildGatewayTlsConfig(
|
||||
params: GatewayTlsParams?,
|
||||
onStore: ((String) -> Unit)? = null,
|
||||
@@ -100,10 +85,10 @@ suspend fun probeGatewayTlsFingerprint(
|
||||
host: String,
|
||||
port: Int,
|
||||
timeoutMs: Int = 3_000,
|
||||
): GatewayTlsProbeResult {
|
||||
): String? {
|
||||
val trimmedHost = host.trim()
|
||||
if (trimmedHost.isEmpty()) return GatewayTlsProbeResult(failure = GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE)
|
||||
if (port !in 1..65535) return GatewayTlsProbeResult(failure = GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE)
|
||||
if (trimmedHost.isEmpty()) return null
|
||||
if (port !in 1..65535) return null
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
val trustAll =
|
||||
@@ -136,21 +121,10 @@ suspend fun probeGatewayTlsFingerprint(
|
||||
}
|
||||
|
||||
socket.startHandshake()
|
||||
val cert =
|
||||
socket.session.peerCertificates.firstOrNull() as? X509Certificate
|
||||
?: return@withContext GatewayTlsProbeResult(failure = GatewayTlsProbeFailure.TLS_UNAVAILABLE)
|
||||
GatewayTlsProbeResult(fingerprintSha256 = sha256Hex(cert.encoded))
|
||||
} catch (err: Throwable) {
|
||||
val failure =
|
||||
when (err) {
|
||||
is SSLException,
|
||||
is EOFException -> GatewayTlsProbeFailure.TLS_UNAVAILABLE
|
||||
is ConnectException,
|
||||
is SocketTimeoutException,
|
||||
is UnknownHostException -> GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE
|
||||
else -> GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE
|
||||
}
|
||||
GatewayTlsProbeResult(failure = failure)
|
||||
val cert = socket.session.peerCertificates.firstOrNull() as? X509Certificate ?: return@withContext null
|
||||
sha256Hex(cert.encoded)
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} finally {
|
||||
try {
|
||||
socket.close()
|
||||
|
||||
@@ -13,13 +13,6 @@ class A2UIHandler(
|
||||
private val getNodeCanvasHostUrl: () -> String?,
|
||||
private val getOperatorCanvasHostUrl: () -> String?,
|
||||
) {
|
||||
fun isTrustedCanvasActionUrl(rawUrl: String?): Boolean {
|
||||
return CanvasActionTrust.isTrustedCanvasActionUrl(
|
||||
rawUrl = rawUrl,
|
||||
trustedA2uiUrls = listOfNotNull(resolveA2uiHostUrl()),
|
||||
)
|
||||
}
|
||||
|
||||
fun resolveA2uiHostUrl(): String? {
|
||||
val nodeRaw = getNodeCanvasHostUrl()?.trim().orEmpty()
|
||||
val operatorRaw = getOperatorCanvasHostUrl()?.trim().orEmpty()
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
package ai.openclaw.app.node
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.provider.CallLog
|
||||
import androidx.core.content.ContextCompat
|
||||
import ai.openclaw.app.gateway.GatewaySession
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
private const val DEFAULT_CALL_LOG_LIMIT = 25
|
||||
|
||||
internal data class CallLogRecord(
|
||||
val number: String?,
|
||||
val cachedName: String?,
|
||||
val date: Long,
|
||||
val duration: Long,
|
||||
val type: Int,
|
||||
)
|
||||
|
||||
internal data class CallLogSearchRequest(
|
||||
val limit: Int, // Number of records to return
|
||||
val offset: Int, // Offset value
|
||||
val cachedName: String?, // Search by contact name
|
||||
val number: String?, // Search by phone number
|
||||
val date: Long?, // Search by time (timestamp, deprecated, use dateStart/dateEnd)
|
||||
val dateStart: Long?, // Query start time (timestamp)
|
||||
val dateEnd: Long?, // Query end time (timestamp)
|
||||
val duration: Long?, // Search by duration (seconds)
|
||||
val type: Int?, // Search by call log type
|
||||
)
|
||||
|
||||
internal interface CallLogDataSource {
|
||||
fun hasReadPermission(context: Context): Boolean
|
||||
|
||||
fun search(context: Context, request: CallLogSearchRequest): List<CallLogRecord>
|
||||
}
|
||||
|
||||
private object SystemCallLogDataSource : CallLogDataSource {
|
||||
override fun hasReadPermission(context: Context): Boolean {
|
||||
return ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.READ_CALL_LOG
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
override fun search(context: Context, request: CallLogSearchRequest): List<CallLogRecord> {
|
||||
val resolver = context.contentResolver
|
||||
val projection = arrayOf(
|
||||
CallLog.Calls.NUMBER,
|
||||
CallLog.Calls.CACHED_NAME,
|
||||
CallLog.Calls.DATE,
|
||||
CallLog.Calls.DURATION,
|
||||
CallLog.Calls.TYPE,
|
||||
)
|
||||
|
||||
// Build selection and selectionArgs for filtering
|
||||
val selections = mutableListOf<String>()
|
||||
val selectionArgs = mutableListOf<String>()
|
||||
|
||||
request.cachedName?.let {
|
||||
selections.add("${CallLog.Calls.CACHED_NAME} LIKE ?")
|
||||
selectionArgs.add("%$it%")
|
||||
}
|
||||
|
||||
request.number?.let {
|
||||
selections.add("${CallLog.Calls.NUMBER} LIKE ?")
|
||||
selectionArgs.add("%$it%")
|
||||
}
|
||||
|
||||
// Support time range query
|
||||
if (request.dateStart != null && request.dateEnd != null) {
|
||||
selections.add("${CallLog.Calls.DATE} >= ? AND ${CallLog.Calls.DATE} <= ?")
|
||||
selectionArgs.add(request.dateStart.toString())
|
||||
selectionArgs.add(request.dateEnd.toString())
|
||||
} else if (request.dateStart != null) {
|
||||
selections.add("${CallLog.Calls.DATE} >= ?")
|
||||
selectionArgs.add(request.dateStart.toString())
|
||||
} else if (request.dateEnd != null) {
|
||||
selections.add("${CallLog.Calls.DATE} <= ?")
|
||||
selectionArgs.add(request.dateEnd.toString())
|
||||
} else if (request.date != null) {
|
||||
// Compatible with the old date parameter (exact match)
|
||||
selections.add("${CallLog.Calls.DATE} = ?")
|
||||
selectionArgs.add(request.date.toString())
|
||||
}
|
||||
|
||||
request.duration?.let {
|
||||
selections.add("${CallLog.Calls.DURATION} = ?")
|
||||
selectionArgs.add(it.toString())
|
||||
}
|
||||
|
||||
request.type?.let {
|
||||
selections.add("${CallLog.Calls.TYPE} = ?")
|
||||
selectionArgs.add(it.toString())
|
||||
}
|
||||
|
||||
val selection = if (selections.isNotEmpty()) selections.joinToString(" AND ") else null
|
||||
val selectionArgsArray = if (selectionArgs.isNotEmpty()) selectionArgs.toTypedArray() else null
|
||||
|
||||
val sortOrder = "${CallLog.Calls.DATE} DESC"
|
||||
|
||||
resolver.query(
|
||||
CallLog.Calls.CONTENT_URI,
|
||||
projection,
|
||||
selection,
|
||||
selectionArgsArray,
|
||||
sortOrder,
|
||||
).use { cursor ->
|
||||
if (cursor == null) return emptyList()
|
||||
|
||||
val numberIndex = cursor.getColumnIndex(CallLog.Calls.NUMBER)
|
||||
val cachedNameIndex = cursor.getColumnIndex(CallLog.Calls.CACHED_NAME)
|
||||
val dateIndex = cursor.getColumnIndex(CallLog.Calls.DATE)
|
||||
val durationIndex = cursor.getColumnIndex(CallLog.Calls.DURATION)
|
||||
val typeIndex = cursor.getColumnIndex(CallLog.Calls.TYPE)
|
||||
|
||||
// Skip offset rows
|
||||
if (request.offset > 0 && cursor.moveToPosition(request.offset - 1)) {
|
||||
// Successfully moved to offset position
|
||||
}
|
||||
|
||||
val out = mutableListOf<CallLogRecord>()
|
||||
var count = 0
|
||||
while (cursor.moveToNext() && count < request.limit) {
|
||||
out += CallLogRecord(
|
||||
number = cursor.getString(numberIndex),
|
||||
cachedName = cursor.getString(cachedNameIndex),
|
||||
date = cursor.getLong(dateIndex),
|
||||
duration = cursor.getLong(durationIndex),
|
||||
type = cursor.getInt(typeIndex),
|
||||
)
|
||||
count++
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CallLogHandler private constructor(
|
||||
private val appContext: Context,
|
||||
private val dataSource: CallLogDataSource,
|
||||
) {
|
||||
constructor(appContext: Context) : this(appContext = appContext, dataSource = SystemCallLogDataSource)
|
||||
|
||||
fun handleCallLogSearch(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
if (!dataSource.hasReadPermission(appContext)) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "CALL_LOG_PERMISSION_REQUIRED",
|
||||
message = "CALL_LOG_PERMISSION_REQUIRED: grant Call Log permission",
|
||||
)
|
||||
}
|
||||
|
||||
val request = parseSearchRequest(paramsJson)
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: expected JSON object",
|
||||
)
|
||||
|
||||
return try {
|
||||
val callLogs = dataSource.search(appContext, request)
|
||||
GatewaySession.InvokeResult.ok(
|
||||
buildJsonObject {
|
||||
put(
|
||||
"callLogs",
|
||||
buildJsonArray {
|
||||
callLogs.forEach { add(callLogJson(it)) }
|
||||
},
|
||||
)
|
||||
}.toString(),
|
||||
)
|
||||
} catch (err: Throwable) {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "CALL_LOG_UNAVAILABLE",
|
||||
message = "CALL_LOG_UNAVAILABLE: ${err.message ?: "call log query failed"}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseSearchRequest(paramsJson: String?): CallLogSearchRequest? {
|
||||
if (paramsJson.isNullOrBlank()) {
|
||||
return CallLogSearchRequest(
|
||||
limit = DEFAULT_CALL_LOG_LIMIT,
|
||||
offset = 0,
|
||||
cachedName = null,
|
||||
number = null,
|
||||
date = null,
|
||||
dateStart = null,
|
||||
dateEnd = null,
|
||||
duration = null,
|
||||
type = null,
|
||||
)
|
||||
}
|
||||
|
||||
val params = try {
|
||||
Json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} ?: return null
|
||||
|
||||
val limit = ((params["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: DEFAULT_CALL_LOG_LIMIT)
|
||||
.coerceIn(1, 200)
|
||||
val offset = ((params["offset"] as? JsonPrimitive)?.content?.toIntOrNull() ?: 0)
|
||||
.coerceAtLeast(0)
|
||||
val cachedName = (params["cachedName"] as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() }
|
||||
val number = (params["number"] as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() }
|
||||
val date = (params["date"] as? JsonPrimitive)?.content?.toLongOrNull()
|
||||
val dateStart = (params["dateStart"] as? JsonPrimitive)?.content?.toLongOrNull()
|
||||
val dateEnd = (params["dateEnd"] as? JsonPrimitive)?.content?.toLongOrNull()
|
||||
val duration = (params["duration"] as? JsonPrimitive)?.content?.toLongOrNull()
|
||||
val type = (params["type"] as? JsonPrimitive)?.content?.toIntOrNull()
|
||||
|
||||
return CallLogSearchRequest(
|
||||
limit = limit,
|
||||
offset = offset,
|
||||
cachedName = cachedName,
|
||||
number = number,
|
||||
date = date,
|
||||
dateStart = dateStart,
|
||||
dateEnd = dateEnd,
|
||||
duration = duration,
|
||||
type = type,
|
||||
)
|
||||
}
|
||||
|
||||
private fun callLogJson(callLog: CallLogRecord): JsonObject {
|
||||
return buildJsonObject {
|
||||
put("number", JsonPrimitive(callLog.number))
|
||||
put("cachedName", JsonPrimitive(callLog.cachedName))
|
||||
put("date", JsonPrimitive(callLog.date))
|
||||
put("duration", JsonPrimitive(callLog.duration))
|
||||
put("type", JsonPrimitive(callLog.type))
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal fun forTesting(
|
||||
appContext: Context,
|
||||
dataSource: CallLogDataSource,
|
||||
): CallLogHandler = CallLogHandler(appContext = appContext, dataSource = dataSource)
|
||||
}
|
||||
}
|
||||
@@ -121,48 +121,42 @@ class CameraCaptureManager(private val context: Context) {
|
||||
(rotated.height.toDouble() * (maxWidth.toDouble() / rotated.width.toDouble()))
|
||||
.toInt()
|
||||
.coerceAtLeast(1)
|
||||
val s = rotated.scale(maxWidth, h)
|
||||
if (s !== rotated) rotated.recycle()
|
||||
s
|
||||
rotated.scale(maxWidth, h)
|
||||
} else {
|
||||
rotated
|
||||
}
|
||||
|
||||
try {
|
||||
val maxPayloadBytes = 5 * 1024 * 1024
|
||||
// Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under 5MB (API limit).
|
||||
val maxEncodedBytes = (maxPayloadBytes / 4) * 3
|
||||
val result =
|
||||
JpegSizeLimiter.compressToLimit(
|
||||
initialWidth = scaled.width,
|
||||
initialHeight = scaled.height,
|
||||
startQuality = (quality * 100.0).roundToInt().coerceIn(10, 100),
|
||||
maxBytes = maxEncodedBytes,
|
||||
encode = { width, height, q ->
|
||||
val bitmap =
|
||||
if (width == scaled.width && height == scaled.height) {
|
||||
scaled
|
||||
} else {
|
||||
scaled.scale(width, height)
|
||||
}
|
||||
val out = ByteArrayOutputStream()
|
||||
if (!bitmap.compress(Bitmap.CompressFormat.JPEG, q, out)) {
|
||||
if (bitmap !== scaled) bitmap.recycle()
|
||||
throw IllegalStateException("UNAVAILABLE: failed to encode JPEG")
|
||||
val maxPayloadBytes = 5 * 1024 * 1024
|
||||
// Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under 5MB (API limit).
|
||||
val maxEncodedBytes = (maxPayloadBytes / 4) * 3
|
||||
val result =
|
||||
JpegSizeLimiter.compressToLimit(
|
||||
initialWidth = scaled.width,
|
||||
initialHeight = scaled.height,
|
||||
startQuality = (quality * 100.0).roundToInt().coerceIn(10, 100),
|
||||
maxBytes = maxEncodedBytes,
|
||||
encode = { width, height, q ->
|
||||
val bitmap =
|
||||
if (width == scaled.width && height == scaled.height) {
|
||||
scaled
|
||||
} else {
|
||||
scaled.scale(width, height)
|
||||
}
|
||||
if (bitmap !== scaled) {
|
||||
bitmap.recycle()
|
||||
}
|
||||
out.toByteArray()
|
||||
},
|
||||
)
|
||||
val base64 = Base64.encodeToString(result.bytes, Base64.NO_WRAP)
|
||||
Payload(
|
||||
"""{"format":"jpg","base64":"$base64","width":${result.width},"height":${result.height}}""",
|
||||
val out = ByteArrayOutputStream()
|
||||
if (!bitmap.compress(Bitmap.CompressFormat.JPEG, q, out)) {
|
||||
if (bitmap !== scaled) bitmap.recycle()
|
||||
throw IllegalStateException("UNAVAILABLE: failed to encode JPEG")
|
||||
}
|
||||
if (bitmap !== scaled) {
|
||||
bitmap.recycle()
|
||||
}
|
||||
out.toByteArray()
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
scaled.recycle()
|
||||
}
|
||||
val base64 = Base64.encodeToString(result.bytes, Base64.NO_WRAP)
|
||||
Payload(
|
||||
"""{"format":"jpg","base64":"$base64","width":${result.width},"height":${result.height}}""",
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
|
||||
@@ -134,11 +134,9 @@ class CameraHandler(
|
||||
}
|
||||
|
||||
val bytes = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
filePayload.file.readBytes()
|
||||
} finally {
|
||||
filePayload.file.delete()
|
||||
}
|
||||
val b = filePayload.file.readBytes()
|
||||
filePayload.file.delete()
|
||||
b
|
||||
}
|
||||
val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)
|
||||
clipLog("returning base64 payload")
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
package ai.openclaw.app.node
|
||||
|
||||
import java.net.URI
|
||||
|
||||
object CanvasActionTrust {
|
||||
const val scaffoldAssetUrl: String = "file:///android_asset/CanvasScaffold/scaffold.html"
|
||||
|
||||
fun isTrustedCanvasActionUrl(rawUrl: String?, trustedA2uiUrls: List<String>): Boolean {
|
||||
val candidate = rawUrl?.trim().orEmpty()
|
||||
if (candidate.isEmpty()) return false
|
||||
if (candidate == scaffoldAssetUrl) return true
|
||||
|
||||
val candidateUri = parseUri(candidate) ?: return false
|
||||
if (candidateUri.scheme.equals("file", ignoreCase = true)) {
|
||||
return false
|
||||
}
|
||||
val normalizedCandidate = normalizeTrustedRemoteA2uiUri(candidateUri) ?: return false
|
||||
|
||||
return trustedA2uiUrls.any { trusted ->
|
||||
matchesTrustedRemoteA2uiUrlExact(normalizedCandidate, trusted)
|
||||
}
|
||||
}
|
||||
|
||||
private fun matchesTrustedRemoteA2uiUrlExact(candidateUri: URI, trustedUrl: String): Boolean {
|
||||
val trustedUri = parseUri(trustedUrl) ?: return false
|
||||
val normalizedTrusted = normalizeTrustedRemoteA2uiUri(trustedUri) ?: return false
|
||||
return candidateUri == normalizedTrusted
|
||||
}
|
||||
|
||||
private fun normalizeTrustedRemoteA2uiUri(uri: URI): URI? {
|
||||
// Keep Android trust normalization aligned with iOS ScreenController:
|
||||
// exact remote URL match, scheme/host normalized, fragment ignored.
|
||||
val scheme = uri.scheme?.lowercase() ?: return null
|
||||
if (scheme != "http" && scheme != "https") return null
|
||||
|
||||
val host = uri.host?.trim()?.takeIf { it.isNotEmpty() }?.lowercase() ?: return null
|
||||
|
||||
return try {
|
||||
URI(scheme, uri.userInfo, host, uri.port, uri.rawPath, uri.rawQuery, null)
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseUri(raw: String): URI? =
|
||||
try {
|
||||
URI(raw)
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -180,41 +180,27 @@ class CanvasController {
|
||||
withContext(Dispatchers.Main) {
|
||||
val wv = webView ?: throw IllegalStateException("no webview")
|
||||
val bmp = wv.captureBitmap()
|
||||
try {
|
||||
val scaled = bmp.scaleForMaxWidth(maxWidth)
|
||||
try {
|
||||
val out = ByteArrayOutputStream()
|
||||
scaled.compress(Bitmap.CompressFormat.PNG, 100, out)
|
||||
Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
|
||||
} finally {
|
||||
if (scaled !== bmp) scaled.recycle()
|
||||
}
|
||||
} finally {
|
||||
bmp.recycle()
|
||||
}
|
||||
val scaled = bmp.scaleForMaxWidth(maxWidth)
|
||||
|
||||
val out = ByteArrayOutputStream()
|
||||
scaled.compress(Bitmap.CompressFormat.PNG, 100, out)
|
||||
Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
suspend fun snapshotBase64(format: SnapshotFormat, quality: Double?, maxWidth: Int?): String =
|
||||
withContext(Dispatchers.Main) {
|
||||
val wv = webView ?: throw IllegalStateException("no webview")
|
||||
val bmp = wv.captureBitmap()
|
||||
try {
|
||||
val scaled = bmp.scaleForMaxWidth(maxWidth)
|
||||
try {
|
||||
val out = ByteArrayOutputStream()
|
||||
val (compressFormat, compressQuality) =
|
||||
when (format) {
|
||||
SnapshotFormat.Png -> Bitmap.CompressFormat.PNG to 100
|
||||
SnapshotFormat.Jpeg -> Bitmap.CompressFormat.JPEG to clampJpegQuality(quality)
|
||||
}
|
||||
scaled.compress(compressFormat, compressQuality, out)
|
||||
Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
|
||||
} finally {
|
||||
if (scaled !== bmp) scaled.recycle()
|
||||
val scaled = bmp.scaleForMaxWidth(maxWidth)
|
||||
|
||||
val out = ByteArrayOutputStream()
|
||||
val (compressFormat, compressQuality) =
|
||||
when (format) {
|
||||
SnapshotFormat.Png -> Bitmap.CompressFormat.PNG to 100
|
||||
SnapshotFormat.Jpeg -> Bitmap.CompressFormat.JPEG to clampJpegQuality(quality)
|
||||
}
|
||||
} finally {
|
||||
bmp.recycle()
|
||||
}
|
||||
scaled.compress(compressFormat, compressQuality, out)
|
||||
Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
private suspend fun WebView.captureBitmap(): Bitmap =
|
||||
|
||||
@@ -7,7 +7,6 @@ import ai.openclaw.app.gateway.GatewayClientInfo
|
||||
import ai.openclaw.app.gateway.GatewayConnectOptions
|
||||
import ai.openclaw.app.gateway.GatewayEndpoint
|
||||
import ai.openclaw.app.gateway.GatewayTlsParams
|
||||
import ai.openclaw.app.gateway.isPrivateLanGatewayHost
|
||||
import ai.openclaw.app.LocationMode
|
||||
import ai.openclaw.app.VoiceWakeMode
|
||||
|
||||
@@ -18,10 +17,7 @@ class ConnectionManager(
|
||||
private val voiceWakeMode: () -> VoiceWakeMode,
|
||||
private val motionActivityAvailable: () -> Boolean,
|
||||
private val motionPedometerAvailable: () -> Boolean,
|
||||
private val sendSmsAvailable: () -> Boolean,
|
||||
private val readSmsAvailable: () -> Boolean,
|
||||
private val smsSearchPossible: () -> Boolean,
|
||||
private val callLogAvailable: () -> Boolean,
|
||||
private val smsAvailable: () -> Boolean,
|
||||
private val hasRecordAudioPermission: () -> Boolean,
|
||||
private val manualTls: () -> Boolean,
|
||||
) {
|
||||
@@ -34,10 +30,9 @@ class ConnectionManager(
|
||||
val stableId = endpoint.stableId
|
||||
val stored = storedFingerprint?.trim().takeIf { !it.isNullOrEmpty() }
|
||||
val isManual = stableId.startsWith("manual|")
|
||||
val cleartextAllowedHost = isPrivateLanGatewayHost(endpoint.host)
|
||||
|
||||
if (isManual) {
|
||||
if (!manualTlsEnabled && cleartextAllowedHost) return null
|
||||
if (!manualTlsEnabled) return null
|
||||
if (!stored.isNullOrBlank()) {
|
||||
return GatewayTlsParams(
|
||||
required = true,
|
||||
@@ -75,15 +70,6 @@ class ConnectionManager(
|
||||
)
|
||||
}
|
||||
|
||||
if (!cleartextAllowedHost) {
|
||||
return GatewayTlsParams(
|
||||
required = true,
|
||||
expectedFingerprint = null,
|
||||
allowTOFU = false,
|
||||
stableId = stableId,
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -92,10 +78,7 @@ class ConnectionManager(
|
||||
NodeRuntimeFlags(
|
||||
cameraEnabled = cameraEnabled(),
|
||||
locationEnabled = locationMode() != LocationMode.Off,
|
||||
sendSmsAvailable = sendSmsAvailable(),
|
||||
readSmsAvailable = readSmsAvailable(),
|
||||
smsSearchPossible = smsSearchPossible(),
|
||||
callLogAvailable = callLogAvailable(),
|
||||
smsAvailable = smsAvailable(),
|
||||
voiceWakeEnabled = voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission(),
|
||||
motionActivityAvailable = motionActivityAvailable(),
|
||||
motionPedometerAvailable = motionPedometerAvailable(),
|
||||
|
||||
@@ -76,8 +76,8 @@ private object SystemContactsDataSource : ContactsDataSource {
|
||||
selection = null
|
||||
selectionArgs = null
|
||||
} else {
|
||||
selection = "${ContactsContract.Contacts.DISPLAY_NAME_PRIMARY} LIKE ? ESCAPE '\\'"
|
||||
selectionArgs = arrayOf("%${escapeLikePattern(request.query)}%")
|
||||
selection = "${ContactsContract.Contacts.DISPLAY_NAME_PRIMARY} LIKE ?"
|
||||
selectionArgs = arrayOf("%${request.query}%")
|
||||
}
|
||||
val sortOrder = "${ContactsContract.Contacts.DISPLAY_NAME_PRIMARY} COLLATE NOCASE ASC LIMIT ${request.limit}"
|
||||
resolver.query(
|
||||
@@ -247,9 +247,6 @@ private object SystemContactsDataSource : ContactsDataSource {
|
||||
}
|
||||
}
|
||||
|
||||
private fun escapeLikePattern(pattern: String): String =
|
||||
pattern.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
private fun loadPhones(resolver: ContentResolver, contactId: Long): List<String> {
|
||||
return queryContactValues(
|
||||
resolver = resolver,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package ai.openclaw.app.node
|
||||
|
||||
import ai.openclaw.app.BuildConfig
|
||||
import android.Manifest
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
@@ -16,9 +15,9 @@ import android.os.PowerManager
|
||||
import android.os.StatFs
|
||||
import android.os.SystemClock
|
||||
import androidx.core.content.ContextCompat
|
||||
import ai.openclaw.app.BuildConfig
|
||||
import ai.openclaw.app.gateway.GatewaySession
|
||||
import java.util.Locale
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
@@ -26,28 +25,7 @@ import kotlinx.serialization.json.put
|
||||
|
||||
class DeviceHandler(
|
||||
private val appContext: Context,
|
||||
private val smsEnabled: Boolean = BuildConfig.OPENCLAW_ENABLE_SMS,
|
||||
private val callLogEnabled: Boolean = BuildConfig.OPENCLAW_ENABLE_CALL_LOG,
|
||||
) {
|
||||
companion object {
|
||||
internal fun hasAnySmsCapability(
|
||||
smsEnabled: Boolean,
|
||||
telephonyAvailable: Boolean,
|
||||
smsSendGranted: Boolean,
|
||||
smsReadGranted: Boolean,
|
||||
): Boolean {
|
||||
return smsEnabled && telephonyAvailable && (smsSendGranted || smsReadGranted)
|
||||
}
|
||||
|
||||
internal fun isSmsPromptable(
|
||||
smsEnabled: Boolean,
|
||||
telephonyAvailable: Boolean,
|
||||
smsSendGranted: Boolean,
|
||||
smsReadGranted: Boolean,
|
||||
): Boolean {
|
||||
return smsEnabled && telephonyAvailable && (!smsSendGranted || !smsReadGranted)
|
||||
}
|
||||
}
|
||||
private data class BatterySnapshot(
|
||||
val status: Int,
|
||||
val plugged: Int,
|
||||
@@ -151,8 +129,6 @@ class DeviceHandler(
|
||||
|
||||
private fun permissionsPayloadJson(): String {
|
||||
val canSendSms = appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY)
|
||||
val smsSendGranted = hasPermission(Manifest.permission.SEND_SMS)
|
||||
val smsReadGranted = hasPermission(Manifest.permission.READ_SMS)
|
||||
val notificationAccess = DeviceNotificationListenerService.isAccessEnabled(appContext)
|
||||
val photosGranted =
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
@@ -196,34 +172,10 @@ class DeviceHandler(
|
||||
)
|
||||
put(
|
||||
"sms",
|
||||
buildJsonObject {
|
||||
put(
|
||||
"status",
|
||||
JsonPrimitive(
|
||||
if (hasAnySmsCapability(smsEnabled, canSendSms, smsSendGranted, smsReadGranted)) "granted" else "denied",
|
||||
),
|
||||
)
|
||||
put("promptable", JsonPrimitive(isSmsPromptable(smsEnabled, canSendSms, smsSendGranted, smsReadGranted)))
|
||||
put(
|
||||
"capabilities",
|
||||
buildJsonObject {
|
||||
put(
|
||||
"send",
|
||||
permissionStateJson(
|
||||
granted = smsEnabled && smsSendGranted && canSendSms,
|
||||
promptableWhenDenied = smsEnabled && canSendSms,
|
||||
),
|
||||
)
|
||||
put(
|
||||
"read",
|
||||
permissionStateJson(
|
||||
granted = smsEnabled && smsReadGranted && canSendSms,
|
||||
promptableWhenDenied = smsEnabled && canSendSms,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
permissionStateJson(
|
||||
granted = hasPermission(Manifest.permission.SEND_SMS) && canSendSms,
|
||||
promptableWhenDenied = canSendSms,
|
||||
),
|
||||
)
|
||||
put(
|
||||
"notificationListener",
|
||||
@@ -260,13 +212,6 @@ class DeviceHandler(
|
||||
promptableWhenDenied = true,
|
||||
),
|
||||
)
|
||||
put(
|
||||
"callLog",
|
||||
permissionStateJson(
|
||||
granted = callLogEnabled && hasPermission(Manifest.permission.READ_CALL_LOG),
|
||||
promptableWhenDenied = callLogEnabled,
|
||||
),
|
||||
)
|
||||
put(
|
||||
"motion",
|
||||
permissionStateJson(
|
||||
|
||||
@@ -8,10 +8,6 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.service.notification.NotificationListenerService
|
||||
import android.service.notification.StatusBarNotification
|
||||
import ai.openclaw.app.NotificationBurstLimiter
|
||||
import ai.openclaw.app.SecurePrefs
|
||||
import ai.openclaw.app.allowsPackage
|
||||
import ai.openclaw.app.isWithinQuietHours
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
@@ -130,9 +126,6 @@ private object DeviceNotificationStore {
|
||||
}
|
||||
|
||||
class DeviceNotificationListenerService : NotificationListenerService() {
|
||||
private val securePrefs by lazy { SecurePrefs(applicationContext) }
|
||||
private val forwardingLimiter = NotificationBurstLimiter()
|
||||
|
||||
override fun onListenerConnected() {
|
||||
super.onListenerConnected()
|
||||
activeService = this
|
||||
@@ -159,12 +152,24 @@ class DeviceNotificationListenerService : NotificationListenerService() {
|
||||
super.onNotificationPosted(sbn)
|
||||
val entry = sbn?.toEntry() ?: return
|
||||
DeviceNotificationStore.upsert(entry)
|
||||
rememberRecentPackage(entry.packageName)
|
||||
if (entry.packageName == packageName) {
|
||||
return
|
||||
}
|
||||
val payload = notificationChangedPayload(entry) ?: return
|
||||
emitNotificationsChanged(payload)
|
||||
emitNotificationsChanged(
|
||||
buildJsonObject {
|
||||
put("change", JsonPrimitive("posted"))
|
||||
put("key", JsonPrimitive(entry.key))
|
||||
put("packageName", JsonPrimitive(entry.packageName))
|
||||
put("postTimeMs", JsonPrimitive(entry.postTimeMs))
|
||||
put("isOngoing", JsonPrimitive(entry.isOngoing))
|
||||
put("isClearable", JsonPrimitive(entry.isClearable))
|
||||
entry.title?.let { put("title", JsonPrimitive(it)) }
|
||||
entry.text?.let { put("text", JsonPrimitive(it)) }
|
||||
entry.subText?.let { put("subText", JsonPrimitive(it)) }
|
||||
entry.category?.let { put("category", JsonPrimitive(it)) }
|
||||
entry.channelId?.let { put("channelId", JsonPrimitive(it)) }
|
||||
}.toString(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun onNotificationRemoved(sbn: StatusBarNotification?) {
|
||||
@@ -175,79 +180,21 @@ class DeviceNotificationListenerService : NotificationListenerService() {
|
||||
return
|
||||
}
|
||||
DeviceNotificationStore.remove(key)
|
||||
rememberRecentPackage(removed.packageName)
|
||||
if (removed.packageName == packageName) {
|
||||
return
|
||||
}
|
||||
val packageName = removed.packageName.trim()
|
||||
val payload =
|
||||
notificationChangedPayload(
|
||||
entry = null,
|
||||
change = "removed",
|
||||
key = key,
|
||||
packageName = packageName,
|
||||
postTimeMs = removed.postTime,
|
||||
isOngoing = removed.isOngoing,
|
||||
isClearable = removed.isClearable,
|
||||
) ?: return
|
||||
emitNotificationsChanged(payload)
|
||||
}
|
||||
|
||||
private fun notificationChangedPayload(entry: DeviceNotificationEntry): String? {
|
||||
return notificationChangedPayload(
|
||||
entry = entry,
|
||||
change = "posted",
|
||||
key = entry.key,
|
||||
packageName = entry.packageName,
|
||||
postTimeMs = entry.postTimeMs,
|
||||
isOngoing = entry.isOngoing,
|
||||
isClearable = entry.isClearable,
|
||||
emitNotificationsChanged(
|
||||
buildJsonObject {
|
||||
put("change", JsonPrimitive("removed"))
|
||||
put("key", JsonPrimitive(key))
|
||||
val packageName = removed.packageName.trim()
|
||||
if (packageName.isNotEmpty()) {
|
||||
put("packageName", JsonPrimitive(packageName))
|
||||
}
|
||||
}.toString(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun notificationChangedPayload(
|
||||
entry: DeviceNotificationEntry?,
|
||||
change: String,
|
||||
key: String,
|
||||
packageName: String,
|
||||
postTimeMs: Long,
|
||||
isOngoing: Boolean,
|
||||
isClearable: Boolean,
|
||||
): String? {
|
||||
val normalizedPackage = packageName.trim()
|
||||
if (normalizedPackage.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
val policy = securePrefs.getNotificationForwardingPolicy(appPackageName = this.packageName)
|
||||
if (!policy.enabled) {
|
||||
return null
|
||||
}
|
||||
if (!policy.allowsPackage(normalizedPackage)) {
|
||||
return null
|
||||
}
|
||||
val nowEpochMs = System.currentTimeMillis()
|
||||
if (policy.isWithinQuietHours(nowEpochMs = nowEpochMs)) {
|
||||
return null
|
||||
}
|
||||
if (!forwardingLimiter.allow(nowEpochMs, policy.maxEventsPerMinute)) {
|
||||
return null
|
||||
}
|
||||
return buildJsonObject {
|
||||
put("change", JsonPrimitive(change))
|
||||
put("key", JsonPrimitive(key))
|
||||
put("packageName", JsonPrimitive(normalizedPackage))
|
||||
put("postTimeMs", JsonPrimitive(postTimeMs))
|
||||
put("isOngoing", JsonPrimitive(isOngoing))
|
||||
put("isClearable", JsonPrimitive(isClearable))
|
||||
policy.sessionKey?.let { put("sessionKey", JsonPrimitive(it)) }
|
||||
entry?.title?.let { put("title", JsonPrimitive(it)) }
|
||||
entry?.text?.let { put("text", JsonPrimitive(it)) }
|
||||
entry?.subText?.let { put("subText", JsonPrimitive(it)) }
|
||||
entry?.category?.let { put("category", JsonPrimitive(it)) }
|
||||
entry?.channelId?.let { put("channelId", JsonPrimitive(it)) }
|
||||
}.toString()
|
||||
}
|
||||
|
||||
private fun refreshActiveNotifications() {
|
||||
val entries =
|
||||
runCatching {
|
||||
@@ -281,9 +228,6 @@ class DeviceNotificationListenerService : NotificationListenerService() {
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val recentPackagesPref = "notifications.forwarding.recentPackages"
|
||||
private const val legacyRecentPackagesPref = "notifications.recentPackages"
|
||||
private const val recentPackagesLimit = 64
|
||||
@Volatile private var activeService: DeviceNotificationListenerService? = null
|
||||
@Volatile private var nodeEventSink: ((event: String, payloadJson: String?) -> Unit)? = null
|
||||
|
||||
@@ -295,31 +239,6 @@ class DeviceNotificationListenerService : NotificationListenerService() {
|
||||
nodeEventSink = sink
|
||||
}
|
||||
|
||||
private fun recentPackagesPrefs(context: Context) =
|
||||
context.applicationContext.getSharedPreferences("openclaw.secure", Context.MODE_PRIVATE)
|
||||
|
||||
private fun migrateLegacyRecentPackagesIfNeeded(context: Context) {
|
||||
val prefs = recentPackagesPrefs(context)
|
||||
val hasNew = prefs.contains(recentPackagesPref)
|
||||
val legacy = prefs.getString(legacyRecentPackagesPref, null)?.trim().orEmpty()
|
||||
if (!hasNew && legacy.isNotEmpty()) {
|
||||
prefs.edit().putString(recentPackagesPref, legacy).remove(legacyRecentPackagesPref).apply()
|
||||
} else if (hasNew && prefs.contains(legacyRecentPackagesPref)) {
|
||||
prefs.edit().remove(legacyRecentPackagesPref).apply()
|
||||
}
|
||||
}
|
||||
|
||||
fun recentPackages(context: Context): List<String> {
|
||||
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 {
|
||||
val manager = context.getSystemService(NotificationManager::class.java) ?: return false
|
||||
return manager.isNotificationListenerAccessGranted(serviceComponent(context))
|
||||
@@ -357,21 +276,6 @@ class DeviceNotificationListenerService : NotificationListenerService() {
|
||||
nodeEventSink?.invoke(NOTIFICATIONS_CHANGED_EVENT, payloadJson)
|
||||
}
|
||||
}
|
||||
|
||||
private fun rememberRecentPackage(packageName: String?) {
|
||||
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 = prefs.getString(recentPackagesPref, null).orEmpty()
|
||||
.split(',')
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() && it != normalized }
|
||||
.take(recentPackagesLimit - 1)
|
||||
val updated = listOf(normalized) + existing
|
||||
prefs.edit().putString(recentPackagesPref, updated.joinToString(",")).apply()
|
||||
}
|
||||
}
|
||||
|
||||
private fun executeActionInternal(request: NotificationActionRequest): NotificationActionResult {
|
||||
|
||||
@@ -5,7 +5,6 @@ import ai.openclaw.app.protocol.OpenClawCanvasA2UICommand
|
||||
import ai.openclaw.app.protocol.OpenClawCanvasCommand
|
||||
import ai.openclaw.app.protocol.OpenClawCameraCommand
|
||||
import ai.openclaw.app.protocol.OpenClawCapability
|
||||
import ai.openclaw.app.protocol.OpenClawCallLogCommand
|
||||
import ai.openclaw.app.protocol.OpenClawContactsCommand
|
||||
import ai.openclaw.app.protocol.OpenClawDeviceCommand
|
||||
import ai.openclaw.app.protocol.OpenClawLocationCommand
|
||||
@@ -18,10 +17,7 @@ import ai.openclaw.app.protocol.OpenClawSystemCommand
|
||||
data class NodeRuntimeFlags(
|
||||
val cameraEnabled: Boolean,
|
||||
val locationEnabled: Boolean,
|
||||
val sendSmsAvailable: Boolean,
|
||||
val readSmsAvailable: Boolean,
|
||||
val smsSearchPossible: Boolean,
|
||||
val callLogAvailable: Boolean,
|
||||
val smsAvailable: Boolean,
|
||||
val voiceWakeEnabled: Boolean,
|
||||
val motionActivityAvailable: Boolean,
|
||||
val motionPedometerAvailable: Boolean,
|
||||
@@ -32,10 +28,7 @@ enum class InvokeCommandAvailability {
|
||||
Always,
|
||||
CameraEnabled,
|
||||
LocationEnabled,
|
||||
SendSmsAvailable,
|
||||
ReadSmsAvailable,
|
||||
RequestableSmsSearchAvailable,
|
||||
CallLogAvailable,
|
||||
SmsAvailable,
|
||||
MotionActivityAvailable,
|
||||
MotionPedometerAvailable,
|
||||
DebugBuild,
|
||||
@@ -46,7 +39,6 @@ enum class NodeCapabilityAvailability {
|
||||
CameraEnabled,
|
||||
LocationEnabled,
|
||||
SmsAvailable,
|
||||
CallLogAvailable,
|
||||
VoiceWakeEnabled,
|
||||
MotionAvailable,
|
||||
}
|
||||
@@ -92,10 +84,6 @@ object InvokeCommandRegistry {
|
||||
name = OpenClawCapability.Motion.rawValue,
|
||||
availability = NodeCapabilityAvailability.MotionAvailable,
|
||||
),
|
||||
NodeCapabilitySpec(
|
||||
name = OpenClawCapability.CallLog.rawValue,
|
||||
availability = NodeCapabilityAvailability.CallLogAvailable,
|
||||
),
|
||||
)
|
||||
|
||||
val all: List<InvokeCommandSpec> =
|
||||
@@ -197,15 +185,7 @@ object InvokeCommandRegistry {
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawSmsCommand.Send.rawValue,
|
||||
availability = InvokeCommandAvailability.SendSmsAvailable,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawSmsCommand.Search.rawValue,
|
||||
availability = InvokeCommandAvailability.RequestableSmsSearchAvailable,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCallLogCommand.Search.rawValue,
|
||||
availability = InvokeCommandAvailability.CallLogAvailable,
|
||||
availability = InvokeCommandAvailability.SmsAvailable,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = "debug.logs",
|
||||
@@ -228,8 +208,7 @@ object InvokeCommandRegistry {
|
||||
NodeCapabilityAvailability.Always -> true
|
||||
NodeCapabilityAvailability.CameraEnabled -> flags.cameraEnabled
|
||||
NodeCapabilityAvailability.LocationEnabled -> flags.locationEnabled
|
||||
NodeCapabilityAvailability.SmsAvailable -> flags.sendSmsAvailable || flags.readSmsAvailable
|
||||
NodeCapabilityAvailability.CallLogAvailable -> flags.callLogAvailable
|
||||
NodeCapabilityAvailability.SmsAvailable -> flags.smsAvailable
|
||||
NodeCapabilityAvailability.VoiceWakeEnabled -> flags.voiceWakeEnabled
|
||||
NodeCapabilityAvailability.MotionAvailable -> flags.motionActivityAvailable || flags.motionPedometerAvailable
|
||||
}
|
||||
@@ -244,10 +223,7 @@ object InvokeCommandRegistry {
|
||||
InvokeCommandAvailability.Always -> true
|
||||
InvokeCommandAvailability.CameraEnabled -> flags.cameraEnabled
|
||||
InvokeCommandAvailability.LocationEnabled -> flags.locationEnabled
|
||||
InvokeCommandAvailability.SendSmsAvailable -> flags.sendSmsAvailable
|
||||
InvokeCommandAvailability.ReadSmsAvailable -> flags.readSmsAvailable
|
||||
InvokeCommandAvailability.RequestableSmsSearchAvailable -> flags.smsSearchPossible
|
||||
InvokeCommandAvailability.CallLogAvailable -> flags.callLogAvailable
|
||||
InvokeCommandAvailability.SmsAvailable -> flags.smsAvailable
|
||||
InvokeCommandAvailability.MotionActivityAvailable -> flags.motionActivityAvailable
|
||||
InvokeCommandAvailability.MotionPedometerAvailable -> flags.motionPedometerAvailable
|
||||
InvokeCommandAvailability.DebugBuild -> flags.debugBuild
|
||||
|
||||
@@ -5,7 +5,6 @@ import ai.openclaw.app.protocol.OpenClawCalendarCommand
|
||||
import ai.openclaw.app.protocol.OpenClawCanvasA2UICommand
|
||||
import ai.openclaw.app.protocol.OpenClawCanvasCommand
|
||||
import ai.openclaw.app.protocol.OpenClawCameraCommand
|
||||
import ai.openclaw.app.protocol.OpenClawCallLogCommand
|
||||
import ai.openclaw.app.protocol.OpenClawContactsCommand
|
||||
import ai.openclaw.app.protocol.OpenClawDeviceCommand
|
||||
import ai.openclaw.app.protocol.OpenClawLocationCommand
|
||||
@@ -14,44 +13,6 @@ import ai.openclaw.app.protocol.OpenClawNotificationsCommand
|
||||
import ai.openclaw.app.protocol.OpenClawSmsCommand
|
||||
import ai.openclaw.app.protocol.OpenClawSystemCommand
|
||||
|
||||
internal enum class SmsSearchAvailabilityReason {
|
||||
Available,
|
||||
PermissionRequired,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
internal fun classifySmsSearchAvailability(
|
||||
readSmsAvailable: Boolean,
|
||||
smsFeatureEnabled: Boolean,
|
||||
smsTelephonyAvailable: Boolean,
|
||||
): SmsSearchAvailabilityReason {
|
||||
if (readSmsAvailable) return SmsSearchAvailabilityReason.Available
|
||||
if (!smsFeatureEnabled || !smsTelephonyAvailable) return SmsSearchAvailabilityReason.Unavailable
|
||||
return SmsSearchAvailabilityReason.PermissionRequired
|
||||
}
|
||||
|
||||
internal fun smsSearchAvailabilityError(
|
||||
readSmsAvailable: Boolean,
|
||||
smsFeatureEnabled: Boolean,
|
||||
smsTelephonyAvailable: Boolean,
|
||||
): GatewaySession.InvokeResult? {
|
||||
return when (
|
||||
classifySmsSearchAvailability(
|
||||
readSmsAvailable = readSmsAvailable,
|
||||
smsFeatureEnabled = smsFeatureEnabled,
|
||||
smsTelephonyAvailable = smsTelephonyAvailable,
|
||||
)
|
||||
) {
|
||||
SmsSearchAvailabilityReason.Available,
|
||||
SmsSearchAvailabilityReason.PermissionRequired -> null
|
||||
SmsSearchAvailabilityReason.Unavailable ->
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "SMS_UNAVAILABLE",
|
||||
message = "SMS_UNAVAILABLE: SMS not available on this device",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class InvokeDispatcher(
|
||||
private val canvas: CanvasController,
|
||||
private val cameraHandler: CameraHandler,
|
||||
@@ -66,15 +27,10 @@ class InvokeDispatcher(
|
||||
private val smsHandler: SmsHandler,
|
||||
private val a2uiHandler: A2UIHandler,
|
||||
private val debugHandler: DebugHandler,
|
||||
private val callLogHandler: CallLogHandler,
|
||||
private val isForeground: () -> Boolean,
|
||||
private val cameraEnabled: () -> Boolean,
|
||||
private val locationEnabled: () -> Boolean,
|
||||
private val sendSmsAvailable: () -> Boolean,
|
||||
private val readSmsAvailable: () -> Boolean,
|
||||
private val smsFeatureEnabled: () -> Boolean,
|
||||
private val smsTelephonyAvailable: () -> Boolean,
|
||||
private val callLogAvailable: () -> Boolean,
|
||||
private val smsAvailable: () -> Boolean,
|
||||
private val debugBuild: () -> Boolean,
|
||||
private val refreshNodeCanvasCapability: suspend () -> Boolean,
|
||||
private val onCanvasA2uiPush: () -> Unit,
|
||||
@@ -204,10 +160,6 @@ class InvokeDispatcher(
|
||||
|
||||
// SMS command
|
||||
OpenClawSmsCommand.Send.rawValue -> smsHandler.handleSmsSend(paramsJson)
|
||||
OpenClawSmsCommand.Search.rawValue -> smsHandler.handleSmsSearch(paramsJson)
|
||||
|
||||
// CallLog command
|
||||
OpenClawCallLogCommand.Search.rawValue -> callLogHandler.handleCallLogSearch(paramsJson)
|
||||
|
||||
// Debug commands
|
||||
"debug.ed25519" -> debugHandler.handleEd25519()
|
||||
@@ -299,8 +251,8 @@ class InvokeDispatcher(
|
||||
message = "PEDOMETER_UNAVAILABLE: step counter not available",
|
||||
)
|
||||
}
|
||||
InvokeCommandAvailability.SendSmsAvailable ->
|
||||
if (sendSmsAvailable()) {
|
||||
InvokeCommandAvailability.SmsAvailable ->
|
||||
if (smsAvailable()) {
|
||||
null
|
||||
} else {
|
||||
GatewaySession.InvokeResult.error(
|
||||
@@ -308,22 +260,6 @@ class InvokeDispatcher(
|
||||
message = "SMS_UNAVAILABLE: SMS not available on this device",
|
||||
)
|
||||
}
|
||||
InvokeCommandAvailability.ReadSmsAvailable,
|
||||
InvokeCommandAvailability.RequestableSmsSearchAvailable ->
|
||||
smsSearchAvailabilityError(
|
||||
readSmsAvailable = readSmsAvailable(),
|
||||
smsFeatureEnabled = smsFeatureEnabled(),
|
||||
smsTelephonyAvailable = smsTelephonyAvailable(),
|
||||
)
|
||||
InvokeCommandAvailability.CallLogAvailable ->
|
||||
if (callLogAvailable()) {
|
||||
null
|
||||
} else {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "CALL_LOG_UNAVAILABLE",
|
||||
message = "CALL_LOG_UNAVAILABLE: call log not available on this build",
|
||||
)
|
||||
}
|
||||
InvokeCommandAvailability.DebugBuild ->
|
||||
if (debugBuild()) {
|
||||
null
|
||||
|
||||
@@ -12,6 +12,8 @@ import java.time.format.DateTimeFormatter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
|
||||
class LocationCaptureManager(private val context: Context) {
|
||||
@@ -98,15 +100,18 @@ class LocationCaptureManager(private val context: Context) {
|
||||
val resolved =
|
||||
providers.firstOrNull { manager.isProviderEnabled(it) }
|
||||
?: throw IllegalStateException("LOCATION_UNAVAILABLE: no providers available")
|
||||
val location = withTimeout(timeoutMs.coerceAtLeast(1)) {
|
||||
suspendCancellableCoroutine<Location?> { cont ->
|
||||
return withTimeout(timeoutMs.coerceAtLeast(1)) {
|
||||
suspendCancellableCoroutine { cont ->
|
||||
val signal = CancellationSignal()
|
||||
cont.invokeOnCancellation { signal.cancel() }
|
||||
manager.getCurrentLocation(resolved, signal, context.mainExecutor) { location ->
|
||||
cont.resume(location) { _, _, _ -> }
|
||||
if (location != null) {
|
||||
cont.resume(location)
|
||||
} else {
|
||||
cont.resumeWithException(IllegalStateException("LOCATION_UNAVAILABLE: no fix"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return location ?: throw IllegalStateException("LOCATION_UNAVAILABLE: no fix")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,85 +8,27 @@ import androidx.core.content.ContextCompat
|
||||
import ai.openclaw.app.gateway.GatewaySession
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
internal interface LocationDataSource {
|
||||
fun hasFinePermission(context: Context): Boolean
|
||||
|
||||
fun hasCoarsePermission(context: Context): Boolean
|
||||
|
||||
suspend fun fetchLocation(
|
||||
desiredProviders: List<String>,
|
||||
maxAgeMs: Long?,
|
||||
timeoutMs: Long,
|
||||
isPrecise: Boolean,
|
||||
): LocationCaptureManager.Payload
|
||||
}
|
||||
|
||||
private class DefaultLocationDataSource(
|
||||
private val capture: LocationCaptureManager,
|
||||
) : LocationDataSource {
|
||||
override fun hasFinePermission(context: Context): Boolean =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
override fun hasCoarsePermission(context: Context): Boolean =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
override suspend fun fetchLocation(
|
||||
desiredProviders: List<String>,
|
||||
maxAgeMs: Long?,
|
||||
timeoutMs: Long,
|
||||
isPrecise: Boolean,
|
||||
): LocationCaptureManager.Payload =
|
||||
capture.getLocation(
|
||||
desiredProviders = desiredProviders,
|
||||
maxAgeMs = maxAgeMs,
|
||||
timeoutMs = timeoutMs,
|
||||
isPrecise = isPrecise,
|
||||
)
|
||||
}
|
||||
|
||||
class LocationHandler private constructor(
|
||||
class LocationHandler(
|
||||
private val appContext: Context,
|
||||
private val dataSource: LocationDataSource,
|
||||
private val location: LocationCaptureManager,
|
||||
private val json: Json,
|
||||
private val isForeground: () -> Boolean,
|
||||
private val locationPreciseEnabled: () -> Boolean,
|
||||
) {
|
||||
constructor(
|
||||
appContext: Context,
|
||||
location: LocationCaptureManager,
|
||||
json: Json,
|
||||
isForeground: () -> Boolean,
|
||||
locationPreciseEnabled: () -> Boolean,
|
||||
) : this(
|
||||
appContext = appContext,
|
||||
dataSource = DefaultLocationDataSource(location),
|
||||
json = json,
|
||||
isForeground = isForeground,
|
||||
locationPreciseEnabled = locationPreciseEnabled,
|
||||
)
|
||||
fun hasFineLocationPermission(): Boolean {
|
||||
return (
|
||||
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
|
||||
fun hasFineLocationPermission(): Boolean = dataSource.hasFinePermission(appContext)
|
||||
|
||||
fun hasCoarseLocationPermission(): Boolean = dataSource.hasCoarsePermission(appContext)
|
||||
|
||||
companion object {
|
||||
internal fun forTesting(
|
||||
appContext: Context,
|
||||
dataSource: LocationDataSource,
|
||||
json: Json = Json { ignoreUnknownKeys = true },
|
||||
isForeground: () -> Boolean = { true },
|
||||
locationPreciseEnabled: () -> Boolean = { true },
|
||||
): LocationHandler =
|
||||
LocationHandler(
|
||||
appContext = appContext,
|
||||
dataSource = dataSource,
|
||||
json = json,
|
||||
isForeground = isForeground,
|
||||
locationPreciseEnabled = locationPreciseEnabled,
|
||||
fun hasCoarseLocationPermission(): Boolean {
|
||||
return (
|
||||
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_COARSE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
|
||||
@@ -97,7 +39,7 @@ class LocationHandler private constructor(
|
||||
message = "LOCATION_BACKGROUND_UNAVAILABLE: location requires OpenClaw to stay open",
|
||||
)
|
||||
}
|
||||
if (!dataSource.hasFinePermission(appContext) && !dataSource.hasCoarsePermission(appContext)) {
|
||||
if (!hasFineLocationPermission() && !hasCoarseLocationPermission()) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "LOCATION_PERMISSION_REQUIRED",
|
||||
message = "LOCATION_PERMISSION_REQUIRED: grant Location permission",
|
||||
@@ -107,9 +49,9 @@ class LocationHandler private constructor(
|
||||
val preciseEnabled = locationPreciseEnabled()
|
||||
val accuracy =
|
||||
when (desiredAccuracy) {
|
||||
"precise" -> if (preciseEnabled && dataSource.hasFinePermission(appContext)) "precise" else "balanced"
|
||||
"precise" -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced"
|
||||
"coarse" -> "coarse"
|
||||
else -> if (preciseEnabled && dataSource.hasFinePermission(appContext)) "precise" else "balanced"
|
||||
else -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced"
|
||||
}
|
||||
val providers =
|
||||
when (accuracy) {
|
||||
@@ -119,7 +61,7 @@ class LocationHandler private constructor(
|
||||
}
|
||||
try {
|
||||
val payload =
|
||||
dataSource.fetchLocation(
|
||||
location.getLocation(
|
||||
desiredProviders = providers,
|
||||
maxAgeMs = maxAgeMs,
|
||||
timeoutMs = timeoutMs,
|
||||
|
||||
@@ -10,7 +10,6 @@ import android.os.SystemClock
|
||||
import androidx.core.content.ContextCompat
|
||||
import ai.openclaw.app.gateway.GatewaySession
|
||||
import java.time.Instant
|
||||
import kotlinx.coroutines.InternalCoroutinesApi
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.json.Json
|
||||
@@ -19,6 +18,7 @@ import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.sqrt
|
||||
@@ -142,18 +142,19 @@ private object SystemMotionDataSource : MotionDataSource {
|
||||
val averageDelta: Double,
|
||||
)
|
||||
|
||||
@OptIn(InternalCoroutinesApi::class)
|
||||
private suspend fun readStepCounter(sensorManager: SensorManager, sensor: Sensor): Int? {
|
||||
val sample =
|
||||
withTimeoutOrNull(1200L) {
|
||||
suspendCancellableCoroutine<Float?> { cont ->
|
||||
var resumed = false
|
||||
val listener =
|
||||
object : SensorEventListener {
|
||||
override fun onSensorChanged(event: SensorEvent?) {
|
||||
if (resumed) return
|
||||
val value = event?.values?.firstOrNull()
|
||||
val token = cont.tryResume(value) ?: return
|
||||
cont.completeResume(token)
|
||||
resumed = true
|
||||
sensorManager.unregisterListener(this)
|
||||
cont.resume(value)
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
|
||||
@@ -161,7 +162,8 @@ private object SystemMotionDataSource : MotionDataSource {
|
||||
val registered = sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL)
|
||||
if (!registered) {
|
||||
sensorManager.unregisterListener(listener)
|
||||
cont.resume(null) { _, _, _ -> }
|
||||
resumed = true
|
||||
cont.resume(null)
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
cont.invokeOnCancellation { sensorManager.unregisterListener(listener) }
|
||||
@@ -170,7 +172,6 @@ private object SystemMotionDataSource : MotionDataSource {
|
||||
return sample?.toInt()?.takeIf { it >= 0 }
|
||||
}
|
||||
|
||||
@OptIn(InternalCoroutinesApi::class)
|
||||
private suspend fun readAccelerometerSample(
|
||||
sensorManager: SensorManager,
|
||||
sensor: Sensor,
|
||||
@@ -180,6 +181,7 @@ private object SystemMotionDataSource : MotionDataSource {
|
||||
suspendCancellableCoroutine<AccelerometerSample?> { cont ->
|
||||
var count = 0
|
||||
var sumDelta = 0.0
|
||||
var resumed = false
|
||||
val listener =
|
||||
object : SensorEventListener {
|
||||
override fun onSensorChanged(event: SensorEvent?) {
|
||||
@@ -193,14 +195,15 @@ private object SystemMotionDataSource : MotionDataSource {
|
||||
).toDouble()
|
||||
sumDelta += abs(magnitude - SensorManager.GRAVITY_EARTH.toDouble())
|
||||
count += 1
|
||||
if (count >= ACCELEROMETER_SAMPLE_TARGET) {
|
||||
val result = AccelerometerSample(
|
||||
samples = count,
|
||||
averageDelta = sumDelta / count,
|
||||
)
|
||||
val token = cont.tryResume(result) ?: return
|
||||
cont.completeResume(token)
|
||||
if (count >= ACCELEROMETER_SAMPLE_TARGET && !resumed) {
|
||||
resumed = true
|
||||
sensorManager.unregisterListener(this)
|
||||
cont.resume(
|
||||
AccelerometerSample(
|
||||
samples = count,
|
||||
averageDelta = if (count == 0) 0.0 else sumDelta / count,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,7 +211,8 @@ private object SystemMotionDataSource : MotionDataSource {
|
||||
}
|
||||
val registered = sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL)
|
||||
if (!registered) {
|
||||
cont.resume(null) { _, _, _ -> }
|
||||
resumed = true
|
||||
cont.resume(null)
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
cont.invokeOnCancellation { sensorManager.unregisterListener(listener) }
|
||||
|
||||
@@ -71,22 +71,17 @@ private object SystemPhotosDataSource : PhotosDataSource {
|
||||
for (row in rows) {
|
||||
if (remainingBudget <= 0) break
|
||||
val bitmap = decodeScaledBitmap(resolver, row.uri, request.maxWidth) ?: continue
|
||||
try {
|
||||
val encoded = encodeJpegUnderBudget(bitmap, request.quality, MAX_PER_PHOTO_BASE64_CHARS)
|
||||
if (encoded == null) continue
|
||||
if (encoded.base64.length > remainingBudget) break
|
||||
remainingBudget -= encoded.base64.length
|
||||
out +=
|
||||
EncodedPhotoPayload(
|
||||
format = "jpeg",
|
||||
base64 = encoded.base64,
|
||||
width = encoded.width,
|
||||
height = encoded.height,
|
||||
createdAt = row.createdAtMs?.let { Instant.ofEpochMilli(it).toString() },
|
||||
)
|
||||
} finally {
|
||||
bitmap.recycle()
|
||||
}
|
||||
val encoded = encodeJpegUnderBudget(bitmap, request.quality, MAX_PER_PHOTO_BASE64_CHARS) ?: continue
|
||||
if (encoded.base64.length > remainingBudget) break
|
||||
remainingBudget -= encoded.base64.length
|
||||
out +=
|
||||
EncodedPhotoPayload(
|
||||
format = "jpeg",
|
||||
base64 = encoded.base64,
|
||||
width = encoded.width,
|
||||
height = encoded.height,
|
||||
createdAt = row.createdAtMs?.let { Instant.ofEpochMilli(it).toString() },
|
||||
)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -164,11 +159,7 @@ private object SystemPhotosDataSource : PhotosDataSource {
|
||||
|
||||
if (decoded.width <= maxWidth) return decoded
|
||||
val targetHeight = max(1, ((decoded.height.toDouble() * maxWidth) / decoded.width).roundToInt())
|
||||
return try {
|
||||
decoded.scale(maxWidth, targetHeight, true)
|
||||
} finally {
|
||||
decoded.recycle()
|
||||
}
|
||||
return decoded.scale(maxWidth, targetHeight, true)
|
||||
}
|
||||
|
||||
private fun computeInSampleSize(width: Int, maxWidth: Int): Int {
|
||||
@@ -187,36 +178,30 @@ private object SystemPhotosDataSource : PhotosDataSource {
|
||||
maxBase64Chars: Int,
|
||||
): EncodedJpeg? {
|
||||
var working = bitmap
|
||||
try {
|
||||
var jpegQuality = (quality.coerceIn(0.1, 1.0) * 100.0).roundToInt().coerceIn(10, 100)
|
||||
repeat(10) {
|
||||
val out = ByteArrayOutputStream()
|
||||
val ok = working.compress(Bitmap.CompressFormat.JPEG, jpegQuality, out)
|
||||
if (!ok) return null
|
||||
val bytes = out.toByteArray()
|
||||
val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)
|
||||
if (base64.length <= maxBase64Chars) {
|
||||
return EncodedJpeg(
|
||||
base64 = base64,
|
||||
width = working.width,
|
||||
height = working.height,
|
||||
)
|
||||
}
|
||||
if (jpegQuality > 35) {
|
||||
jpegQuality = max(25, jpegQuality - 15)
|
||||
return@repeat
|
||||
}
|
||||
val nextWidth = max(240, (working.width * 0.75f).roundToInt())
|
||||
if (nextWidth >= working.width) return null
|
||||
val nextHeight = max(1, ((working.height.toDouble() * nextWidth) / working.width).roundToInt())
|
||||
val previous = working
|
||||
working = working.scale(nextWidth, nextHeight, true)
|
||||
if (previous !== bitmap) previous.recycle()
|
||||
var jpegQuality = (quality.coerceIn(0.1, 1.0) * 100.0).roundToInt().coerceIn(10, 100)
|
||||
repeat(10) {
|
||||
val out = ByteArrayOutputStream()
|
||||
val ok = working.compress(Bitmap.CompressFormat.JPEG, jpegQuality, out)
|
||||
if (!ok) return null
|
||||
val bytes = out.toByteArray()
|
||||
val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)
|
||||
if (base64.length <= maxBase64Chars) {
|
||||
return EncodedJpeg(
|
||||
base64 = base64,
|
||||
width = working.width,
|
||||
height = working.height,
|
||||
)
|
||||
}
|
||||
return null
|
||||
} finally {
|
||||
if (working !== bitmap) working.recycle()
|
||||
if (jpegQuality > 35) {
|
||||
jpegQuality = max(25, jpegQuality - 15)
|
||||
return@repeat
|
||||
}
|
||||
val nextWidth = max(240, (working.width * 0.75f).roundToInt())
|
||||
if (nextWidth >= working.width) return null
|
||||
val nextHeight = max(1, ((working.height.toDouble() * nextWidth) / working.width).roundToInt())
|
||||
working = working.scale(nextWidth, nextHeight, true)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,28 +9,11 @@ class SmsHandler(
|
||||
val res = sms.send(paramsJson)
|
||||
if (res.ok) {
|
||||
return GatewaySession.InvokeResult.ok(res.payloadJson)
|
||||
} else {
|
||||
val error = res.error ?: "SMS_SEND_FAILED"
|
||||
val idx = error.indexOf(':')
|
||||
val code = if (idx > 0) error.substring(0, idx).trim() else "SMS_SEND_FAILED"
|
||||
return GatewaySession.InvokeResult.error(code = code, message = error)
|
||||
}
|
||||
return errorResult(res.error, defaultCode = "SMS_SEND_FAILED")
|
||||
}
|
||||
|
||||
suspend fun handleSmsSearch(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
val res = sms.search(paramsJson)
|
||||
if (res.ok) {
|
||||
return GatewaySession.InvokeResult.ok(res.payloadJson)
|
||||
}
|
||||
return errorResult(res.error, defaultCode = "SMS_SEARCH_FAILED")
|
||||
}
|
||||
|
||||
private fun errorResult(error: String?, defaultCode: String): GatewaySession.InvokeResult {
|
||||
val rawMessage = error ?: defaultCode
|
||||
val idx = rawMessage.indexOf(':')
|
||||
val code = if (idx > 0) rawMessage.substring(0, idx).trim() else defaultCode
|
||||
val message =
|
||||
if (idx > 0 && code == rawMessage.substring(0, idx).trim()) {
|
||||
rawMessage.substring(idx + 1).trim().ifEmpty { rawMessage }
|
||||
} else {
|
||||
rawMessage
|
||||
}
|
||||
return GatewaySession.InvokeResult.error(code = code, message = message)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -58,12 +58,9 @@ object OpenClawCanvasA2UIAction {
|
||||
}
|
||||
|
||||
fun jsDispatchA2UIActionStatus(actionId: String, ok: Boolean, error: String?): String {
|
||||
val err = jsonStringLiteral(error ?: "")
|
||||
val err = (error ?: "").replace("\\", "\\\\").replace("\"", "\\\"")
|
||||
val okLiteral = if (ok) "true" else "false"
|
||||
val idLiteral = jsonStringLiteral(actionId)
|
||||
return "window.dispatchEvent(new CustomEvent('openclaw:a2ui-action-status', { detail: { id: ${idLiteral}, ok: ${okLiteral}, error: ${err} } }));"
|
||||
val idEscaped = actionId.replace("\\", "\\\\").replace("\"", "\\\"")
|
||||
return "window.dispatchEvent(new CustomEvent('openclaw:a2ui-action-status', { detail: { id: \"${idEscaped}\", ok: ${okLiteral}, error: \"${err}\" } }));"
|
||||
}
|
||||
|
||||
private fun jsonStringLiteral(raw: String): String =
|
||||
JsonPrimitive(raw).toString().replace("\u2028", "\\u2028").replace("\u2029", "\\u2029")
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ enum class OpenClawCapability(val rawValue: String) {
|
||||
Contacts("contacts"),
|
||||
Calendar("calendar"),
|
||||
Motion("motion"),
|
||||
CallLog("callLog"),
|
||||
}
|
||||
|
||||
enum class OpenClawCanvasCommand(val rawValue: String) {
|
||||
@@ -53,7 +52,6 @@ enum class OpenClawCameraCommand(val rawValue: String) {
|
||||
|
||||
enum class OpenClawSmsCommand(val rawValue: String) {
|
||||
Send("sms.send"),
|
||||
Search("sms.search"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
@@ -139,12 +137,3 @@ enum class OpenClawMotionCommand(val rawValue: String) {
|
||||
const val NamespacePrefix: String = "motion."
|
||||
}
|
||||
}
|
||||
|
||||
enum class OpenClawCallLogCommand(val rawValue: String) {
|
||||
Search("callLog.search"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "callLog."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,15 +22,13 @@ import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.webkit.WebSettingsCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@Composable
|
||||
fun CanvasScreen(viewModel: MainViewModel, visible: Boolean, modifier: Modifier = Modifier) {
|
||||
fun CanvasScreen(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current
|
||||
val isDebuggable = (context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0
|
||||
val webViewRef = remember { mutableStateOf<WebView?>(null) }
|
||||
val currentPageUrlRef = remember { AtomicReference<String?>(null) }
|
||||
|
||||
DisposableEffect(viewModel) {
|
||||
onDispose {
|
||||
@@ -47,7 +45,6 @@ fun CanvasScreen(viewModel: MainViewModel, visible: Boolean, modifier: Modifier
|
||||
modifier = modifier,
|
||||
factory = {
|
||||
WebView(context).apply {
|
||||
visibility = if (visible) View.VISIBLE else View.INVISIBLE
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
|
||||
@@ -70,14 +67,6 @@ fun CanvasScreen(viewModel: MainViewModel, visible: Boolean, modifier: Modifier
|
||||
isHorizontalScrollBarEnabled = true
|
||||
webViewClient =
|
||||
object : WebViewClient() {
|
||||
override fun onPageStarted(
|
||||
view: WebView,
|
||||
url: String?,
|
||||
favicon: android.graphics.Bitmap?,
|
||||
) {
|
||||
currentPageUrlRef.set(url)
|
||||
}
|
||||
|
||||
override fun onReceivedError(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
@@ -100,7 +89,6 @@ fun CanvasScreen(viewModel: MainViewModel, visible: Boolean, modifier: Modifier
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView, url: String?) {
|
||||
currentPageUrlRef.set(url)
|
||||
if (isDebuggable) {
|
||||
Log.d("OpenClawWebView", "onPageFinished: $url")
|
||||
}
|
||||
@@ -133,27 +121,12 @@ fun CanvasScreen(viewModel: MainViewModel, visible: Boolean, modifier: Modifier
|
||||
}
|
||||
}
|
||||
|
||||
val bridge =
|
||||
CanvasA2UIActionBridge(
|
||||
isTrustedPage = { viewModel.isTrustedCanvasActionUrl(currentPageUrlRef.get()) },
|
||||
) { payload ->
|
||||
viewModel.handleCanvasA2UIActionFromWebView(payload)
|
||||
}
|
||||
val bridge = CanvasA2UIActionBridge { payload -> viewModel.handleCanvasA2UIActionFromWebView(payload) }
|
||||
addJavascriptInterface(bridge, CanvasA2UIActionBridge.interfaceName)
|
||||
viewModel.canvas.attach(this)
|
||||
webViewRef.value = this
|
||||
}
|
||||
},
|
||||
update = { webView ->
|
||||
webView.visibility = if (visible) View.VISIBLE else View.INVISIBLE
|
||||
if (visible) {
|
||||
webView.resumeTimers()
|
||||
webView.onResume()
|
||||
} else {
|
||||
webView.onPause()
|
||||
webView.pauseTimers()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -163,15 +136,11 @@ private fun disableForceDarkIfSupported(settings: WebSettings) {
|
||||
WebSettingsCompat.setForceDark(settings, WebSettingsCompat.FORCE_DARK_OFF)
|
||||
}
|
||||
|
||||
internal class CanvasA2UIActionBridge(
|
||||
private val isTrustedPage: () -> Boolean,
|
||||
private val onMessage: (String) -> Unit,
|
||||
) {
|
||||
private class CanvasA2UIActionBridge(private val onMessage: (String) -> Unit) {
|
||||
@JavascriptInterface
|
||||
fun postMessage(payload: String?) {
|
||||
val msg = payload?.trim().orEmpty()
|
||||
if (msg.isEmpty()) return
|
||||
if (!isTrustedPage()) return
|
||||
onMessage(msg)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -20,7 +20,6 @@ import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Link
|
||||
@@ -50,11 +49,8 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.gateway.GatewayEndpoint
|
||||
import ai.openclaw.app.ui.mobileCardSurface
|
||||
|
||||
private enum class ConnectInputMode {
|
||||
SetupCode,
|
||||
@@ -63,7 +59,6 @@ private enum class ConnectInputMode {
|
||||
|
||||
@Composable
|
||||
fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
val context = LocalContext.current
|
||||
val statusText by viewModel.statusText.collectAsState()
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val remoteAddress by viewModel.remoteAddress.collectAsState()
|
||||
@@ -72,7 +67,6 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
val manualTls by viewModel.manualTls.collectAsState()
|
||||
val manualEnabled by viewModel.manualEnabled.collectAsState()
|
||||
val gatewayToken by viewModel.gatewayToken.collectAsState()
|
||||
val gatewayBootstrapToken by viewModel.gatewayBootstrapToken.collectAsState()
|
||||
val pendingTrust by viewModel.pendingGatewayTrust.collectAsState()
|
||||
|
||||
var advancedOpen by rememberSaveable { mutableStateOf(false) }
|
||||
@@ -97,28 +91,20 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
val prompt = pendingTrust!!
|
||||
AlertDialog(
|
||||
onDismissRequest = { viewModel.declineGatewayTrustPrompt() },
|
||||
containerColor = mobileCardSurface,
|
||||
title = { Text("Trust this gateway?", style = mobileHeadline, color = mobileText) },
|
||||
title = { Text("Trust this gateway?") },
|
||||
text = {
|
||||
Text(
|
||||
"First-time TLS connection.\n\nVerify this SHA-256 fingerprint before trusting:\n${prompt.fingerprintSha256}",
|
||||
style = mobileCallout,
|
||||
color = mobileText,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { viewModel.acceptGatewayTrustPrompt() },
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = mobileAccent),
|
||||
) {
|
||||
TextButton(onClick = { viewModel.acceptGatewayTrustPrompt() }) {
|
||||
Text("Trust and continue")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = { viewModel.declineGatewayTrustPrompt() },
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = mobileTextSecondary),
|
||||
) {
|
||||
TextButton(onClick = { viewModel.declineGatewayTrustPrompt() }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
@@ -139,8 +125,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
val showDiagnostics = !isConnected && gatewayStatusHasDiagnostics(statusText)
|
||||
val statusLabel = gatewayStatusForDisplay(statusText)
|
||||
val primaryLabel = if (isConnected) "Disconnect Gateway" else "Connect Gateway"
|
||||
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
@@ -159,7 +144,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = mobileCardSurface,
|
||||
color = Color.White,
|
||||
border = BorderStroke(1.dp, mobileBorder),
|
||||
) {
|
||||
Column {
|
||||
@@ -220,7 +205,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = mobileCardSurface,
|
||||
containerColor = Color.White,
|
||||
contentColor = mobileDanger,
|
||||
),
|
||||
border = BorderStroke(1.dp, mobileDanger.copy(alpha = 0.4f)),
|
||||
@@ -242,13 +227,9 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
resolveGatewayConnectConfig(
|
||||
useSetupCode = inputMode == ConnectInputMode.SetupCode,
|
||||
setupCode = setupCode,
|
||||
savedManualHost = manualHost,
|
||||
savedManualPort = manualPort.toString(),
|
||||
savedManualTls = manualTls,
|
||||
manualHostInput = manualHostInput,
|
||||
manualPortInput = manualPortInput,
|
||||
manualTlsInput = manualTlsInput,
|
||||
fallbackBootstrapToken = gatewayBootstrapToken,
|
||||
manualHost = manualHostInput,
|
||||
manualPort = manualPortInput,
|
||||
manualTls = manualTlsInput,
|
||||
fallbackToken = gatewayToken,
|
||||
fallbackPassword = passwordInput,
|
||||
)
|
||||
@@ -256,23 +237,9 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
if (config == null) {
|
||||
validationText =
|
||||
if (inputMode == ConnectInputMode.SetupCode) {
|
||||
val parsedSetup = decodeGatewaySetupCode(setupCode)
|
||||
if (parsedSetup == null) {
|
||||
"Paste a valid setup code to connect."
|
||||
} else {
|
||||
val parsedGateway = parseGatewayEndpointResult(parsedSetup.url)
|
||||
gatewayEndpointValidationMessage(
|
||||
parsedGateway.error ?: GatewayEndpointValidationError.INVALID_URL,
|
||||
GatewayEndpointInputSource.SETUP_CODE,
|
||||
)
|
||||
}
|
||||
"Paste a valid setup code to connect."
|
||||
} else {
|
||||
val manualUrl = composeGatewayManualUrl(manualHostInput, manualPortInput, manualTlsInput)
|
||||
val parsedGateway = manualUrl?.let(::parseGatewayEndpointResult)
|
||||
gatewayEndpointValidationMessage(
|
||||
parsedGateway?.error ?: GatewayEndpointValidationError.INVALID_URL,
|
||||
GatewayEndpointInputSource.MANUAL,
|
||||
)
|
||||
"Enter a valid manual host and port to connect."
|
||||
}
|
||||
return@Button
|
||||
}
|
||||
@@ -289,12 +256,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
viewModel.setGatewayToken("")
|
||||
}
|
||||
viewModel.setGatewayPassword(config.password)
|
||||
viewModel.connect(
|
||||
GatewayEndpoint.manual(host = config.host, port = config.port),
|
||||
token = config.token.ifEmpty { null },
|
||||
bootstrapToken = config.bootstrapToken.ifEmpty { null },
|
||||
password = config.password.ifEmpty { null },
|
||||
)
|
||||
viewModel.connectManual()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
@@ -308,46 +270,6 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
if (showDiagnostics) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = mobileWarningSoft,
|
||||
border = BorderStroke(1.dp, mobileWarning.copy(alpha = 0.25f)),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text("Last gateway error", style = mobileHeadline, color = mobileWarning)
|
||||
Text(statusLabel, style = mobileBody.copy(fontFamily = FontFamily.Monospace), color = mobileText)
|
||||
Text("OpenClaw Android ${openClawAndroidVersionLabel()}", style = mobileCaption1, color = mobileTextSecondary)
|
||||
Button(
|
||||
onClick = {
|
||||
copyGatewayDiagnosticsReport(
|
||||
context = context,
|
||||
screen = "connect tab",
|
||||
gatewayAddress = activeEndpoint,
|
||||
statusText = statusLabel,
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(46.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = mobileCardSurface,
|
||||
contentColor = mobileWarning,
|
||||
),
|
||||
border = BorderStroke(1.dp, mobileWarning.copy(alpha = 0.3f)),
|
||||
) {
|
||||
Icon(Icons.Default.ContentCopy, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Copy Report for Claw", style = mobileCallout.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
@@ -376,7 +298,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = mobileCardSurface,
|
||||
color = Color.White,
|
||||
border = BorderStroke(1.dp, mobileBorder),
|
||||
) {
|
||||
Column(
|
||||
@@ -400,11 +322,6 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
Text("Run these on the gateway host:", style = mobileCallout, color = mobileTextSecondary)
|
||||
CommandBlock("openclaw qr --setup-code-only")
|
||||
CommandBlock("openclaw qr --json")
|
||||
Text(
|
||||
"For Tailscale or public hosts, use wss:// or Tailscale Serve. Private LAN ws:// remains supported.",
|
||||
style = mobileCaption1,
|
||||
color = mobileTextSecondary,
|
||||
)
|
||||
|
||||
if (inputMode == ConnectInputMode.SetupCode) {
|
||||
Text("Setup Code", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
@@ -487,11 +404,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text("Use TLS", style = mobileHeadline, color = mobileText)
|
||||
Text(
|
||||
"Turn this on for Tailscale or public hosts. Private LAN ws:// remains supported.",
|
||||
style = mobileCallout,
|
||||
color = mobileTextSecondary,
|
||||
)
|
||||
Text("Switch to secure websocket (`wss`).", style = mobileCallout, color = mobileTextSecondary)
|
||||
}
|
||||
Switch(
|
||||
checked = manualTlsInput,
|
||||
@@ -567,7 +480,7 @@ private fun MethodChip(label: String, active: Boolean, onClick: () -> Unit) {
|
||||
containerColor = if (active) mobileAccent else mobileSurface,
|
||||
contentColor = if (active) Color.White else mobileText,
|
||||
),
|
||||
border = BorderStroke(1.dp, if (active) mobileAccentBorderStrong else mobileBorderStrong),
|
||||
border = BorderStroke(1.dp, if (active) Color(0xFF184DAF) else mobileBorderStrong),
|
||||
) {
|
||||
Text(label, style = mobileCaption1.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
@@ -596,10 +509,10 @@ private fun CommandBlock(command: String) {
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = mobileCodeBg,
|
||||
border = BorderStroke(1.dp, mobileCodeBorder),
|
||||
border = BorderStroke(1.dp, Color(0xFF2B2E35)),
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(modifier = Modifier.width(3.dp).height(42.dp).background(mobileCodeAccent))
|
||||
Box(modifier = Modifier.width(3.dp).height(42.dp).background(Color(0xFF3FC97A)))
|
||||
Text(
|
||||
text = command,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.gateway.isPrivateLanGatewayHost
|
||||
import java.util.Base64
|
||||
import java.util.Locale
|
||||
import java.net.URI
|
||||
@@ -33,49 +32,20 @@ internal data class GatewayConnectConfig(
|
||||
val password: String,
|
||||
)
|
||||
|
||||
internal enum class GatewayEndpointValidationError {
|
||||
INVALID_URL,
|
||||
INSECURE_REMOTE_URL,
|
||||
}
|
||||
|
||||
internal enum class GatewayEndpointInputSource {
|
||||
SETUP_CODE,
|
||||
MANUAL,
|
||||
QR_SCAN,
|
||||
}
|
||||
|
||||
internal data class GatewayEndpointParseResult(
|
||||
val config: GatewayEndpointConfig? = null,
|
||||
val error: GatewayEndpointValidationError? = null,
|
||||
)
|
||||
|
||||
internal data class GatewayScannedSetupCodeResult(
|
||||
val setupCode: String? = null,
|
||||
val error: GatewayEndpointValidationError? = null,
|
||||
)
|
||||
|
||||
private val gatewaySetupJson = Json { ignoreUnknownKeys = true }
|
||||
private const val remoteGatewaySecurityRule =
|
||||
"Tailscale and public mobile nodes require wss:// or Tailscale Serve. ws:// is allowed for private LAN, localhost, and the Android emulator."
|
||||
private const val remoteGatewaySecurityFix =
|
||||
"Use a private LAN host/address, or enable Tailscale Serve / expose a wss:// gateway URL."
|
||||
|
||||
internal fun resolveGatewayConnectConfig(
|
||||
useSetupCode: Boolean,
|
||||
setupCode: String,
|
||||
savedManualHost: String,
|
||||
savedManualPort: String,
|
||||
savedManualTls: Boolean,
|
||||
manualHostInput: String,
|
||||
manualPortInput: String,
|
||||
manualTlsInput: Boolean,
|
||||
fallbackBootstrapToken: String,
|
||||
manualHost: String,
|
||||
manualPort: String,
|
||||
manualTls: Boolean,
|
||||
fallbackToken: String,
|
||||
fallbackPassword: String,
|
||||
): GatewayConnectConfig? {
|
||||
if (useSetupCode) {
|
||||
val setup = decodeGatewaySetupCode(setupCode) ?: return null
|
||||
val parsed = parseGatewayEndpointResult(setup.url).config ?: return null
|
||||
val parsed = parseGatewayEndpoint(setup.url) ?: return null
|
||||
val setupBootstrapToken = setup.bootstrapToken?.trim().orEmpty()
|
||||
val sharedToken =
|
||||
when {
|
||||
@@ -99,42 +69,26 @@ internal fun resolveGatewayConnectConfig(
|
||||
)
|
||||
}
|
||||
|
||||
val manualUrl = composeGatewayManualUrl(manualHostInput, manualPortInput, manualTlsInput) ?: return null
|
||||
val parsed = parseGatewayEndpointResult(manualUrl).config ?: return null
|
||||
val savedManualEndpoint =
|
||||
composeGatewayManualUrl(savedManualHost, savedManualPort, savedManualTls)
|
||||
?.let { parseGatewayEndpointResult(it).config }
|
||||
val preserveBootstrapToken =
|
||||
savedManualEndpoint != null &&
|
||||
savedManualEndpoint.host == parsed.host &&
|
||||
savedManualEndpoint.port == parsed.port &&
|
||||
savedManualEndpoint.tls == parsed.tls &&
|
||||
fallbackToken.isBlank() &&
|
||||
fallbackPassword.isBlank()
|
||||
val manualUrl = composeGatewayManualUrl(manualHost, manualPort, manualTls) ?: return null
|
||||
val parsed = parseGatewayEndpoint(manualUrl) ?: return null
|
||||
return GatewayConnectConfig(
|
||||
host = parsed.host,
|
||||
port = parsed.port,
|
||||
tls = parsed.tls,
|
||||
bootstrapToken = if (preserveBootstrapToken) fallbackBootstrapToken.trim() else "",
|
||||
bootstrapToken = "",
|
||||
token = fallbackToken.trim(),
|
||||
password = fallbackPassword.trim(),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? {
|
||||
return parseGatewayEndpointResult(rawInput).config
|
||||
}
|
||||
|
||||
internal fun parseGatewayEndpointResult(rawInput: String): GatewayEndpointParseResult {
|
||||
val raw = rawInput.trim()
|
||||
if (raw.isEmpty()) return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL)
|
||||
if (raw.isEmpty()) return null
|
||||
|
||||
val normalized = if (raw.contains("://")) raw else "https://$raw"
|
||||
val uri =
|
||||
runCatching { URI(normalized) }.getOrNull()
|
||||
?: return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL)
|
||||
val host = uri.host?.trim()?.trim('[', ']').orEmpty()
|
||||
if (host.isEmpty()) return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL)
|
||||
val uri = runCatching { URI(normalized) }.getOrNull() ?: return null
|
||||
val host = uri.host?.trim().orEmpty()
|
||||
if (host.isEmpty()) return null
|
||||
|
||||
val scheme = uri.scheme?.trim()?.lowercase(Locale.US).orEmpty()
|
||||
val tls =
|
||||
@@ -143,33 +97,10 @@ internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? {
|
||||
"wss", "https" -> true
|
||||
else -> true
|
||||
}
|
||||
if (!tls && !isPrivateLanGatewayHost(host)) {
|
||||
return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INSECURE_REMOTE_URL)
|
||||
}
|
||||
val defaultPort =
|
||||
when (scheme) {
|
||||
"wss", "https" -> 443
|
||||
"ws", "http" -> 18789
|
||||
else -> 443
|
||||
}
|
||||
val displayPort =
|
||||
when (scheme) {
|
||||
"wss", "https" -> 443
|
||||
"ws", "http" -> 80
|
||||
else -> 443
|
||||
}
|
||||
val port = uri.port.takeIf { it in 1..65535 } ?: defaultPort
|
||||
val displayHost = if (host.contains(":")) "[$host]" else host
|
||||
val displayUrl =
|
||||
if (port == displayPort && defaultPort == displayPort) {
|
||||
"${if (tls) "https" else "http"}://$displayHost"
|
||||
} else {
|
||||
"${if (tls) "https" else "http"}://$displayHost:$port"
|
||||
}
|
||||
val port = uri.port.takeIf { it in 1..65535 } ?: if (tls) 443 else 18789
|
||||
val displayUrl = "${if (tls) "https" else "http"}://$host:$port"
|
||||
|
||||
return GatewayEndpointParseResult(
|
||||
config = GatewayEndpointConfig(host = host, port = port, tls = tls, displayUrl = displayUrl),
|
||||
)
|
||||
return GatewayEndpointConfig(host = host, port = port, tls = tls, displayUrl = displayUrl)
|
||||
}
|
||||
|
||||
internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? {
|
||||
@@ -200,44 +131,8 @@ internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? {
|
||||
}
|
||||
|
||||
internal fun resolveScannedSetupCode(rawInput: String): String? {
|
||||
return resolveScannedSetupCodeResult(rawInput).setupCode
|
||||
}
|
||||
|
||||
internal fun resolveScannedSetupCodeResult(rawInput: String): GatewayScannedSetupCodeResult {
|
||||
val setupCode =
|
||||
resolveSetupCodeCandidate(rawInput)
|
||||
?: return GatewayScannedSetupCodeResult(error = GatewayEndpointValidationError.INVALID_URL)
|
||||
val decoded =
|
||||
decodeGatewaySetupCode(setupCode)
|
||||
?: return GatewayScannedSetupCodeResult(error = GatewayEndpointValidationError.INVALID_URL)
|
||||
val parsed = parseGatewayEndpointResult(decoded.url)
|
||||
if (parsed.config == null) {
|
||||
return GatewayScannedSetupCodeResult(error = parsed.error)
|
||||
}
|
||||
return GatewayScannedSetupCodeResult(setupCode = setupCode)
|
||||
}
|
||||
|
||||
internal fun gatewayEndpointValidationMessage(
|
||||
error: GatewayEndpointValidationError,
|
||||
source: GatewayEndpointInputSource,
|
||||
): String {
|
||||
return when (error) {
|
||||
GatewayEndpointValidationError.INSECURE_REMOTE_URL ->
|
||||
when (source) {
|
||||
GatewayEndpointInputSource.SETUP_CODE ->
|
||||
"Setup code points to an insecure remote gateway. $remoteGatewaySecurityRule $remoteGatewaySecurityFix"
|
||||
GatewayEndpointInputSource.QR_SCAN ->
|
||||
"QR code points to an insecure remote gateway. $remoteGatewaySecurityRule $remoteGatewaySecurityFix"
|
||||
GatewayEndpointInputSource.MANUAL ->
|
||||
"$remoteGatewaySecurityRule $remoteGatewaySecurityFix"
|
||||
}
|
||||
GatewayEndpointValidationError.INVALID_URL ->
|
||||
when (source) {
|
||||
GatewayEndpointInputSource.SETUP_CODE -> "Setup code has invalid gateway URL."
|
||||
GatewayEndpointInputSource.QR_SCAN -> "QR code did not contain a valid setup code."
|
||||
GatewayEndpointInputSource.MANUAL -> "Enter a valid manual host and port to connect."
|
||||
}
|
||||
}
|
||||
val setupCode = resolveSetupCodeCandidate(rawInput) ?: return null
|
||||
return setupCode.takeIf { decodeGatewaySetupCode(it) != null }
|
||||
}
|
||||
|
||||
internal fun composeGatewayManualUrl(hostInput: String, portInput: String, tls: Boolean): String? {
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import ai.openclaw.app.BuildConfig
|
||||
|
||||
internal fun openClawAndroidVersionLabel(): String {
|
||||
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
|
||||
return if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
|
||||
"$versionName-dev"
|
||||
} else {
|
||||
versionName
|
||||
}
|
||||
}
|
||||
|
||||
internal fun gatewayStatusForDisplay(statusText: String): String {
|
||||
return statusText.trim().ifEmpty { "Offline" }
|
||||
}
|
||||
|
||||
internal fun gatewayStatusHasDiagnostics(statusText: String): Boolean {
|
||||
val lower = gatewayStatusForDisplay(statusText).lowercase()
|
||||
return lower != "offline" && !lower.contains("connecting")
|
||||
}
|
||||
|
||||
internal fun gatewayStatusLooksLikePairing(statusText: String): Boolean {
|
||||
val lower = gatewayStatusForDisplay(statusText).lowercase()
|
||||
return lower.contains("pair") || lower.contains("approve")
|
||||
}
|
||||
|
||||
internal fun buildGatewayDiagnosticsReport(
|
||||
screen: String,
|
||||
gatewayAddress: String,
|
||||
statusText: String,
|
||||
): String {
|
||||
val device =
|
||||
listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
||||
.joinToString(" ")
|
||||
.trim()
|
||||
.ifEmpty { "Android" }
|
||||
val androidVersion = Build.VERSION.RELEASE?.trim().orEmpty().ifEmpty { Build.VERSION.SDK_INT.toString() }
|
||||
val endpoint = gatewayAddress.trim().ifEmpty { "unknown" }
|
||||
val status = gatewayStatusForDisplay(statusText)
|
||||
return """
|
||||
Help diagnose this OpenClaw Android gateway connection failure.
|
||||
|
||||
Please:
|
||||
- pick one route only: same machine, same LAN, Tailscale, or public URL
|
||||
- classify this as pairing/auth, TLS trust, wrong advertised route, wrong address/port, or gateway down
|
||||
- remember: Tailscale/public mobile routes require wss:// or Tailscale Serve; private LAN ws:// is still allowed
|
||||
- quote the exact app status/error below
|
||||
- tell me whether `openclaw devices list` should show a pending pairing request
|
||||
- if more signal is needed, ask for `openclaw qr --json`, `openclaw devices list`, and `openclaw nodes status`
|
||||
- give the next exact command or tap
|
||||
|
||||
Debug info:
|
||||
- screen: $screen
|
||||
- app version: ${openClawAndroidVersionLabel()}
|
||||
- device: $device
|
||||
- android: $androidVersion (SDK ${Build.VERSION.SDK_INT})
|
||||
- gateway address: $endpoint
|
||||
- status/error: $status
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
internal fun copyGatewayDiagnosticsReport(
|
||||
context: Context,
|
||||
screen: String,
|
||||
gatewayAddress: String,
|
||||
statusText: String,
|
||||
) {
|
||||
val clipboard = context.getSystemService(ClipboardManager::class.java) ?: return
|
||||
val report = buildGatewayDiagnosticsReport(screen = screen, gatewayAddress = gatewayAddress, statusText = statusText)
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("OpenClaw gateway diagnostics", report))
|
||||
Toast.makeText(context, "Copied gateway diagnostics", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
@@ -11,147 +9,32 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import ai.openclaw.app.R
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MobileColors – semantic color tokens with light + dark variants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
internal data class MobileColors(
|
||||
val surface: Color,
|
||||
val surfaceStrong: Color,
|
||||
val cardSurface: Color,
|
||||
val border: Color,
|
||||
val borderStrong: Color,
|
||||
val text: Color,
|
||||
val textSecondary: Color,
|
||||
val textTertiary: Color,
|
||||
val accent: Color,
|
||||
val accentSoft: Color,
|
||||
val accentBorderStrong: Color,
|
||||
val success: Color,
|
||||
val successSoft: Color,
|
||||
val warning: Color,
|
||||
val warningSoft: Color,
|
||||
val danger: Color,
|
||||
val dangerSoft: Color,
|
||||
val codeBg: Color,
|
||||
val codeText: Color,
|
||||
val codeBorder: Color,
|
||||
val codeAccent: Color,
|
||||
val chipBorderConnected: Color,
|
||||
val chipBorderConnecting: Color,
|
||||
val chipBorderWarning: Color,
|
||||
val chipBorderError: Color,
|
||||
)
|
||||
|
||||
internal fun lightMobileColors() =
|
||||
MobileColors(
|
||||
surface = Color(0xFFF6F7FA),
|
||||
surfaceStrong = Color(0xFFECEEF3),
|
||||
cardSurface = Color(0xFFFFFFFF),
|
||||
border = Color(0xFFE5E7EC),
|
||||
borderStrong = Color(0xFFD6DAE2),
|
||||
text = Color(0xFF17181C),
|
||||
textSecondary = Color(0xFF5D6472),
|
||||
textTertiary = Color(0xFF99A0AE),
|
||||
accent = Color(0xFF1D5DD8),
|
||||
accentSoft = Color(0xFFECF3FF),
|
||||
accentBorderStrong = Color(0xFF184DAF),
|
||||
success = Color(0xFF2F8C5A),
|
||||
successSoft = Color(0xFFEEF9F3),
|
||||
warning = Color(0xFFC8841A),
|
||||
warningSoft = Color(0xFFFFF8EC),
|
||||
danger = Color(0xFFD04B4B),
|
||||
dangerSoft = Color(0xFFFFF2F2),
|
||||
codeBg = Color(0xFF15171B),
|
||||
codeText = Color(0xFFE8EAEE),
|
||||
codeBorder = Color(0xFF2B2E35),
|
||||
codeAccent = Color(0xFF3FC97A),
|
||||
chipBorderConnected = Color(0xFFCFEBD8),
|
||||
chipBorderConnecting = Color(0xFFD5E2FA),
|
||||
chipBorderWarning = Color(0xFFEED8B8),
|
||||
chipBorderError = Color(0xFFF3C8C8),
|
||||
internal val mobileBackgroundGradient =
|
||||
Brush.verticalGradient(
|
||||
listOf(
|
||||
Color(0xFFFFFFFF),
|
||||
Color(0xFFF7F8FA),
|
||||
Color(0xFFEFF1F5),
|
||||
),
|
||||
)
|
||||
|
||||
internal fun darkMobileColors() =
|
||||
MobileColors(
|
||||
surface = Color(0xFF1A1C20),
|
||||
surfaceStrong = Color(0xFF24262B),
|
||||
cardSurface = Color(0xFF1E2024),
|
||||
border = Color(0xFF2E3038),
|
||||
borderStrong = Color(0xFF3A3D46),
|
||||
text = Color(0xFFE4E5EA),
|
||||
textSecondary = Color(0xFFA0A6B4),
|
||||
textTertiary = Color(0xFF6B7280),
|
||||
accent = Color(0xFF6EA8FF),
|
||||
accentSoft = Color(0xFF1A2A44),
|
||||
accentBorderStrong = Color(0xFF5B93E8),
|
||||
success = Color(0xFF5FBB85),
|
||||
successSoft = Color(0xFF152E22),
|
||||
warning = Color(0xFFE8A844),
|
||||
warningSoft = Color(0xFF2E2212),
|
||||
danger = Color(0xFFE87070),
|
||||
dangerSoft = Color(0xFF2E1616),
|
||||
codeBg = Color(0xFF111317),
|
||||
codeText = Color(0xFFE8EAEE),
|
||||
codeBorder = Color(0xFF2B2E35),
|
||||
codeAccent = Color(0xFF3FC97A),
|
||||
chipBorderConnected = Color(0xFF1E4A30),
|
||||
chipBorderConnecting = Color(0xFF1E3358),
|
||||
chipBorderWarning = Color(0xFF3E3018),
|
||||
chipBorderError = Color(0xFF3E1E1E),
|
||||
)
|
||||
|
||||
internal val LocalMobileColors = staticCompositionLocalOf { lightMobileColors() }
|
||||
|
||||
internal object MobileColorsAccessor {
|
||||
val current: MobileColors
|
||||
@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.
|
||||
|
||||
internal val mobileSurface: Color @Composable get() = LocalMobileColors.current.surface
|
||||
internal val mobileSurfaceStrong: Color @Composable get() = LocalMobileColors.current.surfaceStrong
|
||||
internal val mobileCardSurface: Color @Composable get() = LocalMobileColors.current.cardSurface
|
||||
internal val mobileBorder: Color @Composable get() = LocalMobileColors.current.border
|
||||
internal val mobileBorderStrong: Color @Composable get() = LocalMobileColors.current.borderStrong
|
||||
internal val mobileText: Color @Composable get() = LocalMobileColors.current.text
|
||||
internal val mobileTextSecondary: Color @Composable get() = LocalMobileColors.current.textSecondary
|
||||
internal val mobileTextTertiary: Color @Composable get() = LocalMobileColors.current.textTertiary
|
||||
internal val mobileAccent: Color @Composable get() = LocalMobileColors.current.accent
|
||||
internal val mobileAccentSoft: Color @Composable get() = LocalMobileColors.current.accentSoft
|
||||
internal val mobileAccentBorderStrong: Color @Composable get() = LocalMobileColors.current.accentBorderStrong
|
||||
internal val mobileSuccess: Color @Composable get() = LocalMobileColors.current.success
|
||||
internal val mobileSuccessSoft: Color @Composable get() = LocalMobileColors.current.successSoft
|
||||
internal val mobileWarning: Color @Composable get() = LocalMobileColors.current.warning
|
||||
internal val mobileWarningSoft: Color @Composable get() = LocalMobileColors.current.warningSoft
|
||||
internal val mobileDanger: Color @Composable get() = LocalMobileColors.current.danger
|
||||
internal val mobileDangerSoft: Color @Composable get() = LocalMobileColors.current.dangerSoft
|
||||
internal val mobileCodeBg: Color @Composable get() = LocalMobileColors.current.codeBg
|
||||
internal val mobileCodeText: Color @Composable get() = LocalMobileColors.current.codeText
|
||||
internal val mobileCodeBorder: Color @Composable get() = LocalMobileColors.current.codeBorder
|
||||
internal val mobileCodeAccent: Color @Composable get() = LocalMobileColors.current.codeAccent
|
||||
|
||||
// Background gradient – light fades white→gray, dark fades near-black→dark-gray
|
||||
internal val mobileBackgroundGradient: Brush
|
||||
@Composable get() {
|
||||
val colors = LocalMobileColors.current
|
||||
return Brush.verticalGradient(
|
||||
listOf(
|
||||
colors.surface,
|
||||
colors.surfaceStrong,
|
||||
colors.surfaceStrong,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Typography tokens (theme-independent)
|
||||
// ---------------------------------------------------------------------------
|
||||
internal val mobileSurface = Color(0xFFF6F7FA)
|
||||
internal val mobileSurfaceStrong = Color(0xFFECEEF3)
|
||||
internal val mobileBorder = Color(0xFFE5E7EC)
|
||||
internal val mobileBorderStrong = Color(0xFFD6DAE2)
|
||||
internal val mobileText = Color(0xFF17181C)
|
||||
internal val mobileTextSecondary = Color(0xFF5D6472)
|
||||
internal val mobileTextTertiary = Color(0xFF99A0AE)
|
||||
internal val mobileAccent = Color(0xFF1D5DD8)
|
||||
internal val mobileAccentSoft = Color(0xFFECF3FF)
|
||||
internal val mobileSuccess = Color(0xFF2F8C5A)
|
||||
internal val mobileSuccessSoft = Color(0xFFEEF9F3)
|
||||
internal val mobileWarning = Color(0xFFC8841A)
|
||||
internal val mobileWarningSoft = Color(0xFFFFF8EC)
|
||||
internal val mobileDanger = Color(0xFFD04B4B)
|
||||
internal val mobileDangerSoft = Color(0xFFFFF2F2)
|
||||
internal val mobileCodeBg = Color(0xFF15171B)
|
||||
internal val mobileCodeText = Color(0xFFE8EAEE)
|
||||
|
||||
internal val mobileFontFamily =
|
||||
FontFamily(
|
||||
@@ -161,15 +44,6 @@ internal val mobileFontFamily =
|
||||
Font(resId = R.font.manrope_700_bold, weight = FontWeight.Bold),
|
||||
)
|
||||
|
||||
internal val mobileDisplay =
|
||||
TextStyle(
|
||||
fontFamily = mobileFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 34.sp,
|
||||
lineHeight = 40.sp,
|
||||
letterSpacing = (-0.8).sp,
|
||||
)
|
||||
|
||||
internal val mobileTitle1 =
|
||||
TextStyle(
|
||||
fontFamily = mobileFontFamily,
|
||||
|
||||
@@ -9,7 +9,6 @@ import android.hardware.SensorManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
@@ -61,7 +60,6 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.ChatBubble
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Link
|
||||
@@ -83,6 +81,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
@@ -93,10 +92,9 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import ai.openclaw.app.BuildConfig
|
||||
import ai.openclaw.app.LocationMode
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.gateway.GatewayEndpoint
|
||||
import ai.openclaw.app.R
|
||||
import ai.openclaw.app.node.DeviceNotificationListenerService
|
||||
import com.google.mlkit.vision.barcode.common.Barcode
|
||||
import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions
|
||||
@@ -125,94 +123,107 @@ private enum class PermissionToggle {
|
||||
Calendar,
|
||||
Motion,
|
||||
Sms,
|
||||
CallLog,
|
||||
}
|
||||
|
||||
private enum class SpecialAccessToggle {
|
||||
NotificationListener,
|
||||
}
|
||||
|
||||
private val onboardingBackgroundGradient: Brush
|
||||
@Composable get() = mobileBackgroundGradient
|
||||
private val onboardingBackgroundGradient =
|
||||
listOf(
|
||||
Color(0xFFFFFFFF),
|
||||
Color(0xFFF7F8FA),
|
||||
Color(0xFFEFF1F5),
|
||||
)
|
||||
private val onboardingSurface = Color(0xFFF6F7FA)
|
||||
private val onboardingBorder = Color(0xFFE5E7EC)
|
||||
private val onboardingBorderStrong = Color(0xFFD6DAE2)
|
||||
private val onboardingText = Color(0xFF17181C)
|
||||
private val onboardingTextSecondary = Color(0xFF4D5563)
|
||||
private val onboardingTextTertiary = Color(0xFF8A92A2)
|
||||
private val onboardingAccent = Color(0xFF1D5DD8)
|
||||
private val onboardingAccentSoft = Color(0xFFECF3FF)
|
||||
private val onboardingSuccess = Color(0xFF2F8C5A)
|
||||
private val onboardingWarning = Color(0xFFC8841A)
|
||||
private val onboardingCommandBg = Color(0xFF15171B)
|
||||
private val onboardingCommandBorder = Color(0xFF2B2E35)
|
||||
private val onboardingCommandAccent = Color(0xFF3FC97A)
|
||||
private val onboardingCommandText = Color(0xFFE8EAEE)
|
||||
|
||||
private val onboardingSurface: Color
|
||||
@Composable get() = mobileCardSurface
|
||||
private val onboardingFontFamily =
|
||||
FontFamily(
|
||||
Font(resId = R.font.manrope_400_regular, weight = FontWeight.Normal),
|
||||
Font(resId = R.font.manrope_500_medium, weight = FontWeight.Medium),
|
||||
Font(resId = R.font.manrope_600_semibold, weight = FontWeight.SemiBold),
|
||||
Font(resId = R.font.manrope_700_bold, weight = FontWeight.Bold),
|
||||
)
|
||||
|
||||
private val onboardingBorder: Color
|
||||
@Composable get() = mobileBorder
|
||||
private val onboardingDisplayStyle =
|
||||
TextStyle(
|
||||
fontFamily = onboardingFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 34.sp,
|
||||
lineHeight = 40.sp,
|
||||
letterSpacing = (-0.8).sp,
|
||||
)
|
||||
|
||||
private val onboardingBorderStrong: Color
|
||||
@Composable get() = mobileBorderStrong
|
||||
private val onboardingTitle1Style =
|
||||
TextStyle(
|
||||
fontFamily = onboardingFontFamily,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 24.sp,
|
||||
lineHeight = 30.sp,
|
||||
letterSpacing = (-0.5).sp,
|
||||
)
|
||||
|
||||
private val onboardingText: Color
|
||||
@Composable get() = mobileText
|
||||
private val onboardingHeadlineStyle =
|
||||
TextStyle(
|
||||
fontFamily = onboardingFontFamily,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 22.sp,
|
||||
letterSpacing = (-0.1).sp,
|
||||
)
|
||||
|
||||
private val onboardingTextSecondary: Color
|
||||
@Composable get() = mobileTextSecondary
|
||||
private val onboardingBodyStyle =
|
||||
TextStyle(
|
||||
fontFamily = onboardingFontFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 22.sp,
|
||||
)
|
||||
|
||||
private val onboardingTextTertiary: Color
|
||||
@Composable get() = mobileTextTertiary
|
||||
private val onboardingCalloutStyle =
|
||||
TextStyle(
|
||||
fontFamily = onboardingFontFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
)
|
||||
|
||||
private val onboardingAccent: Color
|
||||
@Composable get() = mobileAccent
|
||||
private val onboardingCaption1Style =
|
||||
TextStyle(
|
||||
fontFamily = onboardingFontFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.2.sp,
|
||||
)
|
||||
|
||||
private val onboardingAccentSoft: Color
|
||||
@Composable get() = mobileAccentSoft
|
||||
|
||||
private val onboardingAccentBorderStrong: Color
|
||||
@Composable get() = mobileAccentBorderStrong
|
||||
|
||||
private val onboardingSuccess: Color
|
||||
@Composable get() = mobileSuccess
|
||||
|
||||
private val onboardingSuccessSoft: Color
|
||||
@Composable get() = mobileSuccessSoft
|
||||
|
||||
private val onboardingWarning: Color
|
||||
@Composable get() = mobileWarning
|
||||
|
||||
private val onboardingWarningSoft: Color
|
||||
@Composable get() = mobileWarningSoft
|
||||
|
||||
private val onboardingCommandBg: Color
|
||||
@Composable get() = mobileCodeBg
|
||||
|
||||
private val onboardingCommandBorder: Color
|
||||
@Composable get() = mobileCodeBorder
|
||||
|
||||
private val onboardingCommandAccent: Color
|
||||
@Composable get() = mobileCodeAccent
|
||||
|
||||
private val onboardingCommandText: Color
|
||||
@Composable get() = mobileCodeText
|
||||
|
||||
private val onboardingDisplayStyle: TextStyle
|
||||
get() = mobileDisplay
|
||||
|
||||
private val onboardingTitle1Style: TextStyle
|
||||
get() = mobileTitle1
|
||||
|
||||
private val onboardingHeadlineStyle: TextStyle
|
||||
get() = mobileHeadline
|
||||
|
||||
private val onboardingBodyStyle: TextStyle
|
||||
get() = mobileBody
|
||||
|
||||
private val onboardingCalloutStyle: TextStyle
|
||||
get() = mobileCallout
|
||||
|
||||
private val onboardingCaption1Style: TextStyle
|
||||
get() = mobileCaption1
|
||||
|
||||
private val onboardingCaption2Style: TextStyle
|
||||
get() = mobileCaption2
|
||||
private val onboardingCaption2Style =
|
||||
TextStyle(
|
||||
fontFamily = onboardingFontFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 14.sp,
|
||||
letterSpacing = 0.4.sp,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val statusText by viewModel.statusText.collectAsState()
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val isNodeConnected by viewModel.isNodeConnected.collectAsState()
|
||||
val serverName by viewModel.serverName.collectAsState()
|
||||
val remoteAddress by viewModel.remoteAddress.collectAsState()
|
||||
val persistedGatewayToken by viewModel.gatewayToken.collectAsState()
|
||||
@@ -229,7 +240,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
var manualTls by rememberSaveable { mutableStateOf(false) }
|
||||
var gatewayError by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var attemptedConnect by rememberSaveable { mutableStateOf(false) }
|
||||
val canFinishOnboarding = canFinishOnboarding(isConnected = isConnected, isNodeConnected = isNodeConnected)
|
||||
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val qrScannerOptions =
|
||||
@@ -242,10 +252,8 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
|
||||
val smsAvailable =
|
||||
remember(context) {
|
||||
BuildConfig.OPENCLAW_ENABLE_SMS &&
|
||||
context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
|
||||
context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
|
||||
}
|
||||
val callLogAvailable = remember { BuildConfig.OPENCLAW_ENABLE_CALL_LOG }
|
||||
val motionAvailable =
|
||||
remember(context) {
|
||||
hasMotionCapabilities(context)
|
||||
@@ -295,15 +303,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
}
|
||||
var enableSms by
|
||||
rememberSaveable {
|
||||
mutableStateOf(
|
||||
smsAvailable &&
|
||||
isPermissionGranted(context, Manifest.permission.SEND_SMS) &&
|
||||
isPermissionGranted(context, Manifest.permission.READ_SMS)
|
||||
)
|
||||
}
|
||||
var enableCallLog by
|
||||
rememberSaveable {
|
||||
mutableStateOf(callLogAvailable && isPermissionGranted(context, Manifest.permission.READ_CALL_LOG))
|
||||
mutableStateOf(smsAvailable && isPermissionGranted(context, Manifest.permission.SEND_SMS))
|
||||
}
|
||||
|
||||
var pendingPermissionToggle by remember { mutableStateOf<PermissionToggle?>(null) }
|
||||
@@ -321,7 +321,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
PermissionToggle.Calendar -> enableCalendar = enabled
|
||||
PermissionToggle.Motion -> enableMotion = enabled && motionAvailable
|
||||
PermissionToggle.Sms -> enableSms = enabled && smsAvailable
|
||||
PermissionToggle.CallLog -> enableCallLog = enabled && callLogAvailable
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,11 +347,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
!motionPermissionRequired ||
|
||||
isPermissionGranted(context, Manifest.permission.ACTIVITY_RECOGNITION)
|
||||
PermissionToggle.Sms ->
|
||||
!smsAvailable ||
|
||||
(isPermissionGranted(context, Manifest.permission.SEND_SMS) &&
|
||||
isPermissionGranted(context, Manifest.permission.READ_SMS))
|
||||
PermissionToggle.CallLog ->
|
||||
!callLogAvailable || isPermissionGranted(context, Manifest.permission.READ_CALL_LOG)
|
||||
!smsAvailable || isPermissionGranted(context, Manifest.permission.SEND_SMS)
|
||||
}
|
||||
|
||||
fun setSpecialAccessToggleEnabled(toggle: SpecialAccessToggle, enabled: Boolean) {
|
||||
@@ -374,9 +369,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
enableCalendar,
|
||||
enableMotion,
|
||||
enableSms,
|
||||
enableCallLog,
|
||||
smsAvailable,
|
||||
callLogAvailable,
|
||||
motionAvailable,
|
||||
) {
|
||||
val enabled = mutableListOf<String>()
|
||||
@@ -391,7 +384,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
if (enableCalendar) enabled += "Calendar"
|
||||
if (enableMotion && motionAvailable) enabled += "Motion"
|
||||
if (smsAvailable && enableSms) enabled += "SMS"
|
||||
if (callLogAvailable && enableCallLog) enabled += "Call Log"
|
||||
if (enabled.isEmpty()) "None selected" else enabled.joinToString(", ")
|
||||
}
|
||||
|
||||
@@ -480,28 +472,19 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
val prompt = pendingTrust!!
|
||||
AlertDialog(
|
||||
onDismissRequest = { viewModel.declineGatewayTrustPrompt() },
|
||||
containerColor = onboardingSurface,
|
||||
title = { Text("Trust this gateway?", style = onboardingHeadlineStyle, color = onboardingText) },
|
||||
title = { Text("Trust this gateway?") },
|
||||
text = {
|
||||
Text(
|
||||
"First-time TLS connection.\n\nVerify this SHA-256 fingerprint before trusting:\n${prompt.fingerprintSha256}",
|
||||
style = onboardingCalloutStyle,
|
||||
color = onboardingText,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { viewModel.acceptGatewayTrustPrompt() },
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = onboardingAccent),
|
||||
) {
|
||||
TextButton(onClick = { viewModel.acceptGatewayTrustPrompt() }) {
|
||||
Text("Trust and continue")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = { viewModel.declineGatewayTrustPrompt() },
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = onboardingTextSecondary),
|
||||
) {
|
||||
TextButton(onClick = { viewModel.declineGatewayTrustPrompt() }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
@@ -512,7 +495,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.background(onboardingBackgroundGradient),
|
||||
.background(Brush.verticalGradient(onboardingBackgroundGradient)),
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
@@ -566,16 +549,12 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
if (contents.isEmpty()) {
|
||||
return@addOnSuccessListener
|
||||
}
|
||||
val scannedSetupCode = resolveScannedSetupCodeResult(contents)
|
||||
if (scannedSetupCode.setupCode == null) {
|
||||
gatewayError =
|
||||
gatewayEndpointValidationMessage(
|
||||
scannedSetupCode.error ?: GatewayEndpointValidationError.INVALID_URL,
|
||||
GatewayEndpointInputSource.QR_SCAN,
|
||||
)
|
||||
val scannedSetupCode = resolveScannedSetupCode(contents)
|
||||
if (scannedSetupCode == null) {
|
||||
gatewayError = "QR code did not contain a valid setup code."
|
||||
return@addOnSuccessListener
|
||||
}
|
||||
setupCode = scannedSetupCode.setupCode
|
||||
setupCode = scannedSetupCode
|
||||
gatewayInputMode = GatewayInputMode.SetupCode
|
||||
gatewayError = null
|
||||
attemptedConnect = false
|
||||
@@ -624,8 +603,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
motionPermissionRequired = motionPermissionRequired,
|
||||
enableSms = enableSms,
|
||||
smsAvailable = smsAvailable,
|
||||
callLogAvailable = callLogAvailable,
|
||||
enableCallLog = enableCallLog,
|
||||
context = context,
|
||||
onDiscoveryChange = { checked ->
|
||||
requestPermissionToggle(
|
||||
@@ -719,18 +696,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
requestPermissionToggle(
|
||||
PermissionToggle.Sms,
|
||||
checked,
|
||||
listOf(Manifest.permission.SEND_SMS, Manifest.permission.READ_SMS),
|
||||
)
|
||||
}
|
||||
},
|
||||
onCallLogChange = { checked ->
|
||||
if (!callLogAvailable) {
|
||||
setPermissionToggleEnabled(PermissionToggle.CallLog, false)
|
||||
} else {
|
||||
requestPermissionToggle(
|
||||
PermissionToggle.CallLog,
|
||||
checked,
|
||||
listOf(Manifest.permission.READ_CALL_LOG),
|
||||
listOf(Manifest.permission.SEND_SMS),
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -739,7 +705,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
FinalStep(
|
||||
parsedGateway = parseGatewayEndpoint(gatewayUrl),
|
||||
statusText = statusText,
|
||||
isConnected = canFinishOnboarding,
|
||||
isConnected = isConnected,
|
||||
serverName = serverName,
|
||||
remoteAddress = remoteAddress,
|
||||
attemptedConnect = attemptedConnect,
|
||||
@@ -789,7 +755,13 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
onClick = { step = OnboardingStep.Gateway },
|
||||
modifier = Modifier.weight(1f).height(52.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = onboardingPrimaryButtonColors(),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = onboardingAccent,
|
||||
contentColor = Color.White,
|
||||
disabledContainerColor = onboardingAccent.copy(alpha = 0.45f),
|
||||
disabledContentColor = Color.White,
|
||||
),
|
||||
) {
|
||||
Text("Next", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
@@ -803,13 +775,9 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
gatewayError = "Scan QR code first, or use Advanced setup."
|
||||
return@Button
|
||||
}
|
||||
val parsedGateway = parseGatewayEndpointResult(parsedSetup.url)
|
||||
if (parsedGateway.config == null) {
|
||||
gatewayError =
|
||||
gatewayEndpointValidationMessage(
|
||||
parsedGateway.error ?: GatewayEndpointValidationError.INVALID_URL,
|
||||
GatewayEndpointInputSource.SETUP_CODE,
|
||||
)
|
||||
val parsedGateway = parseGatewayEndpoint(parsedSetup.url)
|
||||
if (parsedGateway == null) {
|
||||
gatewayError = "Setup code has invalid gateway URL."
|
||||
return@Button
|
||||
}
|
||||
gatewayUrl = parsedSetup.url
|
||||
@@ -827,23 +795,25 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
}
|
||||
} else {
|
||||
val manualUrl = composeGatewayManualUrl(manualHost, manualPort, manualTls)
|
||||
val parsedGateway = manualUrl?.let(::parseGatewayEndpointResult)
|
||||
if (parsedGateway?.config == null) {
|
||||
gatewayError =
|
||||
gatewayEndpointValidationMessage(
|
||||
parsedGateway?.error ?: GatewayEndpointValidationError.INVALID_URL,
|
||||
GatewayEndpointInputSource.MANUAL,
|
||||
)
|
||||
val parsedGateway = manualUrl?.let(::parseGatewayEndpoint)
|
||||
if (parsedGateway == null) {
|
||||
gatewayError = "Manual endpoint is invalid."
|
||||
return@Button
|
||||
}
|
||||
gatewayUrl = parsedGateway.config.displayUrl
|
||||
gatewayUrl = parsedGateway.displayUrl
|
||||
viewModel.setGatewayBootstrapToken("")
|
||||
}
|
||||
step = OnboardingStep.Permissions
|
||||
},
|
||||
modifier = Modifier.weight(1f).height(52.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = onboardingPrimaryButtonColors(),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = onboardingAccent,
|
||||
contentColor = Color.White,
|
||||
disabledContainerColor = onboardingAccent.copy(alpha = 0.45f),
|
||||
disabledContentColor = Color.White,
|
||||
),
|
||||
) {
|
||||
Text("Next", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
@@ -857,41 +827,49 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
},
|
||||
modifier = Modifier.weight(1f).height(52.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = onboardingPrimaryButtonColors(),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = onboardingAccent,
|
||||
contentColor = Color.White,
|
||||
disabledContainerColor = onboardingAccent.copy(alpha = 0.45f),
|
||||
disabledContentColor = Color.White,
|
||||
),
|
||||
) {
|
||||
Text("Next", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
}
|
||||
OnboardingStep.FinalCheck -> {
|
||||
if (canFinishOnboarding) {
|
||||
if (isConnected) {
|
||||
Button(
|
||||
onClick = { viewModel.setOnboardingCompleted(true) },
|
||||
modifier = Modifier.weight(1f).height(52.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = onboardingPrimaryButtonColors(),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = onboardingAccent,
|
||||
contentColor = Color.White,
|
||||
disabledContainerColor = onboardingAccent.copy(alpha = 0.45f),
|
||||
disabledContentColor = Color.White,
|
||||
),
|
||||
) {
|
||||
Text("Finish", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = {
|
||||
val parsed = parseGatewayEndpointResult(gatewayUrl)
|
||||
if (parsed.config == null) {
|
||||
val parsed = parseGatewayEndpoint(gatewayUrl)
|
||||
if (parsed == null) {
|
||||
step = OnboardingStep.Gateway
|
||||
gatewayError =
|
||||
gatewayEndpointValidationMessage(
|
||||
parsed.error ?: GatewayEndpointValidationError.INVALID_URL,
|
||||
GatewayEndpointInputSource.MANUAL,
|
||||
)
|
||||
gatewayError = "Invalid gateway URL."
|
||||
return@Button
|
||||
}
|
||||
val token = persistedGatewayToken.trim()
|
||||
val password = gatewayPassword.trim()
|
||||
attemptedConnect = true
|
||||
viewModel.setManualEnabled(true)
|
||||
viewModel.setManualHost(parsed.config.host)
|
||||
viewModel.setManualPort(parsed.config.port)
|
||||
viewModel.setManualTls(parsed.config.tls)
|
||||
viewModel.setManualHost(parsed.host)
|
||||
viewModel.setManualPort(parsed.port)
|
||||
viewModel.setManualTls(parsed.tls)
|
||||
if (gatewayInputMode == GatewayInputMode.Manual) {
|
||||
viewModel.setGatewayBootstrapToken("")
|
||||
}
|
||||
@@ -901,21 +879,17 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
viewModel.setGatewayToken("")
|
||||
}
|
||||
viewModel.setGatewayPassword(password)
|
||||
viewModel.connect(
|
||||
GatewayEndpoint.manual(host = parsed.config.host, port = parsed.config.port),
|
||||
token = token.ifEmpty { null },
|
||||
bootstrapToken =
|
||||
if (gatewayInputMode == GatewayInputMode.SetupCode) {
|
||||
decodeGatewaySetupCode(setupCode)?.bootstrapToken?.trim()?.ifEmpty { null }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
password = password.ifEmpty { null },
|
||||
)
|
||||
viewModel.connectManual()
|
||||
},
|
||||
modifier = Modifier.weight(1f).height(52.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = onboardingPrimaryButtonColors(),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = onboardingAccent,
|
||||
contentColor = Color.White,
|
||||
disabledContainerColor = onboardingAccent.copy(alpha = 0.45f),
|
||||
disabledContentColor = Color.White,
|
||||
),
|
||||
) {
|
||||
Text("Connect", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
@@ -927,40 +901,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
}
|
||||
}
|
||||
|
||||
internal fun canFinishOnboarding(isConnected: Boolean, isNodeConnected: Boolean): Boolean {
|
||||
return isConnected && isNodeConnected
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun onboardingPrimaryButtonColors() =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = onboardingAccent,
|
||||
contentColor = Color.White,
|
||||
disabledContainerColor = onboardingAccent.copy(alpha = 0.45f),
|
||||
disabledContentColor = Color.White.copy(alpha = 0.9f),
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun onboardingTextFieldColors() =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = onboardingSurface,
|
||||
unfocusedContainerColor = onboardingSurface,
|
||||
focusedBorderColor = onboardingAccent,
|
||||
unfocusedBorderColor = onboardingBorder,
|
||||
focusedTextColor = onboardingText,
|
||||
unfocusedTextColor = onboardingText,
|
||||
cursorColor = onboardingAccent,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun onboardingSwitchColors() =
|
||||
SwitchDefaults.colors(
|
||||
checkedTrackColor = onboardingAccent,
|
||||
uncheckedTrackColor = onboardingBorderStrong,
|
||||
checkedThumbColor = Color.White,
|
||||
uncheckedThumbColor = Color.White,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun StepRail(current: OnboardingStep) {
|
||||
val steps = OnboardingStep.entries
|
||||
@@ -1056,7 +996,7 @@ private fun GatewayStep(
|
||||
|
||||
StepShell(title = "Gateway Connection") {
|
||||
Text(
|
||||
"Run `openclaw qr` on your gateway host, then scan the code with this device. For Tailscale or public hosts, use wss:// or Tailscale Serve.",
|
||||
"Run `openclaw qr` on your gateway host, then scan the code with this device.",
|
||||
style = onboardingCalloutStyle,
|
||||
color = onboardingTextSecondary,
|
||||
)
|
||||
@@ -1065,7 +1005,11 @@ private fun GatewayStep(
|
||||
onClick = onScanQrClick,
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = onboardingPrimaryButtonColors(),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = onboardingAccent,
|
||||
contentColor = Color.White,
|
||||
),
|
||||
) {
|
||||
Text("Scan QR code", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
@@ -1088,7 +1032,7 @@ private fun GatewayStep(
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text("Advanced setup", style = onboardingHeadlineStyle, color = onboardingText)
|
||||
Text("Paste setup code or enter host/port manually. Private LAN ws:// is supported; Tailscale/public hosts need wss://.", style = onboardingCaption1Style, color = onboardingTextSecondary)
|
||||
Text("Paste setup code or enter host/port manually.", style = onboardingCaption1Style, color = onboardingTextSecondary)
|
||||
}
|
||||
Icon(
|
||||
imageVector = if (advancedOpen) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||
@@ -1115,7 +1059,15 @@ private fun GatewayStep(
|
||||
textStyle = onboardingBodyStyle.copy(fontFamily = FontFamily.Monospace, color = onboardingText),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
onboardingTextFieldColors(),
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = onboardingSurface,
|
||||
unfocusedContainerColor = onboardingSurface,
|
||||
focusedBorderColor = onboardingAccent,
|
||||
unfocusedBorderColor = onboardingBorder,
|
||||
focusedTextColor = onboardingText,
|
||||
unfocusedTextColor = onboardingText,
|
||||
cursorColor = onboardingAccent,
|
||||
),
|
||||
)
|
||||
if (!resolvedEndpoint.isNullOrBlank()) {
|
||||
ResolvedEndpoint(endpoint = resolvedEndpoint)
|
||||
@@ -1145,7 +1097,15 @@ private fun GatewayStep(
|
||||
textStyle = onboardingBodyStyle.copy(color = onboardingText),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
onboardingTextFieldColors(),
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = onboardingSurface,
|
||||
unfocusedContainerColor = onboardingSurface,
|
||||
focusedBorderColor = onboardingAccent,
|
||||
unfocusedBorderColor = onboardingBorder,
|
||||
focusedTextColor = onboardingText,
|
||||
unfocusedTextColor = onboardingText,
|
||||
cursorColor = onboardingAccent,
|
||||
),
|
||||
)
|
||||
|
||||
Text("PORT", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
|
||||
@@ -1159,7 +1119,15 @@ private fun GatewayStep(
|
||||
textStyle = onboardingBodyStyle.copy(fontFamily = FontFamily.Monospace, color = onboardingText),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
onboardingTextFieldColors(),
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = onboardingSurface,
|
||||
unfocusedContainerColor = onboardingSurface,
|
||||
focusedBorderColor = onboardingAccent,
|
||||
unfocusedBorderColor = onboardingBorder,
|
||||
focusedTextColor = onboardingText,
|
||||
unfocusedTextColor = onboardingText,
|
||||
cursorColor = onboardingAccent,
|
||||
),
|
||||
)
|
||||
|
||||
Row(
|
||||
@@ -1169,17 +1137,18 @@ private fun GatewayStep(
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text("Use TLS", style = onboardingHeadlineStyle, color = onboardingText)
|
||||
Text(
|
||||
"Turn this on for Tailscale or public hosts. Private LAN ws:// remains supported.",
|
||||
style = onboardingCalloutStyle.copy(lineHeight = 18.sp),
|
||||
color = onboardingTextSecondary,
|
||||
)
|
||||
Text("Switch to secure websocket (`wss`).", style = onboardingCalloutStyle.copy(lineHeight = 18.sp), color = onboardingTextSecondary)
|
||||
}
|
||||
Switch(
|
||||
checked = manualTls,
|
||||
onCheckedChange = onManualTlsChange,
|
||||
colors =
|
||||
onboardingSwitchColors(),
|
||||
SwitchDefaults.colors(
|
||||
checkedTrackColor = onboardingAccent,
|
||||
uncheckedTrackColor = onboardingBorderStrong,
|
||||
checkedThumbColor = Color.White,
|
||||
uncheckedThumbColor = Color.White,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1194,7 +1163,15 @@ private fun GatewayStep(
|
||||
textStyle = onboardingBodyStyle.copy(color = onboardingText),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
onboardingTextFieldColors(),
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = onboardingSurface,
|
||||
unfocusedContainerColor = onboardingSurface,
|
||||
focusedBorderColor = onboardingAccent,
|
||||
unfocusedBorderColor = onboardingBorder,
|
||||
focusedTextColor = onboardingText,
|
||||
unfocusedTextColor = onboardingText,
|
||||
cursorColor = onboardingAccent,
|
||||
),
|
||||
)
|
||||
|
||||
Text("PASSWORD (OPTIONAL)", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
|
||||
@@ -1208,7 +1185,15 @@ private fun GatewayStep(
|
||||
textStyle = onboardingBodyStyle.copy(color = onboardingText),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
onboardingTextFieldColors(),
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = onboardingSurface,
|
||||
unfocusedContainerColor = onboardingSurface,
|
||||
focusedBorderColor = onboardingAccent,
|
||||
unfocusedBorderColor = onboardingBorder,
|
||||
focusedTextColor = onboardingText,
|
||||
unfocusedTextColor = onboardingText,
|
||||
cursorColor = onboardingAccent,
|
||||
),
|
||||
)
|
||||
|
||||
if (!manualResolvedEndpoint.isNullOrBlank()) {
|
||||
@@ -1276,7 +1261,7 @@ private fun GatewayModeChip(
|
||||
containerColor = if (active) onboardingAccent else onboardingSurface,
|
||||
contentColor = if (active) Color.White else onboardingText,
|
||||
),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, if (active) onboardingAccentBorderStrong else onboardingBorderStrong),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, if (active) Color(0xFF184DAF) else onboardingBorderStrong),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
@@ -1354,8 +1339,6 @@ private fun PermissionsStep(
|
||||
motionPermissionRequired: Boolean,
|
||||
enableSms: Boolean,
|
||||
smsAvailable: Boolean,
|
||||
callLogAvailable: Boolean,
|
||||
enableCallLog: Boolean,
|
||||
context: Context,
|
||||
onDiscoveryChange: (Boolean) -> Unit,
|
||||
onLocationChange: (Boolean) -> Unit,
|
||||
@@ -1368,7 +1351,6 @@ private fun PermissionsStep(
|
||||
onCalendarChange: (Boolean) -> Unit,
|
||||
onMotionChange: (Boolean) -> Unit,
|
||||
onSmsChange: (Boolean) -> Unit,
|
||||
onCallLogChange: (Boolean) -> Unit,
|
||||
) {
|
||||
val discoveryPermission = if (Build.VERSION.SDK_INT >= 33) Manifest.permission.NEARBY_WIFI_DEVICES else Manifest.permission.ACCESS_FINE_LOCATION
|
||||
val locationGranted =
|
||||
@@ -1493,25 +1475,12 @@ private fun PermissionsStep(
|
||||
InlineDivider()
|
||||
PermissionToggleRow(
|
||||
title = "SMS",
|
||||
subtitle = "Send and search text messages via the gateway",
|
||||
subtitle = "Send text messages via the gateway",
|
||||
checked = enableSms,
|
||||
granted =
|
||||
isPermissionGranted(context, Manifest.permission.SEND_SMS) ||
|
||||
isPermissionGranted(context, Manifest.permission.READ_SMS),
|
||||
granted = isPermissionGranted(context, Manifest.permission.SEND_SMS),
|
||||
onCheckedChange = onSmsChange,
|
||||
)
|
||||
}
|
||||
if (callLogAvailable) {
|
||||
InlineDivider()
|
||||
PermissionToggleRow(
|
||||
title = "Call Log",
|
||||
subtitle = "callLog.search",
|
||||
checked = enableCallLog,
|
||||
granted = isPermissionGranted(context, Manifest.permission.READ_CALL_LOG),
|
||||
onCheckedChange = onCallLogChange,
|
||||
)
|
||||
}
|
||||
Text("All settings can be changed later in Settings.", style = onboardingCalloutStyle, color = onboardingTextSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1555,7 +1524,13 @@ private fun PermissionToggleRow(
|
||||
checked = checked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
enabled = enabled,
|
||||
colors = onboardingSwitchColors(),
|
||||
colors =
|
||||
SwitchDefaults.colors(
|
||||
checkedTrackColor = onboardingAccent,
|
||||
uncheckedTrackColor = onboardingBorderStrong,
|
||||
checkedThumbColor = Color.White,
|
||||
uncheckedThumbColor = Color.White,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1571,12 +1546,6 @@ private fun FinalStep(
|
||||
enabledPermissions: String,
|
||||
methodLabel: String,
|
||||
) {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val gatewayAddress = parsedGateway?.displayUrl ?: "Invalid gateway URL"
|
||||
val statusLabel = gatewayStatusForDisplay(statusText)
|
||||
val showDiagnostics = gatewayStatusHasDiagnostics(statusText)
|
||||
val pairingRequired = gatewayStatusLooksLikePairing(statusText)
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text("Review", style = onboardingTitle1Style, color = onboardingText)
|
||||
|
||||
@@ -1589,7 +1558,7 @@ private fun FinalStep(
|
||||
SummaryCard(
|
||||
icon = Icons.Default.Cloud,
|
||||
label = "Gateway",
|
||||
value = gatewayAddress,
|
||||
value = parsedGateway?.displayUrl ?: "Invalid gateway URL",
|
||||
accentColor = Color(0xFF7C5AC7),
|
||||
)
|
||||
SummaryCard(
|
||||
@@ -1636,7 +1605,7 @@ private fun FinalStep(
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = onboardingSuccessSoft,
|
||||
color = Color(0xFFEEF9F3),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, onboardingSuccess.copy(alpha = 0.2f)),
|
||||
) {
|
||||
Row(
|
||||
@@ -1672,8 +1641,8 @@ private fun FinalStep(
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = onboardingWarningSoft,
|
||||
border = BorderStroke(1.dp, onboardingWarning.copy(alpha = 0.2f)),
|
||||
color = Color(0xFFFFF8EC),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, onboardingWarning.copy(alpha = 0.2f)),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
@@ -1698,67 +1667,13 @@ private fun FinalStep(
|
||||
)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
if (pairingRequired) "Pairing Required" else "Connection Failed",
|
||||
style = onboardingHeadlineStyle,
|
||||
color = onboardingWarning,
|
||||
)
|
||||
Text(
|
||||
if (pairingRequired) {
|
||||
"Approve this phone on the gateway host, or copy the report below."
|
||||
} else {
|
||||
"Copy this report and give it to your Claw."
|
||||
},
|
||||
style = onboardingCalloutStyle,
|
||||
color = onboardingTextSecondary,
|
||||
)
|
||||
Text("Pairing Required", style = onboardingHeadlineStyle, color = onboardingWarning)
|
||||
Text("Run these on your gateway host:", style = onboardingCalloutStyle, color = onboardingTextSecondary)
|
||||
}
|
||||
}
|
||||
Text("Status", style = onboardingCaption1Style.copy(fontWeight = FontWeight.Bold), color = onboardingTextSecondary)
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = onboardingCommandBg,
|
||||
border = BorderStroke(1.dp, onboardingCommandBorder),
|
||||
) {
|
||||
Text(
|
||||
statusLabel,
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
style = onboardingCalloutStyle.copy(fontFamily = FontFamily.Monospace),
|
||||
color = onboardingCommandText,
|
||||
)
|
||||
}
|
||||
if (showDiagnostics) {
|
||||
Text("Error", style = onboardingCaption1Style.copy(fontWeight = FontWeight.Bold), color = onboardingTextSecondary)
|
||||
Text(
|
||||
"OpenClaw Android ${openClawAndroidVersionLabel()}",
|
||||
style = onboardingCaption1Style,
|
||||
color = onboardingTextSecondary,
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
copyGatewayDiagnosticsReport(
|
||||
context = context,
|
||||
screen = "onboarding final check",
|
||||
gatewayAddress = gatewayAddress,
|
||||
statusText = statusLabel,
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = onboardingSurface, contentColor = onboardingWarning),
|
||||
border = BorderStroke(1.dp, onboardingWarning.copy(alpha = 0.3f)),
|
||||
) {
|
||||
Icon(Icons.Default.ContentCopy, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Copy Report for Claw", style = onboardingCalloutStyle.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
}
|
||||
if (pairingRequired) {
|
||||
CommandBlock("openclaw devices list")
|
||||
CommandBlock("openclaw devices approve <requestId>")
|
||||
Text("Then tap Connect again.", style = onboardingCalloutStyle, color = onboardingTextSecondary)
|
||||
}
|
||||
CommandBlock("openclaw devices list")
|
||||
CommandBlock("openclaw devices approve <requestId>")
|
||||
Text("Then tap Connect again.", style = onboardingCalloutStyle, color = onboardingTextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,20 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
@Composable
|
||||
fun OpenClawTheme(content: @Composable () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val isDark = isSystemInDarkTheme()
|
||||
val colorScheme = if (isDark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
val mobileColors = if (isDark) darkMobileColors() else lightMobileColors()
|
||||
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = (view.context as Activity).window
|
||||
WindowCompat.getInsetsController(window, window.decorView)
|
||||
.isAppearanceLightStatusBars = !isDark
|
||||
}
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalMobileColors provides mobileColors) {
|
||||
MaterialTheme(colorScheme = colorScheme, content = content)
|
||||
}
|
||||
MaterialTheme(colorScheme = colorScheme, content = content)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -39,14 +39,11 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import ai.openclaw.app.HomeDestination
|
||||
import ai.openclaw.app.MainViewModel
|
||||
|
||||
private enum class HomeTab(
|
||||
@@ -71,33 +68,10 @@ private enum class StatusVisual {
|
||||
@Composable
|
||||
fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
var activeTab by rememberSaveable { mutableStateOf(HomeTab.Connect) }
|
||||
var chatTabStarted by rememberSaveable { mutableStateOf(false) }
|
||||
var screenTabStarted by rememberSaveable { mutableStateOf(false) }
|
||||
val requestedHomeDestination by viewModel.requestedHomeDestination.collectAsState()
|
||||
|
||||
LaunchedEffect(requestedHomeDestination) {
|
||||
val destination = requestedHomeDestination ?: return@LaunchedEffect
|
||||
activeTab =
|
||||
when (destination) {
|
||||
HomeDestination.Connect -> HomeTab.Connect
|
||||
HomeDestination.Chat -> HomeTab.Chat
|
||||
HomeDestination.Voice -> HomeTab.Voice
|
||||
HomeDestination.Screen -> HomeTab.Screen
|
||||
HomeDestination.Settings -> HomeTab.Settings
|
||||
}
|
||||
viewModel.clearRequestedHomeDestination()
|
||||
}
|
||||
|
||||
// Stop TTS when user navigates away from voice tab, and lazily keep the Chat/Screen tabs
|
||||
// alive after the first visit so repeated tab switches do not rebuild their UI trees.
|
||||
// Stop TTS when user navigates away from voice tab
|
||||
LaunchedEffect(activeTab) {
|
||||
viewModel.setVoiceScreenActive(activeTab == HomeTab.Voice)
|
||||
if (activeTab == HomeTab.Chat) {
|
||||
chatTabStarted = true
|
||||
}
|
||||
if (activeTab == HomeTab.Screen) {
|
||||
screenTabStarted = true
|
||||
}
|
||||
}
|
||||
|
||||
val statusText by viewModel.statusText.collectAsState()
|
||||
@@ -146,35 +120,11 @@ fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier)
|
||||
.consumeWindowInsets(innerPadding)
|
||||
.background(mobileBackgroundGradient),
|
||||
) {
|
||||
if (chatTabStarted) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.matchParentSize()
|
||||
.alpha(if (activeTab == HomeTab.Chat) 1f else 0f)
|
||||
.zIndex(if (activeTab == HomeTab.Chat) 1f else 0f),
|
||||
) {
|
||||
ChatSheet(viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
if (screenTabStarted) {
|
||||
ScreenTabScreen(
|
||||
viewModel = viewModel,
|
||||
visible = activeTab == HomeTab.Screen,
|
||||
modifier =
|
||||
Modifier
|
||||
.matchParentSize()
|
||||
.alpha(if (activeTab == HomeTab.Screen) 1f else 0f)
|
||||
.zIndex(if (activeTab == HomeTab.Screen) 1f else 0f),
|
||||
)
|
||||
}
|
||||
|
||||
when (activeTab) {
|
||||
HomeTab.Connect -> ConnectTabScreen(viewModel = viewModel)
|
||||
HomeTab.Chat -> if (!chatTabStarted) ChatSheet(viewModel = viewModel)
|
||||
HomeTab.Chat -> ChatSheet(viewModel = viewModel)
|
||||
HomeTab.Voice -> VoiceTabScreen(viewModel = viewModel)
|
||||
HomeTab.Screen -> Unit
|
||||
HomeTab.Screen -> ScreenTabScreen(viewModel = viewModel)
|
||||
HomeTab.Settings -> SettingsSheet(viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
@@ -182,19 +132,16 @@ fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScreenTabScreen(viewModel: MainViewModel, visible: Boolean, modifier: Modifier = Modifier) {
|
||||
private fun ScreenTabScreen(viewModel: MainViewModel) {
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
var refreshedForCurrentConnection by rememberSaveable(isConnected) { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(isConnected, visible, refreshedForCurrentConnection) {
|
||||
if (visible && isConnected && !refreshedForCurrentConnection) {
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
viewModel.refreshHomeCanvasOverviewIfConnected()
|
||||
refreshedForCurrentConnection = true
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
CanvasScreen(viewModel = viewModel, visible = visible, modifier = Modifier.fillMaxSize())
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
CanvasScreen(viewModel = viewModel, modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,28 +159,28 @@ private fun TopStatusBar(
|
||||
mobileSuccessSoft,
|
||||
mobileSuccess,
|
||||
mobileSuccess,
|
||||
LocalMobileColors.current.chipBorderConnected,
|
||||
Color(0xFFCFEBD8),
|
||||
)
|
||||
StatusVisual.Connecting ->
|
||||
listOf(
|
||||
mobileAccentSoft,
|
||||
mobileAccent,
|
||||
mobileAccent,
|
||||
LocalMobileColors.current.chipBorderConnecting,
|
||||
Color(0xFFD5E2FA),
|
||||
)
|
||||
StatusVisual.Warning ->
|
||||
listOf(
|
||||
mobileWarningSoft,
|
||||
mobileWarning,
|
||||
mobileWarning,
|
||||
LocalMobileColors.current.chipBorderWarning,
|
||||
Color(0xFFEED8B8),
|
||||
)
|
||||
StatusVisual.Error ->
|
||||
listOf(
|
||||
mobileDangerSoft,
|
||||
mobileDanger,
|
||||
mobileDanger,
|
||||
LocalMobileColors.current.chipBorderError,
|
||||
Color(0xFFF3C8C8),
|
||||
)
|
||||
StatusVisual.Offline ->
|
||||
listOf(
|
||||
@@ -302,7 +249,7 @@ private fun BottomTabBar(
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = mobileCardSurface.copy(alpha = 0.97f),
|
||||
color = Color.White.copy(alpha = 0.97f),
|
||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||
border = BorderStroke(1.dp, mobileBorder),
|
||||
shadowElevation = 6.dp,
|
||||
@@ -323,7 +270,7 @@ private fun BottomTabBar(
|
||||
modifier = Modifier.weight(1f).heightIn(min = 58.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = if (active) mobileAccentSoft else Color.Transparent,
|
||||
border = if (active) BorderStroke(1.dp, LocalMobileColors.current.chipBorderConnecting) else null,
|
||||
border = if (active) BorderStroke(1.dp, Color(0xFFD5E2FA)) else null,
|
||||
shadowElevation = 0.dp,
|
||||
) {
|
||||
Column(
|
||||
|
||||
@@ -9,7 +9,6 @@ import android.hardware.SensorManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.app.role.RoleManager
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
@@ -35,6 +34,7 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
@@ -54,23 +54,20 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import ai.openclaw.app.BuildConfig
|
||||
import ai.openclaw.app.LocationMode
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.normalizeLocalHourMinute
|
||||
import ai.openclaw.app.NotificationPackageFilterMode
|
||||
import ai.openclaw.app.node.DeviceNotificationListenerService
|
||||
|
||||
@Composable
|
||||
@@ -84,55 +81,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
val locationPreciseEnabled by viewModel.locationPreciseEnabled.collectAsState()
|
||||
val preventSleep by viewModel.preventSleep.collectAsState()
|
||||
val canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.collectAsState()
|
||||
val notificationForwardingEnabled by viewModel.notificationForwardingEnabled.collectAsState()
|
||||
val notificationForwardingMode by viewModel.notificationForwardingMode.collectAsState()
|
||||
val notificationForwardingPackages by viewModel.notificationForwardingPackages.collectAsState()
|
||||
val notificationForwardingQuietHoursEnabled by viewModel.notificationForwardingQuietHoursEnabled.collectAsState()
|
||||
val notificationForwardingQuietStart by viewModel.notificationForwardingQuietStart.collectAsState()
|
||||
val notificationForwardingQuietEnd by viewModel.notificationForwardingQuietEnd.collectAsState()
|
||||
val notificationForwardingMaxEventsPerMinute by viewModel.notificationForwardingMaxEventsPerMinute.collectAsState()
|
||||
val notificationForwardingSessionKey by viewModel.notificationForwardingSessionKey.collectAsState()
|
||||
|
||||
var notificationQuietStartDraft by remember(notificationForwardingQuietStart) {
|
||||
mutableStateOf(notificationForwardingQuietStart)
|
||||
}
|
||||
var notificationQuietEndDraft by remember(notificationForwardingQuietEnd) {
|
||||
mutableStateOf(notificationForwardingQuietEnd)
|
||||
}
|
||||
var notificationRateDraft by remember(notificationForwardingMaxEventsPerMinute) {
|
||||
mutableStateOf(notificationForwardingMaxEventsPerMinute.toString())
|
||||
}
|
||||
var notificationSessionKeyDraft by remember(notificationForwardingSessionKey) {
|
||||
mutableStateOf(notificationForwardingSessionKey.orEmpty())
|
||||
}
|
||||
val normalizedQuietStartDraft = remember(notificationQuietStartDraft) {
|
||||
normalizeLocalHourMinute(notificationQuietStartDraft)
|
||||
}
|
||||
val normalizedQuietEndDraft = remember(notificationQuietEndDraft) {
|
||||
normalizeLocalHourMinute(notificationQuietEndDraft)
|
||||
}
|
||||
val quietHoursDraftValid = normalizedQuietStartDraft != null && normalizedQuietEndDraft != null
|
||||
val selectedPackagesSummary = remember(notificationForwardingMode, notificationForwardingPackages) {
|
||||
when (notificationForwardingMode) {
|
||||
NotificationPackageFilterMode.Allowlist ->
|
||||
if (notificationForwardingPackages.isEmpty()) {
|
||||
"Selected: none — allowlist mode forwards nothing until you add apps."
|
||||
} else {
|
||||
"Selected: ${notificationForwardingPackages.size} app(s) allowed."
|
||||
}
|
||||
NotificationPackageFilterMode.Blocklist ->
|
||||
if (notificationForwardingPackages.isEmpty()) {
|
||||
"Selected: none — blocklist mode forwards all apps except OpenClaw."
|
||||
} else {
|
||||
"Selected: ${notificationForwardingPackages.size} app(s) blocked."
|
||||
}
|
||||
}
|
||||
}
|
||||
val quietHoursCanEnable = notificationForwardingEnabled && quietHoursDraftValid
|
||||
val quietHoursDraftDirty =
|
||||
notificationForwardingQuietStart != (normalizedQuietStartDraft ?: notificationQuietStartDraft.trim()) ||
|
||||
notificationForwardingQuietEnd != (normalizedQuietEndDraft ?: notificationQuietEndDraft.trim())
|
||||
val quietHoursSaveEnabled = notificationForwardingEnabled && quietHoursDraftValid && quietHoursDraftDirty
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
val deviceModel =
|
||||
@@ -151,8 +99,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
versionName
|
||||
}
|
||||
}
|
||||
var assistantRoleAvailable by remember(context) { mutableStateOf(isAssistantRoleAvailable(context)) }
|
||||
var assistantRoleHeld by remember(context) { mutableStateOf(isAssistantRoleHeld(context)) }
|
||||
val listItemColors =
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
@@ -203,10 +149,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
|
||||
val smsPermissionAvailable =
|
||||
remember {
|
||||
BuildConfig.OPENCLAW_ENABLE_SMS &&
|
||||
context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
|
||||
context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
|
||||
}
|
||||
val callLogPermissionAvailable = remember { BuildConfig.OPENCLAW_ENABLE_CALL_LOG }
|
||||
val photosPermission =
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
Manifest.permission.READ_MEDIA_IMAGES
|
||||
@@ -229,16 +173,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
remember {
|
||||
mutableStateOf(isNotificationListenerEnabled(context))
|
||||
}
|
||||
val notificationForwardingAvailable = notificationForwardingEnabled && notificationListenerEnabled
|
||||
val notificationForwardingControlsAlpha = if (notificationForwardingAvailable) 1f else 0.6f
|
||||
|
||||
var notificationPickerExpanded by remember { mutableStateOf(false) }
|
||||
var notificationAppSearch by remember { mutableStateOf("") }
|
||||
var notificationShowSystemApps by remember { mutableStateOf(false) }
|
||||
var installedNotificationApps by
|
||||
remember(context, notificationForwardingPackages) {
|
||||
mutableStateOf(queryInstalledApps(context, notificationForwardingPackages))
|
||||
}
|
||||
|
||||
var photosPermissionGranted by
|
||||
remember {
|
||||
@@ -284,18 +218,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
calendarPermissionGranted = readOk && writeOk
|
||||
}
|
||||
|
||||
var callLogPermissionGranted by
|
||||
remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALL_LOG) ==
|
||||
PackageManager.PERMISSION_GRANTED,
|
||||
)
|
||||
}
|
||||
val callLogPermissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
callLogPermissionGranted = granted
|
||||
}
|
||||
|
||||
var motionPermissionGranted by
|
||||
remember {
|
||||
mutableStateOf(
|
||||
@@ -313,28 +235,15 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) ==
|
||||
PackageManager.PERMISSION_GRANTED ||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_SMS) ==
|
||||
PackageManager.PERMISSION_GRANTED,
|
||||
)
|
||||
}
|
||||
val smsPermissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
|
||||
smsPermissionGranted =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_SMS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
smsPermissionGranted = granted
|
||||
viewModel.refreshGatewayConnection()
|
||||
}
|
||||
|
||||
val assistantRoleLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
assistantRoleAvailable = isAssistantRoleAvailable(context)
|
||||
assistantRoleHeld = isAssistantRoleHeld(context)
|
||||
}
|
||||
|
||||
DisposableEffect(lifecycleOwner, context) {
|
||||
val observer =
|
||||
LifecycleEventObserver { _, event ->
|
||||
@@ -344,7 +253,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
notificationsPermissionGranted = hasNotificationsPermission(context)
|
||||
notificationListenerEnabled = isNotificationListenerEnabled(context)
|
||||
installedNotificationApps = queryInstalledApps(context, notificationForwardingPackages)
|
||||
photosPermissionGranted =
|
||||
ContextCompat.checkSelfPermission(context, photosPermission) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
@@ -358,9 +266,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
PackageManager.PERMISSION_GRANTED &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
callLogPermissionGranted =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALL_LOG) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
motionPermissionGranted =
|
||||
!motionPermissionRequired ||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) ==
|
||||
@@ -368,11 +273,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
smsPermissionGranted =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_SMS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
assistantRoleAvailable = isAssistantRoleAvailable(context)
|
||||
assistantRoleHeld = isAssistantRoleHeld(context)
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
@@ -428,20 +328,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
val normalizedAppSearch = notificationAppSearch.trim().lowercase()
|
||||
val filteredNotificationApps =
|
||||
remember(installedNotificationApps, normalizedAppSearch, notificationShowSystemApps) {
|
||||
installedNotificationApps
|
||||
.asSequence()
|
||||
.filter { app -> notificationShowSystemApps || !app.isSystemApp }
|
||||
.filter { app ->
|
||||
normalizedAppSearch.isEmpty() ||
|
||||
app.label.lowercase().contains(normalizedAppSearch) ||
|
||||
app.packageName.lowercase().contains(normalizedAppSearch)
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -489,42 +375,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
color = mobileTextTertiary,
|
||||
)
|
||||
}
|
||||
if (assistantRoleAvailable) {
|
||||
HorizontalDivider(color = mobileBorder)
|
||||
ListItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Default Assistant", style = mobileHeadline) },
|
||||
supportingContent = {
|
||||
Text(
|
||||
if (assistantRoleHeld) {
|
||||
"OpenClaw is registered as the device assistant."
|
||||
} else {
|
||||
"Let Android launch OpenClaw from the assistant gesture. Google Assistant App Actions still work separately."
|
||||
},
|
||||
style = mobileCallout,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
assistantRoleLauncher.launch(
|
||||
context
|
||||
.getSystemService(RoleManager::class.java)
|
||||
.createRequestRoleIntent(RoleManager.ROLE_ASSISTANT),
|
||||
)
|
||||
},
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(
|
||||
if (assistantRoleHeld) "Manage" else "Enable",
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -618,12 +468,9 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
ListItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Notification Listener Access", style = mobileHeadline) },
|
||||
headlineContent = { Text("Notification Listener", style = mobileHeadline) },
|
||||
supportingContent = {
|
||||
Text(
|
||||
"Required for `notifications.list`, `notifications.actions`, and forwarded notification events.",
|
||||
style = mobileCallout,
|
||||
)
|
||||
Text("Read and interact with notifications.", style = mobileCallout)
|
||||
},
|
||||
trailingContent = {
|
||||
Button(
|
||||
@@ -645,7 +492,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("SMS", style = mobileHeadline) },
|
||||
supportingContent = {
|
||||
Text("Send and search SMS from this device.", style = mobileCallout)
|
||||
Text("Send SMS from this device.", style = mobileCallout)
|
||||
},
|
||||
trailingContent = {
|
||||
Button(
|
||||
@@ -653,18 +500,14 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
if (smsPermissionGranted) {
|
||||
openAppSettings(context)
|
||||
} else {
|
||||
smsPermissionLauncher.launch(arrayOf(Manifest.permission.SEND_SMS, Manifest.permission.READ_SMS))
|
||||
smsPermissionLauncher.launch(Manifest.permission.SEND_SMS)
|
||||
}
|
||||
},
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(
|
||||
if (smsPermissionGranted) {
|
||||
"Manage"
|
||||
} else {
|
||||
"Grant"
|
||||
},
|
||||
if (smsPermissionGranted) "Manage" else "Grant",
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
}
|
||||
@@ -673,297 +516,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
modifier = Modifier.settingsRowModifier(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Forward Notification Events", style = mobileHeadline) },
|
||||
supportingContent = {
|
||||
Text(
|
||||
if (notificationListenerEnabled) {
|
||||
"Forward listener events into gateway node events. Off by default until you enable it."
|
||||
} else {
|
||||
"Notification listener access is off, so no notification events can be forwarded yet."
|
||||
},
|
||||
style = mobileCallout,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = notificationForwardingEnabled,
|
||||
onCheckedChange = viewModel::setNotificationForwardingEnabled,
|
||||
enabled = notificationListenerEnabled,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
if (notificationListenerEnabled) {
|
||||
"Forwarding is available when enabled below."
|
||||
} else {
|
||||
"Forwarding controls stay disabled until Notification Listener Access is enabled in system Settings."
|
||||
},
|
||||
style = mobileCallout,
|
||||
color = mobileTextSecondary,
|
||||
)
|
||||
}
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier.settingsRowModifier().alpha(notificationForwardingControlsAlpha),
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
) {
|
||||
ListItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Package Filter: Allowlist", style = mobileHeadline) },
|
||||
supportingContent = {
|
||||
Text("Only listed package IDs are forwarded.", style = mobileCallout)
|
||||
},
|
||||
trailingContent = {
|
||||
RadioButton(
|
||||
selected = notificationForwardingMode == NotificationPackageFilterMode.Allowlist,
|
||||
onClick = {
|
||||
viewModel.setNotificationForwardingMode(NotificationPackageFilterMode.Allowlist)
|
||||
},
|
||||
enabled = notificationForwardingAvailable,
|
||||
)
|
||||
},
|
||||
)
|
||||
HorizontalDivider(color = mobileBorder)
|
||||
ListItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Package Filter: Blocklist", style = mobileHeadline) },
|
||||
supportingContent = {
|
||||
Text("All packages except listed IDs are forwarded.", style = mobileCallout)
|
||||
},
|
||||
trailingContent = {
|
||||
RadioButton(
|
||||
selected = notificationForwardingMode == NotificationPackageFilterMode.Blocklist,
|
||||
onClick = {
|
||||
viewModel.setNotificationForwardingMode(NotificationPackageFilterMode.Blocklist)
|
||||
},
|
||||
enabled = notificationForwardingAvailable,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
Button(
|
||||
onClick = { notificationPickerExpanded = !notificationPickerExpanded },
|
||||
enabled = notificationForwardingAvailable,
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(
|
||||
if (notificationPickerExpanded) "Close App Picker" else "Open App Picker",
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
selectedPackagesSummary,
|
||||
style = mobileCallout,
|
||||
color = mobileTextSecondary,
|
||||
)
|
||||
}
|
||||
if (notificationPickerExpanded) {
|
||||
item {
|
||||
OutlinedTextField(
|
||||
value = notificationAppSearch,
|
||||
onValueChange = { notificationAppSearch = it },
|
||||
label = {
|
||||
Text("Search apps", style = mobileCaption1, color = mobileTextSecondary)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textStyle = mobileBody.copy(color = mobileText),
|
||||
colors = settingsTextFieldColors(),
|
||||
enabled = notificationForwardingAvailable,
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
modifier = Modifier.settingsRowModifier().alpha(notificationForwardingControlsAlpha),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Show System Apps", style = mobileHeadline) },
|
||||
supportingContent = {
|
||||
Text("Include Android/system packages in results.", style = mobileCallout)
|
||||
},
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = notificationShowSystemApps,
|
||||
onCheckedChange = { notificationShowSystemApps = it },
|
||||
enabled = notificationForwardingAvailable,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
items(filteredNotificationApps, key = { it.packageName }) { app ->
|
||||
ListItem(
|
||||
modifier = Modifier.settingsRowModifier().alpha(notificationForwardingControlsAlpha),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text(app.label, style = mobileHeadline) },
|
||||
supportingContent = { Text(app.packageName, style = mobileCallout) },
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = notificationForwardingPackages.contains(app.packageName),
|
||||
onCheckedChange = { checked ->
|
||||
val next = notificationForwardingPackages.toMutableSet()
|
||||
if (checked) {
|
||||
next.add(app.packageName)
|
||||
} else {
|
||||
next.remove(app.packageName)
|
||||
}
|
||||
viewModel.setNotificationForwardingPackagesCsv(next.sorted().joinToString(","))
|
||||
},
|
||||
enabled = notificationForwardingAvailable,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
modifier = Modifier.settingsRowModifier().alpha(notificationForwardingControlsAlpha),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Quiet Hours", style = mobileHeadline) },
|
||||
supportingContent = {
|
||||
Text("Suppress forwarding during a local time window.", style = mobileCallout)
|
||||
},
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = notificationForwardingQuietHoursEnabled,
|
||||
onCheckedChange = {
|
||||
if (!quietHoursCanEnable && it) return@Switch
|
||||
viewModel.setNotificationForwardingQuietHours(
|
||||
enabled = it,
|
||||
start = notificationQuietStartDraft,
|
||||
end = notificationQuietEndDraft,
|
||||
)
|
||||
},
|
||||
enabled = if (notificationForwardingQuietHoursEnabled) notificationForwardingAvailable else quietHoursCanEnable,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
item {
|
||||
OutlinedTextField(
|
||||
value = notificationQuietStartDraft,
|
||||
onValueChange = { notificationQuietStartDraft = it },
|
||||
label = { Text("Quiet Start (HH:mm)", style = mobileCaption1, color = mobileTextSecondary) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textStyle = mobileBody.copy(color = mobileText),
|
||||
colors = settingsTextFieldColors(),
|
||||
enabled = notificationForwardingAvailable,
|
||||
isError = notificationForwardingAvailable && normalizedQuietStartDraft == null,
|
||||
supportingText = {
|
||||
if (notificationForwardingAvailable && normalizedQuietStartDraft == null) {
|
||||
Text("Use 24-hour HH:mm format, for example 22:00.", style = mobileCaption1, color = mobileDanger)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
item {
|
||||
OutlinedTextField(
|
||||
value = notificationQuietEndDraft,
|
||||
onValueChange = { notificationQuietEndDraft = it },
|
||||
label = { Text("Quiet End (HH:mm)", style = mobileCaption1, color = mobileTextSecondary) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textStyle = mobileBody.copy(color = mobileText),
|
||||
colors = settingsTextFieldColors(),
|
||||
enabled = notificationForwardingAvailable,
|
||||
isError = notificationForwardingAvailable && normalizedQuietEndDraft == null,
|
||||
supportingText = {
|
||||
if (notificationForwardingAvailable && normalizedQuietEndDraft == null) {
|
||||
Text("Use 24-hour HH:mm format, for example 07:00.", style = mobileCaption1, color = mobileDanger)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
item {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.setNotificationForwardingQuietHours(
|
||||
enabled = notificationForwardingQuietHoursEnabled,
|
||||
start = notificationQuietStartDraft,
|
||||
end = notificationQuietEndDraft,
|
||||
)
|
||||
},
|
||||
enabled = quietHoursSaveEnabled,
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text("Save Quiet Hours", style = mobileCallout.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
OutlinedTextField(
|
||||
value = notificationRateDraft,
|
||||
onValueChange = { notificationRateDraft = it.filter { c -> c.isDigit() } },
|
||||
label = { Text("Max Events / Minute", style = mobileCaption1, color = mobileTextSecondary) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textStyle = mobileBody.copy(color = mobileText),
|
||||
colors = settingsTextFieldColors(),
|
||||
enabled = notificationForwardingAvailable,
|
||||
)
|
||||
}
|
||||
item {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
Button(
|
||||
onClick = {
|
||||
val parsed = notificationRateDraft.toIntOrNull() ?: notificationForwardingMaxEventsPerMinute
|
||||
viewModel.setNotificationForwardingMaxEventsPerMinute(parsed)
|
||||
},
|
||||
enabled = notificationForwardingAvailable,
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text("Save Rate", style = mobileCallout.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
OutlinedTextField(
|
||||
value = notificationSessionKeyDraft,
|
||||
onValueChange = { notificationSessionKeyDraft = it },
|
||||
label = {
|
||||
Text(
|
||||
"Route Session Key (optional)",
|
||||
style = mobileCaption1,
|
||||
color = mobileTextSecondary,
|
||||
)
|
||||
},
|
||||
placeholder = {
|
||||
Text("Blank keeps notification events on this device's default notification route. Set a key only to pin forwarding into a different session.", style = mobileCaption1, color = mobileTextSecondary)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textStyle = mobileBody.copy(color = mobileText),
|
||||
colors = settingsTextFieldColors(),
|
||||
enabled = notificationForwardingAvailable,
|
||||
)
|
||||
}
|
||||
item {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.setNotificationForwardingSessionKey(notificationSessionKeyDraft.trim().ifEmpty { null })
|
||||
},
|
||||
enabled = notificationForwardingAvailable,
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text("Save Session Route", style = mobileCallout.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
}
|
||||
}
|
||||
item { HorizontalDivider(color = mobileBorder) }
|
||||
|
||||
// ── Data Access ──
|
||||
item {
|
||||
@@ -1049,33 +601,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
}
|
||||
},
|
||||
)
|
||||
if (callLogPermissionAvailable) {
|
||||
HorizontalDivider(color = mobileBorder)
|
||||
ListItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Call Log", style = mobileHeadline) },
|
||||
supportingContent = { Text("Search recent call history.", style = mobileCallout) },
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (callLogPermissionGranted) {
|
||||
openAppSettings(context)
|
||||
} else {
|
||||
callLogPermissionLauncher.launch(Manifest.permission.READ_CALL_LOG)
|
||||
}
|
||||
},
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(
|
||||
if (callLogPermissionGranted) "Manage" else "Grant",
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
if (motionAvailable) {
|
||||
HorizontalDivider(color = mobileBorder)
|
||||
ListItem(
|
||||
@@ -1199,78 +724,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
data class InstalledApp(
|
||||
val label: String,
|
||||
val packageName: String,
|
||||
val isSystemApp: Boolean,
|
||||
)
|
||||
|
||||
private fun queryInstalledApps(
|
||||
context: Context,
|
||||
configuredPackages: Set<String>,
|
||||
): List<InstalledApp> {
|
||||
val packageManager = context.packageManager
|
||||
val launcherIntent = Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_LAUNCHER) }
|
||||
|
||||
val launcherPackages =
|
||||
packageManager
|
||||
.queryIntentActivities(launcherIntent, PackageManager.MATCH_ALL)
|
||||
.asSequence()
|
||||
.mapNotNull { it.activityInfo?.packageName?.trim()?.takeIf(String::isNotEmpty) }
|
||||
.toMutableSet()
|
||||
|
||||
val recentNotificationPackages =
|
||||
DeviceNotificationListenerService
|
||||
.recentPackages(context)
|
||||
.asSequence()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.toList()
|
||||
|
||||
val candidatePackages =
|
||||
resolveNotificationCandidatePackages(
|
||||
launcherPackages = launcherPackages,
|
||||
recentPackages = recentNotificationPackages,
|
||||
configuredPackages = configuredPackages,
|
||||
appPackageName = context.packageName,
|
||||
)
|
||||
|
||||
return candidatePackages
|
||||
.asSequence()
|
||||
.mapNotNull { packageName ->
|
||||
runCatching {
|
||||
val appInfo = packageManager.getApplicationInfo(packageName, 0)
|
||||
val label = packageManager.getApplicationLabel(appInfo)?.toString()?.trim().orEmpty()
|
||||
InstalledApp(
|
||||
label = if (label.isEmpty()) packageName else label,
|
||||
packageName = packageName,
|
||||
isSystemApp = (appInfo.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0,
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
.sortedWith(compareBy<InstalledApp> { it.label.lowercase() }.thenBy { it.packageName })
|
||||
.toList()
|
||||
}
|
||||
|
||||
internal fun resolveNotificationCandidatePackages(
|
||||
launcherPackages: Set<String>,
|
||||
recentPackages: List<String>,
|
||||
configuredPackages: Set<String>,
|
||||
appPackageName: String,
|
||||
): Set<String> {
|
||||
val blockedPackage = appPackageName.trim()
|
||||
return sequenceOf(
|
||||
configuredPackages.asSequence(),
|
||||
launcherPackages.asSequence(),
|
||||
recentPackages.asSequence(),
|
||||
)
|
||||
.flatten()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() && it != blockedPackage }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun settingsTextFieldColors() =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
@@ -1283,12 +736,11 @@ private fun settingsTextFieldColors() =
|
||||
cursorColor = mobileAccent,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun Modifier.settingsRowModifier() =
|
||||
this
|
||||
.fillMaxWidth()
|
||||
.border(width = 1.dp, color = mobileBorder, shape = RoundedCornerShape(14.dp))
|
||||
.background(mobileCardSurface, RoundedCornerShape(14.dp))
|
||||
.background(Color.White, RoundedCornerShape(14.dp))
|
||||
|
||||
@Composable
|
||||
private fun settingsPrimaryButtonColors() =
|
||||
@@ -1329,7 +781,7 @@ private fun openNotificationListenerSettings(context: Context) {
|
||||
private fun hasNotificationsPermission(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT < 33) return true
|
||||
return ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
private fun isNotificationListenerEnabled(context: Context): Boolean {
|
||||
@@ -1341,11 +793,3 @@ private fun hasMotionCapabilities(context: Context): Boolean {
|
||||
return sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null ||
|
||||
sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) != null
|
||||
}
|
||||
|
||||
private fun isAssistantRoleAvailable(context: Context): Boolean {
|
||||
return context.getSystemService(RoleManager::class.java).isRoleAvailable(RoleManager.ROLE_ASSISTANT)
|
||||
}
|
||||
|
||||
private fun isAssistantRoleHeld(context: Context): Boolean {
|
||||
return context.getSystemService(RoleManager::class.java).isRoleHeld(RoleManager.ROLE_ASSISTANT)
|
||||
}
|
||||
|
||||
@@ -363,7 +363,7 @@ private fun VoiceTurnBubble(entry: VoiceConversationEntry) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(0.90f),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = if (isUser) mobileAccentSoft else mobileCardSurface,
|
||||
color = if (isUser) mobileAccentSoft else Color.White,
|
||||
border = BorderStroke(1.dp, if (isUser) mobileAccent else mobileBorderStrong),
|
||||
) {
|
||||
Column(
|
||||
@@ -391,7 +391,7 @@ private fun VoiceThinkingBubble() {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(0.68f),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = mobileCardSurface,
|
||||
color = Color.White,
|
||||
border = BorderStroke(1.dp, mobileBorderStrong),
|
||||
) {
|
||||
Row(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package ai.openclaw.app.ui.chat
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -26,7 +28,8 @@ internal fun rememberBase64ImageState(base64: String): Base64ImageState {
|
||||
image =
|
||||
withContext(Dispatchers.Default) {
|
||||
try {
|
||||
val bitmap = decodeBase64Bitmap(base64) ?: return@withContext null
|
||||
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null
|
||||
bitmap.asImageBitmap()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
|
||||
@@ -33,7 +33,6 @@ import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -47,58 +46,23 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import ai.openclaw.app.ui.mobileAccent
|
||||
import ai.openclaw.app.ui.mobileAccentBorderStrong
|
||||
import ai.openclaw.app.ui.mobileAccentSoft
|
||||
import ai.openclaw.app.ui.mobileBorder
|
||||
import ai.openclaw.app.ui.mobileBorderStrong
|
||||
import ai.openclaw.app.ui.mobileCallout
|
||||
import ai.openclaw.app.ui.mobileCaption1
|
||||
import ai.openclaw.app.ui.mobileCardSurface
|
||||
import ai.openclaw.app.ui.mobileHeadline
|
||||
import ai.openclaw.app.ui.mobileSurface
|
||||
import ai.openclaw.app.ui.mobileText
|
||||
import ai.openclaw.app.ui.mobileTextSecondary
|
||||
import ai.openclaw.app.ui.mobileTextTertiary
|
||||
|
||||
internal data class DraftApplication(
|
||||
val input: String,
|
||||
val lastAppliedDraft: String?,
|
||||
val consumed: Boolean,
|
||||
)
|
||||
|
||||
internal fun applyDraftText(
|
||||
draftText: String?,
|
||||
currentInput: String,
|
||||
lastAppliedDraft: String?,
|
||||
): DraftApplication {
|
||||
val draft =
|
||||
draftText?.trim()?.ifEmpty { null } ?: return DraftApplication(
|
||||
input = currentInput,
|
||||
lastAppliedDraft = null,
|
||||
consumed = false,
|
||||
)
|
||||
if (draft == lastAppliedDraft) {
|
||||
return DraftApplication(
|
||||
input = currentInput,
|
||||
lastAppliedDraft = lastAppliedDraft,
|
||||
consumed = false,
|
||||
)
|
||||
}
|
||||
return DraftApplication(
|
||||
input = draft,
|
||||
lastAppliedDraft = draft,
|
||||
consumed = true,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatComposer(
|
||||
draftText: String?,
|
||||
healthOk: Boolean,
|
||||
thinkingLevel: String,
|
||||
pendingRunCount: Int,
|
||||
attachments: List<PendingImageAttachment>,
|
||||
onDraftApplied: () -> Unit,
|
||||
onPickImages: () -> Unit,
|
||||
onRemoveAttachment: (id: String) -> Unit,
|
||||
onSetThinkingLevel: (level: String) -> Unit,
|
||||
@@ -107,18 +71,8 @@ fun ChatComposer(
|
||||
onSend: (text: String) -> Unit,
|
||||
) {
|
||||
var input by rememberSaveable { mutableStateOf("") }
|
||||
var lastAppliedDraft by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var showThinkingMenu by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(draftText) {
|
||||
val next = applyDraftText(draftText = draftText, currentInput = input, lastAppliedDraft = lastAppliedDraft)
|
||||
input = next.input
|
||||
lastAppliedDraft = next.lastAppliedDraft
|
||||
if (next.consumed) {
|
||||
onDraftApplied()
|
||||
}
|
||||
}
|
||||
|
||||
val canSend = pendingRunCount == 0 && (input.trim().isNotEmpty() || attachments.isNotEmpty()) && healthOk
|
||||
val sendBusy = pendingRunCount > 0
|
||||
|
||||
@@ -156,7 +110,7 @@ fun ChatComposer(
|
||||
Surface(
|
||||
onClick = { showThinkingMenu = true },
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = mobileCardSurface,
|
||||
color = Color.White,
|
||||
border = BorderStroke(1.dp, mobileBorderStrong),
|
||||
) {
|
||||
Row(
|
||||
@@ -172,15 +126,7 @@ fun ChatComposer(
|
||||
}
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showThinkingMenu,
|
||||
onDismissRequest = { showThinkingMenu = false },
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
containerColor = mobileCardSurface,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 8.dp,
|
||||
border = BorderStroke(1.dp, mobileBorder),
|
||||
) {
|
||||
DropdownMenu(expanded = showThinkingMenu, onDismissRequest = { showThinkingMenu = false }) {
|
||||
ThinkingMenuItem("off", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||
ThinkingMenuItem("low", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||
ThinkingMenuItem("medium", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||
@@ -231,7 +177,7 @@ fun ChatComposer(
|
||||
disabledContainerColor = mobileBorderStrong,
|
||||
disabledContentColor = mobileTextTertiary,
|
||||
),
|
||||
border = BorderStroke(1.dp, if (canSend) mobileAccentBorderStrong else mobileBorderStrong),
|
||||
border = BorderStroke(1.dp, if (canSend) Color(0xFF154CAD) else mobileBorderStrong),
|
||||
) {
|
||||
if (sendBusy) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp, color = Color.White)
|
||||
@@ -265,9 +211,9 @@ private fun SecondaryActionButton(
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = mobileCardSurface,
|
||||
containerColor = Color.White,
|
||||
contentColor = mobileTextSecondary,
|
||||
disabledContainerColor = mobileCardSurface,
|
||||
disabledContainerColor = Color.White,
|
||||
disabledContentColor = mobileTextTertiary,
|
||||
),
|
||||
border = BorderStroke(1.dp, mobileBorderStrong),
|
||||
@@ -357,7 +303,7 @@ private fun AttachmentChip(fileName: String, onRemove: () -> Unit) {
|
||||
Surface(
|
||||
onClick = onRemove,
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
color = mobileCardSurface,
|
||||
color = Color.White,
|
||||
border = BorderStroke(1.dp, mobileBorderStrong),
|
||||
) {
|
||||
Text(
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
package ai.openclaw.app.ui.chat
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
import android.util.LruCache
|
||||
import androidx.core.graphics.scale
|
||||
import ai.openclaw.app.node.JpegSizeLimiter
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val CHAT_ATTACHMENT_MAX_WIDTH = 1600
|
||||
private const val CHAT_ATTACHMENT_MAX_BASE64_CHARS = 300 * 1024
|
||||
private const val CHAT_ATTACHMENT_START_QUALITY = 85
|
||||
private const val CHAT_DECODE_MAX_DIMENSION = 1600
|
||||
private const val CHAT_IMAGE_CACHE_BYTES = 16 * 1024 * 1024
|
||||
|
||||
private val decodedBitmapCache =
|
||||
object : LruCache<String, Bitmap>(CHAT_IMAGE_CACHE_BYTES) {
|
||||
override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount.coerceAtLeast(1)
|
||||
}
|
||||
|
||||
internal fun loadSizedImageAttachment(resolver: ContentResolver, uri: Uri): PendingImageAttachment {
|
||||
val fileName = normalizeAttachmentFileName((uri.lastPathSegment ?: "image").substringAfterLast('/'))
|
||||
val bitmap = decodeScaledBitmap(resolver, uri, maxDimension = CHAT_ATTACHMENT_MAX_WIDTH)
|
||||
if (bitmap == null) {
|
||||
throw IllegalStateException("unsupported attachment")
|
||||
}
|
||||
val maxBytes = (CHAT_ATTACHMENT_MAX_BASE64_CHARS / 4) * 3
|
||||
val encoded =
|
||||
JpegSizeLimiter.compressToLimit(
|
||||
initialWidth = bitmap.width,
|
||||
initialHeight = bitmap.height,
|
||||
startQuality = CHAT_ATTACHMENT_START_QUALITY,
|
||||
maxBytes = maxBytes,
|
||||
minSize = 240,
|
||||
encode = { width, height, quality ->
|
||||
val working =
|
||||
if (width == bitmap.width && height == bitmap.height) {
|
||||
bitmap
|
||||
} else {
|
||||
bitmap.scale(width, height, true)
|
||||
}
|
||||
try {
|
||||
val out = ByteArrayOutputStream()
|
||||
if (!working.compress(Bitmap.CompressFormat.JPEG, quality, out)) {
|
||||
throw IllegalStateException("attachment encode failed")
|
||||
}
|
||||
out.toByteArray()
|
||||
} finally {
|
||||
if (working !== bitmap) {
|
||||
working.recycle()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
val base64 = Base64.encodeToString(encoded.bytes, Base64.NO_WRAP)
|
||||
return PendingImageAttachment(
|
||||
id = uri.toString() + "#" + System.currentTimeMillis().toString(),
|
||||
fileName = fileName,
|
||||
mimeType = "image/jpeg",
|
||||
base64 = base64,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun decodeBase64Bitmap(base64: String, maxDimension: Int = CHAT_DECODE_MAX_DIMENSION): Bitmap? {
|
||||
val cacheKey = "$maxDimension:${base64.length}:${base64.hashCode()}"
|
||||
decodedBitmapCache.get(cacheKey)?.let { return it }
|
||||
|
||||
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||
if (bytes.isEmpty()) return null
|
||||
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||
|
||||
val bitmap =
|
||||
BitmapFactory.decodeByteArray(
|
||||
bytes,
|
||||
0,
|
||||
bytes.size,
|
||||
BitmapFactory.Options().apply {
|
||||
inSampleSize = computeInSampleSize(bounds.outWidth, bounds.outHeight, maxDimension)
|
||||
inPreferredConfig = Bitmap.Config.RGB_565
|
||||
},
|
||||
) ?: return null
|
||||
|
||||
decodedBitmapCache.put(cacheKey, bitmap)
|
||||
return bitmap
|
||||
}
|
||||
|
||||
internal fun computeInSampleSize(width: Int, height: Int, maxDimension: Int): Int {
|
||||
if (width <= 0 || height <= 0 || maxDimension <= 0) return 1
|
||||
|
||||
var sample = 1
|
||||
var longestEdge = max(width, height)
|
||||
while (longestEdge > maxDimension && sample < 64) {
|
||||
sample *= 2
|
||||
longestEdge = max(width / sample, height / sample)
|
||||
}
|
||||
return sample.coerceAtLeast(1)
|
||||
}
|
||||
|
||||
internal fun normalizeAttachmentFileName(raw: String): String {
|
||||
val trimmed = raw.trim()
|
||||
if (trimmed.isEmpty()) return "image.jpg"
|
||||
val stem = trimmed.substringBeforeLast('.', missingDelimiterValue = trimmed).ifEmpty { "image" }
|
||||
return "$stem.jpg"
|
||||
}
|
||||
|
||||
private fun decodeScaledBitmap(
|
||||
resolver: ContentResolver,
|
||||
uri: Uri,
|
||||
maxDimension: Int,
|
||||
): Bitmap? {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
resolver.openInputStream(uri).use { input ->
|
||||
if (input == null) return null
|
||||
BitmapFactory.decodeStream(input, null, bounds)
|
||||
}
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||
|
||||
val decoded =
|
||||
resolver.openInputStream(uri).use { input ->
|
||||
if (input == null) return null
|
||||
BitmapFactory.decodeStream(
|
||||
input,
|
||||
null,
|
||||
BitmapFactory.Options().apply {
|
||||
inSampleSize = computeInSampleSize(bounds.outWidth, bounds.outHeight, maxDimension)
|
||||
inPreferredConfig = Bitmap.Config.ARGB_8888
|
||||
},
|
||||
)
|
||||
} ?: return null
|
||||
|
||||
val longestEdge = max(decoded.width, decoded.height)
|
||||
if (longestEdge <= maxDimension) return decoded
|
||||
|
||||
val scale = maxDimension.toDouble() / longestEdge.toDouble()
|
||||
val targetWidth = max(1, (decoded.width * scale).roundToInt())
|
||||
val targetHeight = max(1, (decoded.height * scale).roundToInt())
|
||||
val scaled = decoded.scale(targetWidth, targetHeight, true)
|
||||
if (scaled !== decoded) {
|
||||
decoded.recycle()
|
||||
}
|
||||
return scaled
|
||||
}
|
||||
@@ -94,7 +94,7 @@ private val markdownParser: Parser by lazy {
|
||||
@Composable
|
||||
fun ChatMarkdown(text: String, textColor: Color) {
|
||||
val document = remember(text) { markdownParser.parse(text) as Document }
|
||||
val inlineStyles = InlineStyles(inlineCodeBg = mobileCodeBg, inlineCodeColor = mobileCodeText, linkColor = mobileAccent, baseCallout = mobileCallout)
|
||||
val inlineStyles = InlineStyles(inlineCodeBg = mobileCodeBg, inlineCodeColor = mobileCodeText)
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
RenderMarkdownBlocks(
|
||||
@@ -124,7 +124,7 @@ private fun RenderMarkdownBlocks(
|
||||
val headingText = remember(current) { buildInlineMarkdown(current.firstChild, inlineStyles) }
|
||||
Text(
|
||||
text = headingText,
|
||||
style = headingStyle(current.level, inlineStyles.baseCallout),
|
||||
style = headingStyle(current.level),
|
||||
color = textColor,
|
||||
)
|
||||
}
|
||||
@@ -231,7 +231,7 @@ private fun RenderParagraph(
|
||||
|
||||
Text(
|
||||
text = annotated,
|
||||
style = inlineStyles.baseCallout,
|
||||
style = mobileCallout,
|
||||
color = textColor,
|
||||
)
|
||||
}
|
||||
@@ -315,7 +315,7 @@ private fun RenderListItem(
|
||||
) {
|
||||
Text(
|
||||
text = marker,
|
||||
style = inlineStyles.baseCallout.copy(fontWeight = FontWeight.SemiBold),
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = textColor,
|
||||
modifier = Modifier.width(24.dp),
|
||||
)
|
||||
@@ -360,7 +360,7 @@ private fun RenderTableBlock(
|
||||
val cell = row.cells.getOrNull(index) ?: AnnotatedString("")
|
||||
Text(
|
||||
text = cell,
|
||||
style = if (row.isHeader) mobileCaption1.copy(fontWeight = FontWeight.SemiBold) else inlineStyles.baseCallout,
|
||||
style = if (row.isHeader) mobileCaption1.copy(fontWeight = FontWeight.SemiBold) else mobileCallout,
|
||||
color = textColor,
|
||||
modifier = Modifier
|
||||
.border(1.dp, mobileTextSecondary.copy(alpha = 0.22f))
|
||||
@@ -417,7 +417,6 @@ private fun buildInlineMarkdown(start: Node?, inlineStyles: InlineStyles): Annot
|
||||
node = start,
|
||||
inlineCodeBg = inlineStyles.inlineCodeBg,
|
||||
inlineCodeColor = inlineStyles.inlineCodeColor,
|
||||
linkColor = inlineStyles.linkColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -426,7 +425,6 @@ private fun AnnotatedString.Builder.appendInlineNode(
|
||||
node: Node?,
|
||||
inlineCodeBg: Color,
|
||||
inlineCodeColor: Color,
|
||||
linkColor: Color,
|
||||
) {
|
||||
var current = node
|
||||
while (current != null) {
|
||||
@@ -447,27 +445,27 @@ private fun AnnotatedString.Builder.appendInlineNode(
|
||||
}
|
||||
is Emphasis -> {
|
||||
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
|
||||
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor, linkColor = linkColor)
|
||||
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor)
|
||||
}
|
||||
}
|
||||
is StrongEmphasis -> {
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) {
|
||||
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor, linkColor = linkColor)
|
||||
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor)
|
||||
}
|
||||
}
|
||||
is Strikethrough -> {
|
||||
withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) {
|
||||
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor, linkColor = linkColor)
|
||||
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor)
|
||||
}
|
||||
}
|
||||
is Link -> {
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
color = linkColor,
|
||||
color = mobileAccent,
|
||||
textDecoration = TextDecoration.Underline,
|
||||
),
|
||||
) {
|
||||
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor, linkColor = linkColor)
|
||||
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor)
|
||||
}
|
||||
}
|
||||
is MarkdownImage -> {
|
||||
@@ -484,7 +482,7 @@ private fun AnnotatedString.Builder.appendInlineNode(
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor, linkColor = linkColor)
|
||||
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor)
|
||||
}
|
||||
}
|
||||
current = current.next
|
||||
@@ -521,21 +519,19 @@ private fun parseDataImageDestination(destination: String?): ParsedDataImage? {
|
||||
return ParsedDataImage(mimeType = "image/$subtype", base64 = base64)
|
||||
}
|
||||
|
||||
private fun headingStyle(level: Int, baseCallout: TextStyle): TextStyle {
|
||||
private fun headingStyle(level: Int): TextStyle {
|
||||
return when (level.coerceIn(1, 6)) {
|
||||
1 -> baseCallout.copy(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold)
|
||||
2 -> baseCallout.copy(fontSize = 20.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold)
|
||||
3 -> baseCallout.copy(fontSize = 18.sp, lineHeight = 24.sp, fontWeight = FontWeight.SemiBold)
|
||||
4 -> baseCallout.copy(fontSize = 16.sp, lineHeight = 22.sp, fontWeight = FontWeight.SemiBold)
|
||||
else -> baseCallout.copy(fontWeight = FontWeight.SemiBold)
|
||||
1 -> mobileCallout.copy(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold)
|
||||
2 -> mobileCallout.copy(fontSize = 20.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold)
|
||||
3 -> mobileCallout.copy(fontSize = 18.sp, lineHeight = 24.sp, fontWeight = FontWeight.SemiBold)
|
||||
4 -> mobileCallout.copy(fontSize = 16.sp, lineHeight = 22.sp, fontWeight = FontWeight.SemiBold)
|
||||
else -> mobileCallout.copy(fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
|
||||
private data class InlineStyles(
|
||||
val inlineCodeBg: Color,
|
||||
val inlineCodeColor: Color,
|
||||
val linkColor: Color,
|
||||
val baseCallout: TextStyle,
|
||||
)
|
||||
|
||||
private data class TableRenderRow(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user