mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 14:01:24 +08:00
Compare commits
2 Commits
v2026.3.12
...
patch-2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc4e1a434f | ||
|
|
e9d1f3b030 |
@@ -1,380 +0,0 @@
|
||||
---
|
||||
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 +0,0 @@
|
||||
Maintainer skills now live in [`openclaw/maintainers`](https://github.com/openclaw/maintainers/).
|
||||
@@ -24,22 +24,3 @@ pattern = "talk\.apiKey"
|
||||
pattern = === "string"
|
||||
# specific optional-chaining password check that didn't match the line above.
|
||||
pattern = typeof remote\?\.password === "string"
|
||||
# Docker apt signing key fingerprint constant; not a secret.
|
||||
pattern = OPENCLAW_DOCKER_GPG_FINGERPRINT=
|
||||
# Credential matrix metadata field in docs JSON; not a secret value.
|
||||
pattern = "secretShape": "(secret_input|sibling_ref)"
|
||||
# Docs line describing API key rotation knobs; not a credential.
|
||||
pattern = API key rotation \(provider-specific\): set `\*_API_KEYS`
|
||||
# Docs line describing remote password precedence; not a credential.
|
||||
pattern = passw[o]rd: `OPENCLAW_GATEWAY_PASSW[O]RD` -> `gateway\.auth\.passw[o]rd` -> `gateway\.remote\.passw[o]rd`
|
||||
pattern = passw[o]rd: `OPENCLAW_GATEWAY_PASSW[O]RD` -> `gateway\.remote\.passw[o]rd` -> `gateway\.auth\.passw[o]rd`
|
||||
# Test fixture starts a multiline fake private key; detector should ignore the header line.
|
||||
pattern = const key = `-----BEGIN PRIVATE KEY-----
|
||||
# Docs examples: literal placeholder API key snippets and shell heredoc helper.
|
||||
pattern = export CUSTOM_API_K[E]Y="your-key"
|
||||
pattern = grep -q 'N[O]DE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache' ~/.bashrc \|\| cat >> ~/.bashrc <<'EOF'
|
||||
pattern = env: \{ MISTRAL_API_K[E]Y: "sk-\.\.\." \},
|
||||
pattern = "ap[i]Key": "xxxxx",
|
||||
pattern = ap[i]Key: "A[I]za\.\.\.",
|
||||
# Sparkle appcast signatures are release metadata, not credentials.
|
||||
pattern = sparkle:edSignature="[A-Za-z0-9+/=]+"
|
||||
|
||||
@@ -46,19 +46,3 @@ Swabble/
|
||||
Core/
|
||||
Users/
|
||||
vendor/
|
||||
|
||||
# Needed for building the Canvas A2UI bundle during Docker image builds.
|
||||
# Keep the rest of apps/ and vendor/ excluded to avoid a large build context.
|
||||
!apps/shared/
|
||||
!apps/shared/OpenClawKit/
|
||||
!apps/shared/OpenClawKit/Sources/
|
||||
!apps/shared/OpenClawKit/Sources/OpenClawKit/
|
||||
!apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/
|
||||
!apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json
|
||||
!apps/shared/OpenClawKit/Tools/
|
||||
!apps/shared/OpenClawKit/Tools/CanvasA2UI/
|
||||
!apps/shared/OpenClawKit/Tools/CanvasA2UI/**
|
||||
!vendor/a2ui/
|
||||
!vendor/a2ui/renderers/
|
||||
!vendor/a2ui/renderers/lit/
|
||||
!vendor/a2ui/renderers/lit/**
|
||||
|
||||
85
.env.example
85
.env.example
@@ -1,80 +1,5 @@
|
||||
# OpenClaw .env example
|
||||
#
|
||||
# Quick start:
|
||||
# 1) Copy this file to `.env` (for local runs from this repo), OR to `~/.openclaw/.env` (for launchd/systemd daemons).
|
||||
# 2) Fill only the values you use.
|
||||
# 3) Keep real secrets out of git.
|
||||
#
|
||||
# Env-source precedence for environment variables (highest -> lowest):
|
||||
# process env, ./.env, ~/.openclaw/.env, then openclaw.json `env` block.
|
||||
# Existing non-empty process env vars are not overridden by dotenv/config env loading.
|
||||
# Note: direct config keys (for example `gateway.auth.token` or channel tokens in openclaw.json)
|
||||
# are resolved separately from env loading and often take precedence over env fallbacks.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Gateway auth + paths
|
||||
# -----------------------------------------------------------------------------
|
||||
# Recommended if the gateway binds beyond loopback.
|
||||
OPENCLAW_GATEWAY_TOKEN=change-me-to-a-long-random-token
|
||||
# Example generator: openssl rand -hex 32
|
||||
|
||||
# Optional alternative auth mode (use token OR password).
|
||||
# OPENCLAW_GATEWAY_PASSWORD=change-me-to-a-strong-password
|
||||
|
||||
# Optional path overrides (defaults shown for reference).
|
||||
# OPENCLAW_STATE_DIR=~/.openclaw
|
||||
# OPENCLAW_CONFIG_PATH=~/.openclaw/openclaw.json
|
||||
# OPENCLAW_HOME=~
|
||||
|
||||
# Optional: import missing keys from your login shell profile.
|
||||
# OPENCLAW_LOAD_SHELL_ENV=1
|
||||
# OPENCLAW_SHELL_ENV_TIMEOUT_MS=15000
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Model provider API keys (set at least one)
|
||||
# -----------------------------------------------------------------------------
|
||||
# OPENAI_API_KEY=sk-...
|
||||
# ANTHROPIC_API_KEY=sk-ant-...
|
||||
# GEMINI_API_KEY=...
|
||||
# OPENROUTER_API_KEY=sk-or-...
|
||||
# OPENCLAW_LIVE_OPENAI_KEY=sk-...
|
||||
# OPENCLAW_LIVE_ANTHROPIC_KEY=sk-ant-...
|
||||
# OPENCLAW_LIVE_GEMINI_KEY=...
|
||||
# OPENAI_API_KEY_1=...
|
||||
# ANTHROPIC_API_KEY_1=...
|
||||
# GEMINI_API_KEY_1=...
|
||||
# GOOGLE_API_KEY=...
|
||||
# OPENAI_API_KEYS=sk-1,sk-2
|
||||
# ANTHROPIC_API_KEYS=sk-ant-1,sk-ant-2
|
||||
# GEMINI_API_KEYS=key-1,key-2
|
||||
|
||||
# Optional additional providers
|
||||
# ZAI_API_KEY=...
|
||||
# AI_GATEWAY_API_KEY=...
|
||||
# MINIMAX_API_KEY=...
|
||||
# SYNTHETIC_API_KEY=...
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Channels (only set what you enable)
|
||||
# -----------------------------------------------------------------------------
|
||||
# TELEGRAM_BOT_TOKEN=123456:ABCDEF...
|
||||
# DISCORD_BOT_TOKEN=...
|
||||
# SLACK_BOT_TOKEN=xoxb-...
|
||||
# SLACK_APP_TOKEN=xapp-...
|
||||
|
||||
# Optional channel env fallbacks
|
||||
# MATTERMOST_BOT_TOKEN=...
|
||||
# MATTERMOST_URL=https://chat.example.com
|
||||
# ZALO_BOT_TOKEN=...
|
||||
# OPENCLAW_TWITCH_ACCESS_TOKEN=oauth:...
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Tools + voice/media (optional)
|
||||
# -----------------------------------------------------------------------------
|
||||
# BRAVE_API_KEY=...
|
||||
# PERPLEXITY_API_KEY=pplx-...
|
||||
# FIRECRAWL_API_KEY=...
|
||||
|
||||
# ELEVENLABS_API_KEY=...
|
||||
# XI_API_KEY=... # alias for ElevenLabs
|
||||
# DEEPGRAM_API_KEY=...
|
||||
# Copy to .env and fill with your Twilio credentials
|
||||
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
TWILIO_AUTH_TOKEN=your_auth_token_here
|
||||
# Must be a WhatsApp-enabled Twilio number, prefixed with whatsapp:
|
||||
TWILIO_WHATSAPP_FROM=whatsapp:+17343367101
|
||||
|
||||
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -1,3 +1 @@
|
||||
* text=auto eol=lf
|
||||
CLAUDE.md -text
|
||||
src/gateway/server-methods/CLAUDE.md -text
|
||||
|
||||
28
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
28
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Report a problem or unexpected behavior in Clawdbot.
|
||||
title: "[Bug]: "
|
||||
labels: bug
|
||||
---
|
||||
|
||||
## Summary
|
||||
What went wrong?
|
||||
|
||||
## Steps to reproduce
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Expected behavior
|
||||
What did you expect to happen?
|
||||
|
||||
## Actual behavior
|
||||
What actually happened?
|
||||
|
||||
## Environment
|
||||
- Clawdbot version:
|
||||
- OS:
|
||||
- Install method (pnpm/npx/docker/etc):
|
||||
|
||||
## Logs or screenshots
|
||||
Paste relevant logs or add screenshots (redact secrets).
|
||||
137
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
137
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,137 +0,0 @@
|
||||
name: Bug report
|
||||
description: Report defects, including regressions, crashes, and behavior bugs.
|
||||
title: "[Bug]: "
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for filing this report. Keep it concise, reproducible, and evidence-based.
|
||||
- type: dropdown
|
||||
id: bug_type
|
||||
attributes:
|
||||
label: Bug type
|
||||
description: Choose the category that best matches this report.
|
||||
options:
|
||||
- Regression (worked before, now fails)
|
||||
- Crash (process/app exits or hangs)
|
||||
- Behavior bug (incorrect output/state without crash)
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
label: Summary
|
||||
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.
|
||||
placeholder: |
|
||||
1. Configure channel X.
|
||||
2. Send message Y.
|
||||
3. Run command Z.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
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: What happened instead, including user-visible errors.
|
||||
placeholder: No reply is posted; gateway logs "reply target not found".
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: OpenClaw version
|
||||
description: Exact version/build tested.
|
||||
placeholder: <version such as 2026.2.17>
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating system
|
||||
description: OS and version where this occurs.
|
||||
placeholder: macOS 15.4 / Ubuntu 24.04 / Windows 11
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: install_method
|
||||
attributes:
|
||||
label: Install method
|
||||
description: How OpenClaw was installed or launched.
|
||||
placeholder: npm global / pnpm dev / docker / mac app
|
||||
- type: input
|
||||
id: model
|
||||
attributes:
|
||||
label: Model
|
||||
description: Effective model under test.
|
||||
placeholder: minimax/text-01 / openrouter/anthropic/claude-opus-4.1 / anthropic/claude-sonnet-4.5
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: provider_chain
|
||||
attributes:
|
||||
label: Provider / routing chain
|
||||
description: Effective request path through gateways, proxies, providers, or model routers.
|
||||
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:
|
||||
label: Additional provider/model setup details
|
||||
description: Optional. Include redacted routing details, per-agent overrides, auth-profile interactions, env/config context, or anything else needed to explain the effective provider/model setup. Do not include API keys, tokens, or passwords.
|
||||
placeholder: |
|
||||
Default route is openclaw -> cloudflare-ai-gateway -> minimax.
|
||||
Previous setup was openclaw -> cloudflare-ai-gateway -> openrouter -> minimax.
|
||||
Relevant config lives in ~/.openclaw/openclaw.json under models.providers.minimax and models.providers.cloudflare-ai-gateway.
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs, screenshots, and evidence
|
||||
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.
|
||||
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 <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 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 ...
|
||||
6
.github/ISSUE_TEMPLATE/config.yml
vendored
6
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,8 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Onboarding
|
||||
url: https://discord.gg/clawd
|
||||
about: "New to OpenClaw? Join Discord for setup guidance in #help."
|
||||
about: New to Clawdbot? Join Discord for setup guidance from Krill in #help.
|
||||
- name: Support
|
||||
url: https://discord.gg/clawd
|
||||
about: "Get help from the OpenClaw community on Discord in #help."
|
||||
about: Get help from Krill and the community on Discord in #help.
|
||||
|
||||
18
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
18
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea or improvement for Clawdbot.
|
||||
title: "[Feature]: "
|
||||
labels: enhancement
|
||||
---
|
||||
|
||||
## Summary
|
||||
Describe the problem you are trying to solve or the opportunity you see.
|
||||
|
||||
## Proposed solution
|
||||
What would you like Clawdbot to do?
|
||||
|
||||
## Alternatives considered
|
||||
Any other approaches you have considered?
|
||||
|
||||
## Additional context
|
||||
Links, screenshots, or related issues.
|
||||
70
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
70
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -1,70 +0,0 @@
|
||||
name: Feature request
|
||||
description: Propose a new capability or product improvement.
|
||||
title: "[Feature]: "
|
||||
labels:
|
||||
- enhancement
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Help us evaluate this request with concrete use cases and tradeoffs.
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
label: Summary
|
||||
description: One-line statement of the requested capability.
|
||||
placeholder: Add per-channel default response prefix.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem to solve
|
||||
description: What user pain this solves and why current behavior is insufficient.
|
||||
placeholder: Agents cannot distinguish persona context in mixed channels, causing misrouted follow-ups.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: proposed_solution
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: Desired behavior/API/UX with as much specificity as possible.
|
||||
placeholder: Support channels.<channel>.responsePrefix with default fallback and account-level override.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
description: Other approaches considered and why they are weaker.
|
||||
placeholder: Manual prefixing in prompts is inconsistent and hard to enforce.
|
||||
- type: textarea
|
||||
id: impact
|
||||
attributes:
|
||||
label: Impact
|
||||
description: |
|
||||
Explain who is affected, severity/urgency, how often this pain occurs, and practical consequences.
|
||||
Include:
|
||||
- Affected users/systems/channels
|
||||
- Severity (annoying, blocks workflow, etc.)
|
||||
- Frequency (always/intermittent/edge case)
|
||||
- Consequence (delays, errors, extra manual work, etc.)
|
||||
placeholder: |
|
||||
Affected: Multi-team shared channels
|
||||
Severity: Medium
|
||||
Frequency: Daily
|
||||
Consequence: +20 minutes/day/operator and delayed alerts
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: evidence
|
||||
attributes:
|
||||
label: Evidence/examples
|
||||
description: Prior art, links, screenshots, logs, or metrics.
|
||||
placeholder: Comparable behavior in X, sample config, and screenshot of current limitation.
|
||||
- type: textarea
|
||||
id: additional_information
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Extra context, constraints, or references not covered above.
|
||||
placeholder: Must remain backward-compatible with existing config keys.
|
||||
23
.github/actionlint.yaml
vendored
23
.github/actionlint.yaml
vendored
@@ -1,23 +0,0 @@
|
||||
# actionlint configuration
|
||||
# https://github.com/rhysd/actionlint/blob/main/docs/config.md
|
||||
|
||||
self-hosted-runner:
|
||||
labels:
|
||||
# Blacksmith CI runners
|
||||
- blacksmith-8vcpu-ubuntu-2404
|
||||
- blacksmith-8vcpu-windows-2025
|
||||
- blacksmith-16vcpu-ubuntu-2404
|
||||
- blacksmith-16vcpu-windows-2025
|
||||
- blacksmith-32vcpu-windows-2025
|
||||
- blacksmith-16vcpu-ubuntu-2404-arm
|
||||
|
||||
# Ignore patterns for known issues
|
||||
paths:
|
||||
.github/workflows/**/*.yml:
|
||||
ignore:
|
||||
# Ignore shellcheck warnings (we run shellcheck separately)
|
||||
- "shellcheck reported issue.+"
|
||||
# Ignore intentional if: false for disabled jobs
|
||||
- 'constant expression "false" in condition'
|
||||
# actionlint's built-in runner label allowlist lags Blacksmith additions.
|
||||
- 'label "blacksmith-16vcpu-[^"]+" is unknown\.'
|
||||
53
.github/actions/detect-docs-changes/action.yml
vendored
53
.github/actions/detect-docs-changes/action.yml
vendored
@@ -1,53 +0,0 @@
|
||||
name: Detect docs-only changes
|
||||
description: >
|
||||
Outputs docs_only=true when all changed files are under docs/ or are
|
||||
markdown (.md/.mdx). Fail-safe: if detection fails, outputs false (run
|
||||
everything). Uses git diff — no API calls, no extra permissions needed.
|
||||
|
||||
outputs:
|
||||
docs_only:
|
||||
description: "'true' if all changes are docs/markdown, 'false' otherwise"
|
||||
value: ${{ steps.check.outputs.docs_only }}
|
||||
docs_changed:
|
||||
description: "'true' if any changed file is under docs/ or is markdown"
|
||||
value: ${{ steps.check.outputs.docs_changed }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Detect docs-only changes
|
||||
id: check
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
BASE="${{ github.event.before }}"
|
||||
else
|
||||
# Use the exact base SHA from the event payload — stable regardless
|
||||
# of base branch movement (avoids origin/<ref> drift).
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
fi
|
||||
|
||||
# Fail-safe: if we can't diff, assume non-docs (run everything)
|
||||
CHANGED=$(git diff --name-only "$BASE" HEAD 2>/dev/null || echo "UNKNOWN")
|
||||
if [ "$CHANGED" = "UNKNOWN" ] || [ -z "$CHANGED" ]; then
|
||||
echo "docs_only=false" >> "$GITHUB_OUTPUT"
|
||||
echo "docs_changed=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if any changed file is a doc
|
||||
DOCS=$(echo "$CHANGED" | grep -E '^docs/|\.md$|\.mdx$' || true)
|
||||
if [ -n "$DOCS" ]; then
|
||||
echo "docs_changed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "docs_changed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Check if all changed files are docs or markdown
|
||||
NON_DOCS=$(echo "$CHANGED" | grep -vE '^docs/|\.md$|\.mdx$' || true)
|
||||
if [ -z "$NON_DOCS" ]; then
|
||||
echo "docs_only=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Docs-only change detected — skipping heavy jobs"
|
||||
else
|
||||
echo "docs_only=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
47
.github/actions/ensure-base-commit/action.yml
vendored
47
.github/actions/ensure-base-commit/action.yml
vendored
@@ -1,47 +0,0 @@
|
||||
name: Ensure base commit
|
||||
description: Ensure a shallow checkout has enough history to diff against a base SHA.
|
||||
inputs:
|
||||
base-sha:
|
||||
description: Base commit SHA to diff against.
|
||||
required: true
|
||||
fetch-ref:
|
||||
description: Branch or ref to deepen/fetch from origin when base-sha is missing.
|
||||
required: true
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Ensure base commit is available
|
||||
shell: bash
|
||||
env:
|
||||
BASE_SHA: ${{ inputs.base-sha }}
|
||||
FETCH_REF: ${{ inputs.fetch-ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "$BASE_SHA" ] || [[ "$BASE_SHA" =~ ^0+$ ]]; then
|
||||
echo "No concrete base SHA available; skipping targeted fetch."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if git rev-parse --verify "$BASE_SHA^{commit}" >/dev/null 2>&1; then
|
||||
echo "Base commit already present: $BASE_SHA"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for deepen_by in 25 100 300; do
|
||||
echo "Base commit missing; deepening $FETCH_REF by $deepen_by."
|
||||
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
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Base commit still missing; fetching full history for $FETCH_REF."
|
||||
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
|
||||
fi
|
||||
|
||||
echo "Base commit still unavailable after fetch attempts: $BASE_SHA"
|
||||
113
.github/actions/setup-node-env/action.yml
vendored
113
.github/actions/setup-node-env/action.yml
vendored
@@ -1,113 +0,0 @@
|
||||
name: Setup Node environment
|
||||
description: >
|
||||
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.
|
||||
required: false
|
||||
default: "24.x"
|
||||
cache-key-suffix:
|
||||
description: Suffix appended to the pnpm store cache key.
|
||||
required: false
|
||||
default: "node24"
|
||||
pnpm-version:
|
||||
description: pnpm version for corepack.
|
||||
required: false
|
||||
default: "10.23.0"
|
||||
install-bun:
|
||||
description: Whether to install Bun alongside Node.
|
||||
required: false
|
||||
default: "true"
|
||||
use-sticky-disk:
|
||||
description: Request Blacksmith sticky-disk pnpm caching on trusted runs; pull_request runs fall back to actions/cache.
|
||||
required: false
|
||||
default: "false"
|
||||
install-deps:
|
||||
description: Whether to run pnpm install after environment setup.
|
||||
required: false
|
||||
default: "true"
|
||||
frozen-lockfile:
|
||||
description: Whether to use --frozen-lockfile for install.
|
||||
required: false
|
||||
default: "true"
|
||||
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@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
check-latest: false
|
||||
|
||||
- name: Setup pnpm + cache store
|
||||
uses: ./.github/actions/setup-pnpm-store-cache
|
||||
with:
|
||||
pnpm-version: ${{ inputs.pnpm-version }}
|
||||
cache-key-suffix: ${{ inputs.cache-key-suffix }}
|
||||
use-sticky-disk: ${{ inputs.use-sticky-disk }}
|
||||
|
||||
- name: Setup Bun
|
||||
if: inputs.install-bun == 'true'
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.9"
|
||||
|
||||
- name: Runtime versions
|
||||
shell: bash
|
||||
run: |
|
||||
node -v
|
||||
npm -v
|
||||
pnpm -v
|
||||
if command -v bun &>/dev/null; then bun -v; fi
|
||||
|
||||
- name: Capture node path
|
||||
if: inputs.install-deps == 'true'
|
||||
shell: bash
|
||||
run: echo "NODE_BIN=$(dirname "$(node -p "process.execPath")")" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
if: inputs.install-deps == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
CI: "true"
|
||||
FROZEN_LOCKFILE: ${{ inputs.frozen-lockfile }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export PATH="$NODE_BIN:$PATH"
|
||||
which node
|
||||
node -v
|
||||
pnpm -v
|
||||
case "$FROZEN_LOCKFILE" in
|
||||
true) LOCKFILE_FLAG="--frozen-lockfile" ;;
|
||||
false) LOCKFILE_FLAG="" ;;
|
||||
*)
|
||||
echo "::error::Invalid frozen-lockfile input: '$FROZEN_LOCKFILE' (expected true or false)"
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
install_args=(
|
||||
install
|
||||
--ignore-scripts=false
|
||||
--config.engine-strict=false
|
||||
--config.enable-pre-post-scripts=true
|
||||
)
|
||||
if [ -n "$LOCKFILE_FLAG" ]; then
|
||||
install_args+=("$LOCKFILE_FLAG")
|
||||
fi
|
||||
pnpm "${install_args[@]}" || pnpm "${install_args[@]}"
|
||||
@@ -1,76 +0,0 @@
|
||||
name: Setup pnpm + store cache
|
||||
description: Prepare pnpm via corepack and restore pnpm store cache.
|
||||
inputs:
|
||||
pnpm-version:
|
||||
description: pnpm version to activate via corepack.
|
||||
required: false
|
||||
default: "10.23.0"
|
||||
cache-key-suffix:
|
||||
description: Suffix appended to the cache key.
|
||||
required: false
|
||||
default: "node24"
|
||||
use-sticky-disk:
|
||||
description: Use Blacksmith sticky disks instead of actions/cache for pnpm store on trusted runs; pull_request runs fall back to actions/cache.
|
||||
required: false
|
||||
default: "false"
|
||||
use-restore-keys:
|
||||
description: Whether to use restore-keys fallback for actions/cache.
|
||||
required: false
|
||||
default: "true"
|
||||
use-actions-cache:
|
||||
description: Whether to restore/save pnpm store with actions/cache, including pull_request fallback when sticky disks are disabled.
|
||||
required: false
|
||||
default: "true"
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Setup pnpm (corepack retry)
|
||||
shell: bash
|
||||
env:
|
||||
PNPM_VERSION: ${{ inputs.pnpm-version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "$PNPM_VERSION" =~ ^[0-9]+(\.[0-9]+){1,2}([.-][0-9A-Za-z.-]+)?$ ]]; then
|
||||
echo "::error::Invalid pnpm-version input: '$PNPM_VERSION'"
|
||||
exit 2
|
||||
fi
|
||||
corepack enable
|
||||
for attempt in 1 2 3; do
|
||||
if corepack prepare "pnpm@$PNPM_VERSION" --activate; then
|
||||
pnpm -v
|
||||
exit 0
|
||||
fi
|
||||
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Resolve pnpm store path
|
||||
id: pnpm-store
|
||||
shell: bash
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Mount pnpm store sticky disk
|
||||
# Keep persistent sticky-disk state off untrusted PR runs.
|
||||
if: inputs.use-sticky-disk == 'true' && github.event_name != 'pull_request'
|
||||
uses: useblacksmith/stickydisk@v1
|
||||
with:
|
||||
key: ${{ github.repository }}-pnpm-store-${{ runner.os }}-${{ github.ref_name }}-${{ inputs.cache-key-suffix }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
|
||||
- name: Restore pnpm store cache (exact key only)
|
||||
# PRs that request sticky disks still need a safe cache restore path.
|
||||
if: inputs.use-actions-cache == 'true' && (inputs.use-sticky-disk != 'true' || github.event_name == 'pull_request') && inputs.use-restore-keys != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ inputs.cache-key-suffix }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
- name: Restore pnpm store cache (with fallback keys)
|
||||
if: inputs.use-actions-cache == 'true' && (inputs.use-sticky-disk != 'true' || github.event_name == 'pull_request') && inputs.use-restore-keys == 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ inputs.cache-key-suffix }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-${{ inputs.cache-key-suffix }}-
|
||||
18
.github/codeql/codeql-javascript-typescript.yml
vendored
18
.github/codeql/codeql-javascript-typescript.yml
vendored
@@ -1,18 +0,0 @@
|
||||
name: openclaw-codeql-javascript-typescript
|
||||
|
||||
paths:
|
||||
- src
|
||||
- extensions
|
||||
- ui/src
|
||||
- skills
|
||||
|
||||
paths-ignore:
|
||||
- apps
|
||||
- dist
|
||||
- docs
|
||||
- "**/node_modules"
|
||||
- "**/coverage"
|
||||
- "**/*.test.ts"
|
||||
- "**/*.test.tsx"
|
||||
- "**/*.e2e.test.ts"
|
||||
- "**/*.e2e.test.tsx"
|
||||
127
.github/dependabot.yml
vendored
127
.github/dependabot.yml
vendored
@@ -1,127 +0,0 @@
|
||||
# Dependabot configuration
|
||||
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
|
||||
registries:
|
||||
npm-npmjs:
|
||||
type: npm-registry
|
||||
url: https://registry.npmjs.org
|
||||
token: ${{secrets.NPM_NPMJS_TOKEN}}
|
||||
replaces-base: true
|
||||
|
||||
updates:
|
||||
# npm dependencies (root)
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
schedule:
|
||||
interval: daily
|
||||
cooldown:
|
||||
default-days: 2
|
||||
groups:
|
||||
production:
|
||||
dependency-type: production
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
development:
|
||||
dependency-type: development
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
open-pull-requests-limit: 10
|
||||
registries:
|
||||
- npm-npmjs
|
||||
|
||||
# GitHub Actions
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: daily
|
||||
cooldown:
|
||||
default-days: 2
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
open-pull-requests-limit: 5
|
||||
|
||||
# Swift Package Manager - macOS app
|
||||
- package-ecosystem: swift
|
||||
directory: /apps/macos
|
||||
schedule:
|
||||
interval: daily
|
||||
cooldown:
|
||||
default-days: 2
|
||||
groups:
|
||||
swift-deps:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
open-pull-requests-limit: 5
|
||||
|
||||
# Swift Package Manager - shared MoltbotKit
|
||||
- package-ecosystem: swift
|
||||
directory: /apps/shared/MoltbotKit
|
||||
schedule:
|
||||
interval: daily
|
||||
cooldown:
|
||||
default-days: 2
|
||||
groups:
|
||||
swift-deps:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
open-pull-requests-limit: 5
|
||||
|
||||
# Swift Package Manager - Swabble
|
||||
- package-ecosystem: swift
|
||||
directory: /Swabble
|
||||
schedule:
|
||||
interval: daily
|
||||
cooldown:
|
||||
default-days: 2
|
||||
groups:
|
||||
swift-deps:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
open-pull-requests-limit: 5
|
||||
|
||||
# Gradle - Android app
|
||||
- package-ecosystem: gradle
|
||||
directory: /apps/android
|
||||
schedule:
|
||||
interval: daily
|
||||
cooldown:
|
||||
default-days: 2
|
||||
groups:
|
||||
android-deps:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
open-pull-requests-limit: 5
|
||||
|
||||
# Docker base images
|
||||
- package-ecosystem: docker
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 2
|
||||
groups:
|
||||
docker-images:
|
||||
patterns:
|
||||
- "*"
|
||||
open-pull-requests-limit: 5
|
||||
64
.github/instructions/copilot.instructions.md
vendored
64
.github/instructions/copilot.instructions.md
vendored
@@ -1,64 +0,0 @@
|
||||
# OpenClaw Codebase Patterns
|
||||
|
||||
**Always reuse existing code - no redundancy!**
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Runtime**: Node 22+ (Bun also supported for dev/scripts)
|
||||
- **Language**: TypeScript (ESM, strict mode)
|
||||
- **Package Manager**: pnpm (keep `pnpm-lock.yaml` in sync)
|
||||
- **Lint/Format**: Oxlint, Oxfmt (`pnpm check`)
|
||||
- **Tests**: Vitest with V8 coverage
|
||||
- **CLI Framework**: Commander + clack/prompts
|
||||
- **Build**: tsdown (outputs to `dist/`)
|
||||
|
||||
## Anti-Redundancy Rules
|
||||
|
||||
- Avoid files that just re-export from another file. Import directly from the original source.
|
||||
- If a function already exists, import it - do NOT create a duplicate in another file.
|
||||
- Before creating any formatter, utility, or helper, search for existing implementations first.
|
||||
|
||||
## Source of Truth Locations
|
||||
|
||||
### Formatting Utilities (`src/infra/`)
|
||||
|
||||
- **Time formatting**: `src\infra\format-time`
|
||||
|
||||
**NEVER create local `formatAge`, `formatDuration`, `formatElapsedTime` functions - import from centralized modules.**
|
||||
|
||||
### Terminal Output (`src/terminal/`)
|
||||
|
||||
- Tables: `src/terminal/table.ts` (`renderTable`)
|
||||
- Themes/colors: `src/terminal/theme.ts` (`theme.success`, `theme.muted`, etc.)
|
||||
- Progress: `src/cli/progress.ts` (spinners, progress bars)
|
||||
|
||||
### CLI Patterns
|
||||
|
||||
- CLI option wiring: `src/cli/`
|
||||
- Commands: `src/commands/`
|
||||
- Dependency injection via `createDefaultDeps`
|
||||
|
||||
## Import Conventions
|
||||
|
||||
- Use `.js` extension for cross-package imports (ESM)
|
||||
- Direct imports only - no re-export wrapper files
|
||||
- Types: `import type { X }` for type-only imports
|
||||
|
||||
## Code Quality
|
||||
|
||||
- TypeScript (ESM), strict typing, avoid `any`
|
||||
- Keep files under ~700 LOC - extract helpers when larger
|
||||
- Colocated tests: `*.test.ts` next to source files
|
||||
- Run `pnpm check` before commits (lint + format)
|
||||
- Run `pnpm tsgo` for type checking
|
||||
|
||||
## Stack & Commands
|
||||
|
||||
- **Package manager**: pnpm (`pnpm install`)
|
||||
- **Dev**: `pnpm openclaw ...` or `pnpm dev`
|
||||
- **Type-check**: `pnpm tsgo`
|
||||
- **Lint/format**: `pnpm check`
|
||||
- **Tests**: `pnpm test`
|
||||
- **Build**: `pnpm build`
|
||||
|
||||
If you are coding together with a human, do NOT use scripts/committer, but git directly and run the above commands manually to ensure quality.
|
||||
258
.github/labeler.yml
vendored
258
.github/labeler.yml
vendored
@@ -1,258 +0,0 @@
|
||||
"channel: bluebubbles":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/bluebubbles/**"
|
||||
- "docs/channels/bluebubbles.md"
|
||||
"channel: discord":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/discord/**"
|
||||
- "extensions/discord/**"
|
||||
- "docs/channels/discord.md"
|
||||
"channel: irc":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/irc/**"
|
||||
- "docs/channels/irc.md"
|
||||
"channel: feishu":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/feishu/**"
|
||||
- "extensions/feishu/**"
|
||||
- "docs/channels/feishu.md"
|
||||
"channel: googlechat":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/googlechat/**"
|
||||
- "docs/channels/googlechat.md"
|
||||
"channel: imessage":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/imessage/**"
|
||||
- "extensions/imessage/**"
|
||||
- "docs/channels/imessage.md"
|
||||
"channel: line":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/line/**"
|
||||
- "docs/channels/line.md"
|
||||
"channel: matrix":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/matrix/**"
|
||||
- "docs/channels/matrix.md"
|
||||
"channel: mattermost":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/mattermost/**"
|
||||
- "docs/channels/mattermost.md"
|
||||
"channel: msteams":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/msteams/**"
|
||||
- "docs/channels/msteams.md"
|
||||
"channel: nextcloud-talk":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/nextcloud-talk/**"
|
||||
- "docs/channels/nextcloud-talk.md"
|
||||
"channel: nostr":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/nostr/**"
|
||||
- "docs/channels/nostr.md"
|
||||
"channel: signal":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/signal/**"
|
||||
- "extensions/signal/**"
|
||||
- "docs/channels/signal.md"
|
||||
"channel: slack":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/slack/**"
|
||||
- "extensions/slack/**"
|
||||
- "docs/channels/slack.md"
|
||||
"channel: telegram":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/telegram/**"
|
||||
- "extensions/telegram/**"
|
||||
- "docs/channels/telegram.md"
|
||||
"channel: tlon":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/tlon/**"
|
||||
- "docs/channels/tlon.md"
|
||||
"channel: twitch":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/twitch/**"
|
||||
- "docs/channels/twitch.md"
|
||||
"channel: voice-call":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/voice-call/**"
|
||||
"channel: whatsapp-web":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/web/**"
|
||||
- "extensions/whatsapp/**"
|
||||
- "docs/channels/whatsapp.md"
|
||||
"channel: zalo":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/zalo/**"
|
||||
- "docs/channels/zalo.md"
|
||||
"channel: zalouser":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/zalouser/**"
|
||||
- "docs/channels/zalouser.md"
|
||||
|
||||
"app: android":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "apps/android/**"
|
||||
- "docs/platforms/android.md"
|
||||
"app: ios":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "apps/ios/**"
|
||||
- "docs/platforms/ios.md"
|
||||
"app: macos":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "apps/macos/**"
|
||||
- "docs/platforms/macos.md"
|
||||
- "docs/platforms/mac/**"
|
||||
"app: web-ui":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "ui/**"
|
||||
- "src/gateway/control-ui.ts"
|
||||
- "src/gateway/control-ui-shared.ts"
|
||||
- "src/gateway/protocol/**"
|
||||
- "src/gateway/server-methods/chat.ts"
|
||||
- "src/infra/control-ui-assets.ts"
|
||||
|
||||
"gateway":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/gateway/**"
|
||||
- "src/daemon/**"
|
||||
- "docs/gateway/**"
|
||||
|
||||
"docs":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "docs/**"
|
||||
- "docs.acp.md"
|
||||
|
||||
"cli":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/cli/**"
|
||||
|
||||
"commands":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/commands/**"
|
||||
|
||||
"scripts":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "scripts/**"
|
||||
|
||||
"docker":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "Dockerfile"
|
||||
- "Dockerfile.*"
|
||||
- "docker-compose.yml"
|
||||
- "docker-setup.sh"
|
||||
- ".dockerignore"
|
||||
- "scripts/**/*docker*"
|
||||
- "scripts/**/Dockerfile*"
|
||||
- "scripts/sandbox-*.sh"
|
||||
- "src/agents/sandbox*.ts"
|
||||
- "src/commands/sandbox*.ts"
|
||||
- "src/cli/sandbox-cli.ts"
|
||||
- "src/docker-setup.test.ts"
|
||||
- "src/config/**/*sandbox*"
|
||||
- "docs/cli/sandbox.md"
|
||||
- "docs/gateway/sandbox*.md"
|
||||
- "docs/install/docker.md"
|
||||
- "docs/multi-agent-sandbox-tools.md"
|
||||
|
||||
"agents":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/agents/**"
|
||||
|
||||
"security":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "docs/cli/security.md"
|
||||
- "docs/gateway/security.md"
|
||||
|
||||
"extensions: copilot-proxy":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/copilot-proxy/**"
|
||||
"extensions: diagnostics-otel":
|
||||
- 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:
|
||||
- "extensions/llm-task/**"
|
||||
"extensions: lobster":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/lobster/**"
|
||||
"extensions: memory-core":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/memory-core/**"
|
||||
"extensions: memory-lancedb":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/memory-lancedb/**"
|
||||
"extensions: open-prose":
|
||||
- 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: acpx":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/acpx/**"
|
||||
"extensions: minimax-portal-auth":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/minimax-portal-auth/**"
|
||||
"extensions: phone-control":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/phone-control/**"
|
||||
"extensions: talk-voice":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/talk-voice/**"
|
||||
115
.github/pull_request_template.md
vendored
115
.github/pull_request_template.md
vendored
@@ -1,115 +0,0 @@
|
||||
## Summary
|
||||
|
||||
Describe the problem and fix in 2–5 bullets:
|
||||
|
||||
- Problem:
|
||||
- Why it matters:
|
||||
- What changed:
|
||||
- What did NOT change (scope boundary):
|
||||
|
||||
## Change Type (select all)
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] Feature
|
||||
- [ ] Refactor
|
||||
- [ ] Docs
|
||||
- [ ] Security hardening
|
||||
- [ ] Chore/infra
|
||||
|
||||
## Scope (select all touched areas)
|
||||
|
||||
- [ ] Gateway / orchestration
|
||||
- [ ] Skills / tool execution
|
||||
- [ ] Auth / tokens
|
||||
- [ ] Memory / storage
|
||||
- [ ] Integrations
|
||||
- [ ] API / contracts
|
||||
- [ ] UI / DX
|
||||
- [ ] CI/CD / infra
|
||||
|
||||
## Linked Issue/PR
|
||||
|
||||
- Closes #
|
||||
- Related #
|
||||
|
||||
## User-visible / Behavior Changes
|
||||
|
||||
List user-visible changes (including defaults/config).
|
||||
If none, write `None`.
|
||||
|
||||
## Security Impact (required)
|
||||
|
||||
- New permissions/capabilities? (`Yes/No`)
|
||||
- Secrets/tokens handling changed? (`Yes/No`)
|
||||
- New/changed network calls? (`Yes/No`)
|
||||
- Command/tool execution surface changed? (`Yes/No`)
|
||||
- Data access scope changed? (`Yes/No`)
|
||||
- If any `Yes`, explain risk + mitigation:
|
||||
|
||||
## Repro + Verification
|
||||
|
||||
### Environment
|
||||
|
||||
- OS:
|
||||
- Runtime/container:
|
||||
- Model/provider:
|
||||
- Integration/channel (if any):
|
||||
- Relevant config (redacted):
|
||||
|
||||
### Steps
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
### Expected
|
||||
|
||||
-
|
||||
|
||||
### Actual
|
||||
|
||||
-
|
||||
|
||||
## Evidence
|
||||
|
||||
Attach at least one:
|
||||
|
||||
- [ ] Failing test/log before + passing after
|
||||
- [ ] Trace/log snippets
|
||||
- [ ] Screenshot/recording
|
||||
- [ ] Perf numbers (if relevant)
|
||||
|
||||
## Human Verification (required)
|
||||
|
||||
What you personally verified (not just CI), and how:
|
||||
|
||||
- Verified scenarios:
|
||||
- Edge cases checked:
|
||||
- What you did **not** verify:
|
||||
|
||||
## Review Conversations
|
||||
|
||||
- [ ] I replied to or resolved every bot review conversation I addressed in this PR.
|
||||
- [ ] I left unresolved only the conversations that still need reviewer or maintainer judgment.
|
||||
|
||||
If a bot review conversation is addressed by this PR, resolve that conversation yourself. Do not leave bot review conversation cleanup for maintainers.
|
||||
|
||||
## Compatibility / Migration
|
||||
|
||||
- Backward compatible? (`Yes/No`)
|
||||
- Config/env changes? (`Yes/No`)
|
||||
- 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`.
|
||||
|
||||
- Risk:
|
||||
- Mitigation:
|
||||
521
.github/workflows/auto-response.yml
vendored
521
.github/workflows/auto-response.yml
vendored
@@ -1,521 +0,0 @@
|
||||
name: Auto response
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited, labeled]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_target:
|
||||
types: [labeled]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
auto-response:
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token-fallback
|
||||
if: steps.app-token.outcome == 'failure'
|
||||
with:
|
||||
app-id: "2971289"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
|
||||
- name: Handle labeled items
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
script: |
|
||||
// Labels prefixed with "r:" are auto-response triggers.
|
||||
const activePrLimit = 10;
|
||||
const rules = [
|
||||
{
|
||||
label: "r: skill",
|
||||
close: true,
|
||||
message:
|
||||
"Thanks for the contribution! New skills should be published to [Clawhub](https://clawhub.ai) for everyone to use. We’re keeping the core lean on skills, so I’m closing this out.",
|
||||
},
|
||||
{
|
||||
label: "r: support",
|
||||
close: true,
|
||||
message:
|
||||
"Please use [our support server](https://discord.gg/clawd) and ask in #help or #users-helping-users to resolve this, or follow the stuck FAQ at https://docs.openclaw.ai/help/faq#im-stuck-whats-the-fastest-way-to-get-unstuck.",
|
||||
},
|
||||
{
|
||||
label: "r: no-ci-pr",
|
||||
close: true,
|
||||
message:
|
||||
"Please don't make PRs for test failures on main.\n\n" +
|
||||
"The team is aware of those and will handle them directly on the codebase, not only fixing the tests but also investigating what the root cause is. Having to sift through test-fix-PRs (including some that have been out of date for weeks...) on top of that doesn't help. There are already way too many PRs for humans to manage; please don't make the flood worse.\n\n" +
|
||||
"Thank you.",
|
||||
},
|
||||
{
|
||||
label: "r: too-many-prs",
|
||||
close: true,
|
||||
message:
|
||||
`Closing this PR because the author has more than ${activePrLimit} active PRs in this repo. ` +
|
||||
"Please reduce the active PR queue and reopen or resubmit once it is back under the limit. You can close your own PRs to get back under the limit.",
|
||||
},
|
||||
{
|
||||
label: "r: testflight",
|
||||
close: true,
|
||||
commentTriggers: ["testflight"],
|
||||
message: "Not available, build from source.",
|
||||
},
|
||||
{
|
||||
label: "r: third-party-extension",
|
||||
close: true,
|
||||
message:
|
||||
"Please make this as a third-party plugin that you maintain yourself in your own repo. Docs: https://docs.openclaw.ai/plugin. Feel free to open a PR after to add it to our community plugins page: https://docs.openclaw.ai/plugins/community",
|
||||
},
|
||||
{
|
||||
label: "r: moltbook",
|
||||
close: true,
|
||||
lock: true,
|
||||
lockReason: "off-topic",
|
||||
commentTriggers: ["moltbook"],
|
||||
message:
|
||||
"OpenClaw is not affiliated with Moltbook, and issues related to Moltbook should not be submitted here.",
|
||||
},
|
||||
];
|
||||
|
||||
const maintainerTeam = "maintainer";
|
||||
const pingWarningMessage =
|
||||
"Please don’t spam-ping multiple maintainers at once. Be patient, or join our community Discord for help: https://discord.gg/clawd";
|
||||
const mentionRegex = /@([A-Za-z0-9-]+)/g;
|
||||
const maintainerCache = new Map();
|
||||
const normalizeLogin = (login) => login.toLowerCase();
|
||||
const bugSubtypeLabelSpecs = {
|
||||
regression: {
|
||||
color: "D93F0B",
|
||||
description: "Behavior that previously worked and now fails",
|
||||
},
|
||||
"bug:crash": {
|
||||
color: "B60205",
|
||||
description: "Process/app exits unexpectedly or hangs",
|
||||
},
|
||||
"bug:behavior": {
|
||||
color: "D73A4A",
|
||||
description: "Incorrect behavior without a crash",
|
||||
},
|
||||
};
|
||||
const bugTypeToLabel = {
|
||||
"Regression (worked before, now fails)": "regression",
|
||||
"Crash (process/app exits or hangs)": "bug:crash",
|
||||
"Behavior bug (incorrect output/state without crash)": "bug:behavior",
|
||||
};
|
||||
const bugSubtypeLabels = Object.keys(bugSubtypeLabelSpecs);
|
||||
|
||||
const extractIssueFormValue = (body, field) => {
|
||||
if (!body) {
|
||||
return "";
|
||||
}
|
||||
const escapedField = field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const regex = new RegExp(
|
||||
`(?:^|\\n)###\\s+${escapedField}\\s*\\n([\\s\\S]*?)(?=\\n###\\s+|$)`,
|
||||
"i",
|
||||
);
|
||||
const match = body.match(regex);
|
||||
if (!match) {
|
||||
return "";
|
||||
}
|
||||
for (const line of match[1].split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const ensureLabelExists = async (name, color, description) => {
|
||||
try {
|
||||
await github.rest.issues.getLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
await github.rest.issues.createLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name,
|
||||
color,
|
||||
description,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const syncBugSubtypeLabel = async (issue, labelSet) => {
|
||||
if (!labelSet.has("bug")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedBugType = extractIssueFormValue(issue.body ?? "", "Bug type");
|
||||
const targetLabel = bugTypeToLabel[selectedBugType];
|
||||
if (!targetLabel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetSpec = bugSubtypeLabelSpecs[targetLabel];
|
||||
await ensureLabelExists(targetLabel, targetSpec.color, targetSpec.description);
|
||||
|
||||
for (const subtypeLabel of bugSubtypeLabels) {
|
||||
if (subtypeLabel === targetLabel) {
|
||||
continue;
|
||||
}
|
||||
if (!labelSet.has(subtypeLabel)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
name: subtypeLabel,
|
||||
});
|
||||
labelSet.delete(subtypeLabel);
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!labelSet.has(targetLabel)) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: [targetLabel],
|
||||
});
|
||||
labelSet.add(targetLabel);
|
||||
}
|
||||
};
|
||||
|
||||
const isMaintainer = async (login) => {
|
||||
if (!login) {
|
||||
return false;
|
||||
}
|
||||
const normalized = normalizeLogin(login);
|
||||
if (maintainerCache.has(normalized)) {
|
||||
return maintainerCache.get(normalized);
|
||||
}
|
||||
let isMember = false;
|
||||
try {
|
||||
const membership = await github.rest.teams.getMembershipForUserInOrg({
|
||||
org: context.repo.owner,
|
||||
team_slug: maintainerTeam,
|
||||
username: normalized,
|
||||
});
|
||||
isMember = membership?.data?.state === "active";
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
maintainerCache.set(normalized, isMember);
|
||||
return isMember;
|
||||
};
|
||||
|
||||
const countMaintainerMentions = async (body, authorLogin) => {
|
||||
if (!body) {
|
||||
return 0;
|
||||
}
|
||||
const normalizedAuthor = authorLogin ? normalizeLogin(authorLogin) : "";
|
||||
if (normalizedAuthor && (await isMaintainer(normalizedAuthor))) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const haystack = body.toLowerCase();
|
||||
const teamMention = `@${context.repo.owner.toLowerCase()}/${maintainerTeam}`;
|
||||
if (haystack.includes(teamMention)) {
|
||||
return 3;
|
||||
}
|
||||
|
||||
const mentions = new Set();
|
||||
for (const match of body.matchAll(mentionRegex)) {
|
||||
mentions.add(normalizeLogin(match[1]));
|
||||
}
|
||||
if (normalizedAuthor) {
|
||||
mentions.delete(normalizedAuthor);
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
for (const login of mentions) {
|
||||
if (await isMaintainer(login)) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
||||
const triggerLabel = "trigger-response";
|
||||
const activePrLimitLabel = "r: too-many-prs";
|
||||
const activePrLimitOverrideLabel = "r: too-many-prs-override";
|
||||
const target = context.payload.issue ?? context.payload.pull_request;
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const labelSet = new Set(
|
||||
(target.labels ?? [])
|
||||
.map((label) => (typeof label === "string" ? label : label?.name))
|
||||
.filter((name) => typeof name === "string"),
|
||||
);
|
||||
|
||||
const issue = context.payload.issue;
|
||||
const pullRequest = context.payload.pull_request;
|
||||
const comment = context.payload.comment;
|
||||
if (comment) {
|
||||
const authorLogin = comment.user?.login ?? "";
|
||||
if (comment.user?.type === "Bot" || authorLogin.endsWith("[bot]")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const commentBody = comment.body ?? "";
|
||||
const responses = [];
|
||||
const mentionCount = await countMaintainerMentions(commentBody, authorLogin);
|
||||
if (mentionCount >= 3) {
|
||||
responses.push(pingWarningMessage);
|
||||
}
|
||||
|
||||
const commentHaystack = commentBody.toLowerCase();
|
||||
const commentRule = rules.find((item) =>
|
||||
(item.commentTriggers ?? []).some((trigger) =>
|
||||
commentHaystack.includes(trigger),
|
||||
),
|
||||
);
|
||||
if (commentRule) {
|
||||
responses.push(commentRule.message);
|
||||
}
|
||||
|
||||
if (responses.length > 0) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: target.number,
|
||||
body: responses.join("\n\n"),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (issue) {
|
||||
const action = context.payload.action;
|
||||
if (action === "opened" || action === "edited") {
|
||||
const issueText = `${issue.title ?? ""}\n${issue.body ?? ""}`.trim();
|
||||
const authorLogin = issue.user?.login ?? "";
|
||||
const mentionCount = await countMaintainerMentions(
|
||||
issueText,
|
||||
authorLogin,
|
||||
);
|
||||
if (mentionCount >= 3) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: pingWarningMessage,
|
||||
});
|
||||
}
|
||||
|
||||
await syncBugSubtypeLabel(issue, labelSet);
|
||||
}
|
||||
}
|
||||
|
||||
const hasTriggerLabel = labelSet.has(triggerLabel);
|
||||
if (hasTriggerLabel) {
|
||||
labelSet.delete(triggerLabel);
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: target.number,
|
||||
name: triggerLabel,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isLabelEvent = context.payload.action === "labeled";
|
||||
if (!hasTriggerLabel && !isLabelEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (issue) {
|
||||
const title = issue.title ?? "";
|
||||
const body = issue.body ?? "";
|
||||
const haystack = `${title}\n${body}`.toLowerCase();
|
||||
const hasMoltbookLabel = labelSet.has("r: moltbook");
|
||||
const hasTestflightLabel = labelSet.has("r: testflight");
|
||||
const hasSecurityLabel = labelSet.has("security");
|
||||
if (title.toLowerCase().includes("security") && !hasSecurityLabel) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: ["security"],
|
||||
});
|
||||
labelSet.add("security");
|
||||
}
|
||||
if (title.toLowerCase().includes("testflight") && !hasTestflightLabel) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: ["r: testflight"],
|
||||
});
|
||||
labelSet.add("r: testflight");
|
||||
}
|
||||
if (haystack.includes("moltbook") && !hasMoltbookLabel) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: ["r: moltbook"],
|
||||
});
|
||||
labelSet.add("r: moltbook");
|
||||
}
|
||||
}
|
||||
|
||||
const invalidLabel = "invalid";
|
||||
const spamLabel = "r: spam";
|
||||
const dirtyLabel = "dirty";
|
||||
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(dirtyLabel)) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
body: noisyPrMessage,
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
state: "closed",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const labelCount = labelSet.size;
|
||||
if (labelCount > 20) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
body: noisyPrMessage,
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
state: "closed",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (labelSet.has(spamLabel)) {
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
state: "closed",
|
||||
});
|
||||
await github.rest.issues.lock({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
lock_reason: "spam",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (labelSet.has(invalidLabel)) {
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
state: "closed",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (issue && labelSet.has(spamLabel)) {
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
state: "closed",
|
||||
state_reason: "not_planned",
|
||||
});
|
||||
await github.rest.issues.lock({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
lock_reason: "spam",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (issue && labelSet.has(invalidLabel)) {
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
state: "closed",
|
||||
state_reason: "not_planned",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pullRequest && labelSet.has(activePrLimitOverrideLabel)) {
|
||||
labelSet.delete(activePrLimitLabel);
|
||||
}
|
||||
|
||||
const rule = rules.find((item) => labelSet.has(item.label));
|
||||
if (!rule) {
|
||||
return;
|
||||
}
|
||||
|
||||
const issueNumber = target.number;
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
body: rule.message,
|
||||
});
|
||||
|
||||
if (rule.close) {
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
state: "closed",
|
||||
});
|
||||
}
|
||||
|
||||
if (rule.lock) {
|
||||
await github.rest.issues.lock({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
lock_reason: rule.lockReason ?? "resolved",
|
||||
});
|
||||
}
|
||||
852
.github/workflows/ci.yml
vendored
852
.github/workflows/ci.yml
vendored
@@ -2,464 +2,132 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
# Detect docs-only changes to skip heavy jobs (test, build, Windows, macOS, Android).
|
||||
# Lint and format always run. Fail-safe: if detection fails, run everything.
|
||||
docs-scope:
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
outputs:
|
||||
docs_only: ${{ steps.check.outputs.docs_only }}
|
||||
docs_changed: ${{ steps.check.outputs.docs_changed }}
|
||||
install-check:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
fetch-tags: false
|
||||
submodules: false
|
||||
|
||||
- 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: check
|
||||
uses: ./.github/actions/detect-docs-changes
|
||||
|
||||
# Detect which heavy areas are touched so PRs can skip unrelated expensive jobs.
|
||||
# Push to main keeps broad coverage, but this job still needs to run so
|
||||
# downstream jobs that list it in `needs` are not skipped.
|
||||
changed-scope:
|
||||
needs: [docs-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
outputs:
|
||||
run_node: ${{ steps.scope.outputs.run_node }}
|
||||
run_macos: ${{ steps.scope.outputs.run_macos }}
|
||||
run_android: ${{ steps.scope.outputs.run_android }}
|
||||
run_skills_python: ${{ steps.scope.outputs.run_skills_python }}
|
||||
run_windows: ${{ steps.scope.outputs.run_windows }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
fetch-tags: false
|
||||
submodules: false
|
||||
|
||||
- name: Ensure changed-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 changed scopes
|
||||
id: scope
|
||||
shell: bash
|
||||
- name: Checkout submodules (retry)
|
||||
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
|
||||
|
||||
# Build dist once for Node-relevant changes and share it with downstream jobs.
|
||||
build-artifacts:
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Ensure secrets base commit (PR fast path)
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: ./.github/actions/ensure-base-commit
|
||||
with:
|
||||
base-sha: ${{ github.event.pull_request.base.sha }}
|
||||
fetch-ref: ${{ github.event.pull_request.base.ref }}
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "true"
|
||||
|
||||
- name: Build dist
|
||||
run: pnpm build
|
||||
|
||||
- name: Upload dist artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist-build
|
||||
path: dist/
|
||||
retention-days: 1
|
||||
|
||||
# Validate npm pack contents after build (only on push to main, not PRs).
|
||||
release-check:
|
||||
needs: [docs-scope, build-artifacts]
|
||||
if: github.event_name == 'push' && needs.docs-scope.outputs.docs_only != 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "true"
|
||||
|
||||
- name: Download dist artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist-build
|
||||
path: dist/
|
||||
|
||||
- name: Check release contents
|
||||
run: pnpm release:check
|
||||
|
||||
checks:
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runtime: node
|
||||
task: test
|
||||
command: pnpm canvas:a2ui:bundle && pnpm test
|
||||
- runtime: node
|
||||
task: extensions
|
||||
command: pnpm test:extensions
|
||||
- runtime: node
|
||||
task: protocol
|
||||
command: pnpm protocol:check
|
||||
- runtime: bun
|
||||
task: test
|
||||
command: pnpm canvas:a2ui:bundle && bunx vitest run --config vitest.unit.config.ts
|
||||
steps:
|
||||
- name: Skip bun lane on push
|
||||
if: github.event_name == 'push' && matrix.runtime == 'bun'
|
||||
run: echo "Skipping bun test lane on push events."
|
||||
|
||||
- name: Checkout
|
||||
if: github.event_name != 'push' || matrix.runtime != 'bun'
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
if: matrix.runtime != 'bun' || github.event_name != 'push'
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "${{ matrix.runtime == 'bun' }}"
|
||||
use-sticky-disk: "true"
|
||||
|
||||
- name: Configure Node test resources
|
||||
if: (github.event_name != 'push' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node'
|
||||
run: |
|
||||
# `pnpm test` runs `scripts/test-parallel.mjs`, which spawns multiple Node processes.
|
||||
# Default heap limits have been too low on Linux CI (V8 OOM near 4GB).
|
||||
echo "OPENCLAW_TEST_WORKERS=2" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_TEST_MAX_OLD_SPACE_SIZE_MB=6144" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||
if: matrix.runtime != 'bun' || github.event_name != 'push'
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
# Types, lint, and format check.
|
||||
check:
|
||||
name: "check"
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "true"
|
||||
|
||||
- name: Check types and lint and oxfmt
|
||||
run: pnpm check
|
||||
|
||||
- name: Strict TS build smoke
|
||||
run: pnpm build:strict-smoke
|
||||
|
||||
- name: Enforce safe external URL opening policy
|
||||
run: pnpm lint:ui:no-raw-window-open
|
||||
|
||||
# Validate docs (format, lint, broken links) only when docs files changed.
|
||||
check-docs:
|
||||
needs: [docs-scope]
|
||||
if: needs.docs-scope.outputs.docs_changed == 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "true"
|
||||
|
||||
- name: Check docs
|
||||
run: pnpm check:docs
|
||||
|
||||
compat-node22:
|
||||
name: "compat-node22"
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node 22 compatibility environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: "22.x"
|
||||
cache-key-suffix: "node22"
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "true"
|
||||
|
||||
- name: Configure Node 22 test resources
|
||||
run: |
|
||||
# Keep the compatibility lane aligned with the default Node test lane.
|
||||
echo "OPENCLAW_TEST_WORKERS=2" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_TEST_MAX_OLD_SPACE_SIZE_MB=6144" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build under Node 22
|
||||
run: pnpm build
|
||||
|
||||
- name: Run tests under Node 22
|
||||
run: pnpm test
|
||||
|
||||
- name: Verify npm pack under Node 22
|
||||
run: pnpm release:check
|
||||
|
||||
skills-python:
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true' || needs.changed-scope.outputs.run_skills_python == 'true')
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install Python tooling
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install pytest ruff pyyaml
|
||||
|
||||
- name: Lint Python skill scripts
|
||||
run: python -m ruff check skills
|
||||
|
||||
- name: Test skill Python scripts
|
||||
run: python -m pytest -q skills
|
||||
|
||||
secrets:
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Ensure secrets 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: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
install-deps: "false"
|
||||
|
||||
- name: Setup Python
|
||||
id: setup-python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
cache-dependency-path: |
|
||||
pyproject.toml
|
||||
.pre-commit-config.yaml
|
||||
.github/workflows/ci.yml
|
||||
|
||||
- name: Restore pre-commit cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pre-commit
|
||||
key: pre-commit-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }}
|
||||
|
||||
- name: Install pre-commit
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install pre-commit
|
||||
|
||||
- name: Detect committed private keys
|
||||
run: pre-commit run --all-files detect-private-key
|
||||
|
||||
- name: Audit changed GitHub workflows with zizmor
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
BASE="${{ github.event.before }}"
|
||||
else
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
fi
|
||||
|
||||
mapfile -t workflow_files < <(git diff --name-only "$BASE" HEAD -- '.github/workflows/*.yml' '.github/workflows/*.yaml')
|
||||
if [ "${#workflow_files[@]}" -eq 0 ]; then
|
||||
echo "No workflow changes detected; skipping zizmor."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
pre-commit run zizmor --files "${workflow_files[@]}"
|
||||
|
||||
- name: Audit production dependencies
|
||||
run: pre-commit run --all-files pnpm-audit-prod
|
||||
|
||||
checks-windows:
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_windows == 'true')
|
||||
runs-on: blacksmith-32vcpu-windows-2025
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=6144
|
||||
# Keep total concurrency predictable on the 32 vCPU runner.
|
||||
# Windows shard 2 has shown intermittent instability at 2 workers.
|
||||
OPENCLAW_TEST_WORKERS: 1
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runtime: node
|
||||
task: test
|
||||
shard_index: 1
|
||||
shard_count: 6
|
||||
command: pnpm test
|
||||
- runtime: node
|
||||
task: test
|
||||
shard_index: 2
|
||||
shard_count: 6
|
||||
command: pnpm test
|
||||
- runtime: node
|
||||
task: test
|
||||
shard_index: 3
|
||||
shard_count: 6
|
||||
command: pnpm test
|
||||
- runtime: node
|
||||
task: test
|
||||
shard_index: 4
|
||||
shard_count: 6
|
||||
command: pnpm test
|
||||
- runtime: node
|
||||
task: test
|
||||
shard_index: 5
|
||||
shard_count: 6
|
||||
command: pnpm test
|
||||
- runtime: node
|
||||
task: test
|
||||
shard_index: 6
|
||||
shard_count: 6
|
||||
command: pnpm test
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Try to exclude workspace from Windows Defender (best-effort)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$cmd = Get-Command Add-MpPreference -ErrorAction SilentlyContinue
|
||||
if (-not $cmd) {
|
||||
Write-Host "Add-MpPreference not available, skipping Defender exclusions."
|
||||
exit 0
|
||||
}
|
||||
|
||||
try {
|
||||
# Defender sometimes intercepts process spawning (vitest workers). If this fails
|
||||
# (eg hardened images), keep going and rely on worker limiting above.
|
||||
Add-MpPreference -ExclusionPath "$env:GITHUB_WORKSPACE" -ErrorAction Stop
|
||||
Add-MpPreference -ExclusionProcess "node.exe" -ErrorAction Stop
|
||||
Write-Host "Defender exclusions applied."
|
||||
} catch {
|
||||
Write-Warning "Failed to apply Defender exclusions, continuing. $($_.Exception.Message)"
|
||||
}
|
||||
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@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24.x
|
||||
check-latest: false
|
||||
|
||||
- name: Setup pnpm + cache store
|
||||
uses: ./.github/actions/setup-pnpm-store-cache
|
||||
with:
|
||||
pnpm-version: "10.23.0"
|
||||
cache-key-suffix: "node24"
|
||||
# Sticky disk mount currently retries/fails on every shard and adds ~50s
|
||||
# before install while still yielding zero pnpm store reuse.
|
||||
# Try exact-key actions/cache restores instead to recover store reuse
|
||||
# without the sticky-disk mount penalty.
|
||||
use-sticky-disk: "false"
|
||||
use-restore-keys: "false"
|
||||
use-actions-cache: "true"
|
||||
node-version: 22.x
|
||||
check-latest: true
|
||||
|
||||
- name: Runtime versions
|
||||
run: |
|
||||
node -v
|
||||
npm -v
|
||||
pnpm -v
|
||||
|
||||
- name: Capture node path
|
||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Enable corepack and pin pnpm
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.23.0 --activate
|
||||
pnpm -v
|
||||
|
||||
- name: Install dependencies (frozen)
|
||||
env:
|
||||
CI: true
|
||||
run: |
|
||||
export PATH="$NODE_BIN:$PATH"
|
||||
which node
|
||||
node -v
|
||||
pnpm -v
|
||||
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
|
||||
checks:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runtime: node
|
||||
task: lint
|
||||
command: pnpm lint
|
||||
- runtime: node
|
||||
task: test
|
||||
command: pnpm test
|
||||
- runtime: node
|
||||
task: build
|
||||
command: pnpm build
|
||||
- runtime: node
|
||||
task: protocol
|
||||
command: pnpm protocol:check
|
||||
- runtime: node
|
||||
task: format
|
||||
command: pnpm format
|
||||
- runtime: bun
|
||||
task: test
|
||||
command: bunx vitest run
|
||||
- runtime: bun
|
||||
task: build
|
||||
command: bunx tsc -p tsconfig.json
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Checkout submodules (retry)
|
||||
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@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
check-latest: true
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Runtime versions
|
||||
run: |
|
||||
node -v
|
||||
npm -v
|
||||
bun -v
|
||||
|
||||
- name: Capture node path
|
||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Enable corepack and pin pnpm
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.23.0 --activate
|
||||
pnpm -v
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
CI: true
|
||||
@@ -468,56 +136,236 @@ jobs:
|
||||
which node
|
||||
node -v
|
||||
pnpm -v
|
||||
# Persist Windows-native postinstall outputs in the pnpm store so restored
|
||||
# caches can skip repeated rebuild/download work on later shards/runs.
|
||||
pnpm install --frozen-lockfile --prefer-offline --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true --config.side-effects-cache=true || pnpm install --frozen-lockfile --prefer-offline --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true --config.side-effects-cache=true
|
||||
|
||||
- name: Configure test shard (Windows)
|
||||
if: matrix.task == 'test'
|
||||
run: |
|
||||
echo "OPENCLAW_TEST_SHARDS=${{ matrix.shard_count }}" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_TEST_SHARD_INDEX=${{ matrix.shard_index }}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build A2UI bundle (Windows)
|
||||
if: matrix.task == 'test'
|
||||
run: pnpm canvas:a2ui:bundle
|
||||
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
|
||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
# Consolidated macOS job: runs TS tests + Swift lint/build/test sequentially
|
||||
# on a single runner. GitHub limits macOS concurrent jobs to 5 per org;
|
||||
# running 4 separate jobs per PR (as before) starved the queue. One job
|
||||
# per PR allows 5 PRs to run macOS checks simultaneously.
|
||||
macos:
|
||||
needs: [docs-scope, changed-scope, check]
|
||||
if: github.event_name == 'pull_request' && needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_macos == 'true'
|
||||
runs-on: macos-latest
|
||||
secrets:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
install-bun: "false"
|
||||
python-version: "3.12"
|
||||
|
||||
# --- Run all checks sequentially (fast gates first) ---
|
||||
- name: TS tests (macOS)
|
||||
- name: Install detect-secrets
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install detect-secrets==1.5.0
|
||||
|
||||
- name: Detect secrets
|
||||
run: |
|
||||
if ! detect-secrets scan --baseline .secrets.baseline; then
|
||||
echo "::error::Secret scanning failed. See docs/gateway/security.md#secret-scanning-detect-secrets"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
checks-windows:
|
||||
runs-on: blacksmith-4vcpu-windows-2025
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runtime: node
|
||||
task: lint
|
||||
command: pnpm lint
|
||||
- runtime: node
|
||||
task: test
|
||||
command: pnpm test
|
||||
- runtime: node
|
||||
task: build
|
||||
command: pnpm build
|
||||
- runtime: node
|
||||
task: protocol
|
||||
command: pnpm protocol:check
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Checkout submodules (retry)
|
||||
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@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
check-latest: true
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Runtime versions
|
||||
run: |
|
||||
node -v
|
||||
npm -v
|
||||
bun -v
|
||||
|
||||
- name: Capture node path
|
||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Enable corepack and pin pnpm
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.23.0 --activate
|
||||
pnpm -v
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
run: pnpm test
|
||||
CI: true
|
||||
run: |
|
||||
export PATH="$NODE_BIN:$PATH"
|
||||
which node
|
||||
node -v
|
||||
pnpm -v
|
||||
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
|
||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
checks-macos:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: macos-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- task: test
|
||||
command: pnpm test
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Checkout submodules (retry)
|
||||
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@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
check-latest: true
|
||||
|
||||
- name: Runtime versions
|
||||
run: |
|
||||
node -v
|
||||
npm -v
|
||||
|
||||
- name: Capture node path
|
||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Enable corepack and pin pnpm
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.23.0 --activate
|
||||
pnpm -v
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
CI: true
|
||||
run: |
|
||||
export PATH="$NODE_BIN:$PATH"
|
||||
which node
|
||||
node -v
|
||||
pnpm -v
|
||||
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
|
||||
- name: Run ${{ matrix.task }}
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
macos-app:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: macos-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- task: lint
|
||||
command: |
|
||||
swiftlint --config .swiftlint.yml
|
||||
swiftformat --lint apps/macos/Sources --config .swiftformat
|
||||
- task: build
|
||||
command: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if swift build --package-path apps/macos --configuration release; then
|
||||
exit 0
|
||||
fi
|
||||
echo "swift build failed (attempt $attempt/3). Retrying…"
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
exit 1
|
||||
- task: test
|
||||
command: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then
|
||||
exit 0
|
||||
fi
|
||||
echo "swift test failed (attempt $attempt/3). Retrying…"
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
exit 1
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Checkout submodules (retry)
|
||||
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
|
||||
|
||||
# --- Xcode/Swift setup ---
|
||||
- name: Select Xcode 26.1
|
||||
run: |
|
||||
sudo xcode-select -s /Applications/Xcode_26.1.app
|
||||
xcodebuild -version
|
||||
|
||||
- name: Install XcodeGen / SwiftLint / SwiftFormat
|
||||
run: brew install xcodegen swiftlint swiftformat
|
||||
run: |
|
||||
brew install xcodegen swiftlint swiftformat
|
||||
|
||||
- name: Show toolchain
|
||||
run: |
|
||||
@@ -525,43 +373,8 @@ jobs:
|
||||
xcodebuild -version
|
||||
swift --version
|
||||
|
||||
- name: Swift lint
|
||||
run: |
|
||||
swiftlint --config .swiftlint.yml
|
||||
swiftformat --lint apps/macos/Sources --config .swiftformat
|
||||
|
||||
- name: Cache SwiftPM
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/Library/Caches/org.swift.swiftpm
|
||||
key: ${{ runner.os }}-swiftpm-${{ hashFiles('apps/macos/Package.resolved') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-swiftpm-
|
||||
|
||||
- name: Swift build (release)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if swift build --package-path apps/macos --configuration release; then
|
||||
exit 0
|
||||
fi
|
||||
echo "swift build failed (attempt $attempt/3). Retrying…"
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Swift test
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then
|
||||
exit 0
|
||||
fi
|
||||
echo "swift test failed (attempt $attempt/3). Retrying…"
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Run ${{ matrix.task }}
|
||||
run: ${{ matrix.command }}
|
||||
ios:
|
||||
if: false # ignore iOS in CI for now
|
||||
runs-on: macos-latest
|
||||
@@ -571,6 +384,19 @@ jobs:
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Checkout submodules (retry)
|
||||
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: Select Xcode 26.1
|
||||
run: |
|
||||
sudo xcode-select -s /Applications/Xcode_26.1.app
|
||||
@@ -723,9 +549,7 @@ jobs:
|
||||
PY
|
||||
|
||||
android:
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_android == 'true')
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -740,12 +564,24 @@ jobs:
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Checkout submodules (retry)
|
||||
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 Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
# setup-android's sdkmanager currently crashes on JDK 21 in CI.
|
||||
java-version: 17
|
||||
java-version: 21
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
@@ -754,8 +590,6 @@ jobs:
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
with:
|
||||
gradle-version: 8.11.1
|
||||
|
||||
- name: Install Android SDK packages
|
||||
run: |
|
||||
|
||||
134
.github/workflows/codeql.yml
vendored
134
.github/workflows/codeql.yml
vendored
@@ -1,134 +0,0 @@
|
||||
name: CodeQL
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: codeql-${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
runs-on: ${{ matrix.runs_on }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: javascript-typescript
|
||||
runs_on: blacksmith-16vcpu-ubuntu-2404
|
||||
needs_node: true
|
||||
needs_python: false
|
||||
needs_java: false
|
||||
needs_swift_tools: false
|
||||
needs_manual_build: false
|
||||
needs_autobuild: false
|
||||
config_file: ./.github/codeql/codeql-javascript-typescript.yml
|
||||
- language: actions
|
||||
runs_on: blacksmith-16vcpu-ubuntu-2404
|
||||
needs_node: false
|
||||
needs_python: false
|
||||
needs_java: false
|
||||
needs_swift_tools: false
|
||||
needs_manual_build: false
|
||||
needs_autobuild: false
|
||||
config_file: ""
|
||||
- language: python
|
||||
runs_on: blacksmith-16vcpu-ubuntu-2404
|
||||
needs_node: false
|
||||
needs_python: true
|
||||
needs_java: false
|
||||
needs_swift_tools: false
|
||||
needs_manual_build: false
|
||||
needs_autobuild: false
|
||||
config_file: ""
|
||||
- language: java-kotlin
|
||||
runs_on: blacksmith-16vcpu-ubuntu-2404
|
||||
needs_node: false
|
||||
needs_python: false
|
||||
needs_java: true
|
||||
needs_swift_tools: false
|
||||
needs_manual_build: true
|
||||
needs_autobuild: false
|
||||
config_file: ""
|
||||
- language: swift
|
||||
runs_on: macos-latest
|
||||
needs_node: false
|
||||
needs_python: false
|
||||
needs_java: false
|
||||
needs_swift_tools: true
|
||||
needs_manual_build: true
|
||||
needs_autobuild: false
|
||||
config_file: ""
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
if: matrix.needs_node
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "true"
|
||||
|
||||
- name: Setup Python
|
||||
if: matrix.needs_python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Setup Java
|
||||
if: matrix.needs_java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Setup Swift build tools
|
||||
if: matrix.needs_swift_tools
|
||||
run: |
|
||||
sudo xcode-select -s /Applications/Xcode_26.1.app
|
||||
xcodebuild -version
|
||||
brew install xcodegen swiftlint swiftformat
|
||||
swift --version
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: security-and-quality
|
||||
config-file: ${{ matrix.config_file || '' }}
|
||||
|
||||
- name: Autobuild
|
||||
if: matrix.needs_autobuild
|
||||
uses: github/codeql-action/autobuild@v4
|
||||
|
||||
- name: Build Android for CodeQL
|
||||
if: matrix.language == 'java-kotlin'
|
||||
working-directory: apps/android
|
||||
run: ./gradlew --no-daemon :app:assembleDebug
|
||||
|
||||
- name: Build Swift for CodeQL
|
||||
if: matrix.language == 'swift'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
swift build --package-path apps/macos --configuration release
|
||||
cd apps/ios
|
||||
xcodegen generate
|
||||
xcodebuild build \
|
||||
-project OpenClaw.xcodeproj \
|
||||
-scheme OpenClaw \
|
||||
-destination "generic/platform=iOS Simulator" \
|
||||
CODE_SIGNING_ALLOWED=NO
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
308
.github/workflows/docker-release.yml
vendored
308
.github/workflows/docker-release.yml
vendored
@@ -1,308 +0,0 @@
|
||||
name: Docker Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*"
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**/*.md"
|
||||
- "**/*.mdx"
|
||||
- ".agents/**"
|
||||
- "skills/**"
|
||||
|
||||
concurrency:
|
||||
group: docker-release-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
# Build amd64 images (default + slim share the build stage cache)
|
||||
build-amd64:
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
slim-digest: ${{ steps.build-slim.outputs.digest }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Builder
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Resolve image tags (amd64)
|
||||
id: tags
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tags=()
|
||||
slim_tags=()
|
||||
if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then
|
||||
tags+=("${IMAGE}:main-amd64")
|
||||
slim_tags+=("${IMAGE}:main-slim-amd64")
|
||||
fi
|
||||
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||
version="${GITHUB_REF#refs/tags/v}"
|
||||
tags+=("${IMAGE}:${version}-amd64")
|
||||
slim_tags+=("${IMAGE}:${version}-slim-amd64")
|
||||
fi
|
||||
if [[ ${#tags[@]} -eq 0 ]]; then
|
||||
echo "::error::No amd64 tags resolved for ref ${GITHUB_REF}"
|
||||
exit 1
|
||||
fi
|
||||
{
|
||||
echo "value<<EOF"
|
||||
printf "%s\n" "${tags[@]}"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "slim<<EOF"
|
||||
printf "%s\n" "${slim_tags[@]}"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Resolve OCI labels (amd64)
|
||||
id: labels
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${GITHUB_SHA}"
|
||||
if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then
|
||||
version="main"
|
||||
fi
|
||||
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||
version="${GITHUB_REF#refs/tags/v}"
|
||||
fi
|
||||
created="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
{
|
||||
echo "value<<EOF"
|
||||
echo "org.opencontainers.image.revision=${GITHUB_SHA}"
|
||||
echo "org.opencontainers.image.version=${version}"
|
||||
echo "org.opencontainers.image.created=${created}"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and push amd64 image
|
||||
id: build
|
||||
uses: useblacksmith/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
tags: ${{ steps.tags.outputs.value }}
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
provenance: false
|
||||
push: true
|
||||
|
||||
- name: Build and push amd64 slim image
|
||||
id: build-slim
|
||||
uses: useblacksmith/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
build-args: |
|
||||
OPENCLAW_VARIANT=slim
|
||||
tags: ${{ steps.tags.outputs.slim }}
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
provenance: false
|
||||
push: true
|
||||
|
||||
# Build arm64 images (default + slim share the build stage cache)
|
||||
build-arm64:
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404-arm
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
slim-digest: ${{ steps.build-slim.outputs.digest }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Builder
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Resolve image tags (arm64)
|
||||
id: tags
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tags=()
|
||||
slim_tags=()
|
||||
if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then
|
||||
tags+=("${IMAGE}:main-arm64")
|
||||
slim_tags+=("${IMAGE}:main-slim-arm64")
|
||||
fi
|
||||
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||
version="${GITHUB_REF#refs/tags/v}"
|
||||
tags+=("${IMAGE}:${version}-arm64")
|
||||
slim_tags+=("${IMAGE}:${version}-slim-arm64")
|
||||
fi
|
||||
if [[ ${#tags[@]} -eq 0 ]]; then
|
||||
echo "::error::No arm64 tags resolved for ref ${GITHUB_REF}"
|
||||
exit 1
|
||||
fi
|
||||
{
|
||||
echo "value<<EOF"
|
||||
printf "%s\n" "${tags[@]}"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "slim<<EOF"
|
||||
printf "%s\n" "${slim_tags[@]}"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Resolve OCI labels (arm64)
|
||||
id: labels
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${GITHUB_SHA}"
|
||||
if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then
|
||||
version="main"
|
||||
fi
|
||||
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||
version="${GITHUB_REF#refs/tags/v}"
|
||||
fi
|
||||
created="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
{
|
||||
echo "value<<EOF"
|
||||
echo "org.opencontainers.image.revision=${GITHUB_SHA}"
|
||||
echo "org.opencontainers.image.version=${version}"
|
||||
echo "org.opencontainers.image.created=${created}"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and push arm64 image
|
||||
id: build
|
||||
uses: useblacksmith/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/arm64
|
||||
tags: ${{ steps.tags.outputs.value }}
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
provenance: false
|
||||
push: true
|
||||
|
||||
- name: Build and push arm64 slim image
|
||||
id: build-slim
|
||||
uses: useblacksmith/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/arm64
|
||||
build-args: |
|
||||
OPENCLAW_VARIANT=slim
|
||||
tags: ${{ steps.tags.outputs.slim }}
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
provenance: false
|
||||
push: true
|
||||
|
||||
# Create multi-platform manifests
|
||||
create-manifest:
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
needs: [build-amd64, build-arm64]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Resolve manifest tags
|
||||
id: tags
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tags=()
|
||||
slim_tags=()
|
||||
if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then
|
||||
tags+=("${IMAGE}:main")
|
||||
slim_tags+=("${IMAGE}:main-slim")
|
||||
fi
|
||||
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||
version="${GITHUB_REF#refs/tags/v}"
|
||||
tags+=("${IMAGE}:${version}")
|
||||
slim_tags+=("${IMAGE}:${version}-slim")
|
||||
if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$ ]]; then
|
||||
tags+=("${IMAGE}:latest")
|
||||
slim_tags+=("${IMAGE}:slim")
|
||||
fi
|
||||
fi
|
||||
if [[ ${#tags[@]} -eq 0 ]]; then
|
||||
echo "::error::No manifest tags resolved for ref ${GITHUB_REF}"
|
||||
exit 1
|
||||
fi
|
||||
{
|
||||
echo "value<<EOF"
|
||||
printf "%s\n" "${tags[@]}"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "slim<<EOF"
|
||||
printf "%s\n" "${slim_tags[@]}"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create and push default manifest
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mapfile -t tags <<< "${{ steps.tags.outputs.value }}"
|
||||
args=()
|
||||
for tag in "${tags[@]}"; do
|
||||
[ -z "$tag" ] && continue
|
||||
args+=("-t" "$tag")
|
||||
done
|
||||
docker buildx imagetools create "${args[@]}" \
|
||||
${{ needs.build-amd64.outputs.digest }} \
|
||||
${{ needs.build-arm64.outputs.digest }}
|
||||
|
||||
- name: Create and push slim manifest
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mapfile -t tags <<< "${{ steps.tags.outputs.slim }}"
|
||||
args=()
|
||||
for tag in "${tags[@]}"; do
|
||||
[ -z "$tag" ] && continue
|
||||
args+=("-t" "$tag")
|
||||
done
|
||||
docker buildx imagetools create "${args[@]}" \
|
||||
${{ needs.build-amd64.outputs.slim-digest }} \
|
||||
${{ needs.build-arm64.outputs.slim-digest }}
|
||||
103
.github/workflows/install-smoke.yml
vendored
103
.github/workflows/install-smoke.yml
vendored
@@ -6,107 +6,28 @@ on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: install-smoke-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
docs-scope:
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
outputs:
|
||||
docs_only: ${{ steps.check.outputs.docs_only }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
fetch-tags: false
|
||||
|
||||
- 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: check
|
||||
uses: ./.github/actions/detect-docs-changes
|
||||
|
||||
install-smoke:
|
||||
needs: [docs-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout CLI
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Builder
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# Blacksmith can fall back to the local docker driver, which rejects gha
|
||||
# cache export/import. Keep smoke builds driver-agnostic.
|
||||
- name: Build root Dockerfile smoke image
|
||||
uses: useblacksmith/build-push-action@v2
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
tags: openclaw-dockerfile-smoke:local
|
||||
load: true
|
||||
push: false
|
||||
provenance: false
|
||||
version: 10
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Run root Dockerfile CLI smoke
|
||||
run: |
|
||||
docker run --rm --entrypoint sh openclaw-dockerfile-smoke:local -lc 'which openclaw && openclaw --version'
|
||||
|
||||
# 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_EXTENSIONS=diagnostics-otel
|
||||
tags: openclaw-ext-smoke:local
|
||||
load: true
|
||||
push: false
|
||||
provenance: false
|
||||
|
||||
- name: Smoke test Dockerfile with extension build arg
|
||||
run: |
|
||||
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
|
||||
with:
|
||||
context: ./scripts/docker
|
||||
file: ./scripts/docker/install-sh-smoke/Dockerfile
|
||||
tags: openclaw-install-smoke:local
|
||||
load: true
|
||||
push: false
|
||||
provenance: false
|
||||
|
||||
- name: Build installer non-root image
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: useblacksmith/build-push-action@v2
|
||||
with:
|
||||
context: ./scripts/docker
|
||||
file: ./scripts/docker/install-sh-nonroot/Dockerfile
|
||||
tags: openclaw-install-nonroot:local
|
||||
load: true
|
||||
push: false
|
||||
provenance: false
|
||||
- name: Install pnpm deps (minimal)
|
||||
run: pnpm install --ignore-scripts --frozen-lockfile
|
||||
|
||||
- name: Run installer docker tests
|
||||
env:
|
||||
CLAWDBOT_INSTALL_URL: https://openclaw.ai/install.sh
|
||||
CLAWDBOT_INSTALL_CLI_URL: https://openclaw.ai/install-cli.sh
|
||||
CLAWDBOT_INSTALL_URL: https://clawd.bot/install.sh
|
||||
CLAWDBOT_INSTALL_CLI_URL: https://clawd.bot/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
|
||||
CLAWDBOT_INSTALL_SMOKE_PREVIOUS: "2026.1.11-4"
|
||||
run: pnpm test:install:smoke
|
||||
|
||||
714
.github/workflows/labeler.yml
vendored
714
.github/workflows/labeler.yml
vendored
@@ -1,714 +0,0 @@
|
||||
name: Labeler
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
issues:
|
||||
types: [opened]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
max_prs:
|
||||
description: "Maximum number of open PRs to process (0 = all)"
|
||||
required: false
|
||||
default: "200"
|
||||
per_page:
|
||||
description: "PRs per page (1-100)"
|
||||
required: false
|
||||
default: "50"
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
label:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token-fallback
|
||||
if: steps.app-token.outcome == 'failure'
|
||||
with:
|
||||
app-id: "2971289"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
|
||||
- uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5
|
||||
with:
|
||||
configuration-path: .github/labeler.yml
|
||||
repo-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
sync-labels: true
|
||||
- name: Apply PR size label
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
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 sizeLabels = ["size: XS", "size: S", "size: M", "size: L", "size: XL"];
|
||||
const labelColor = "b76e79";
|
||||
|
||||
for (const label of sizeLabels) {
|
||||
try {
|
||||
await github.rest.issues.getLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: label,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
await github.rest.issues.createLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: label,
|
||||
color: labelColor,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pullRequest.number,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const excludedLockfiles = new Set(["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb"]);
|
||||
const totalChangedLines = files.reduce((total, file) => {
|
||||
const path = file.filename ?? "";
|
||||
if (path === "docs.acp.md" || path.startsWith("docs/") || excludedLockfiles.has(path)) {
|
||||
return total;
|
||||
}
|
||||
return total + (file.additions ?? 0) + (file.deletions ?? 0);
|
||||
}, 0);
|
||||
|
||||
let targetSizeLabel = "size: XL";
|
||||
if (totalChangedLines < 50) {
|
||||
targetSizeLabel = "size: XS";
|
||||
} else if (totalChangedLines < 200) {
|
||||
targetSizeLabel = "size: S";
|
||||
} else if (totalChangedLines < 500) {
|
||||
targetSizeLabel = "size: M";
|
||||
} else if (totalChangedLines < 1000) {
|
||||
targetSizeLabel = "size: L";
|
||||
}
|
||||
|
||||
const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
for (const label of currentLabels) {
|
||||
const name = label.name ?? "";
|
||||
if (!sizeLabels.includes(name)) {
|
||||
continue;
|
||||
}
|
||||
if (name === targetSizeLabel) {
|
||||
continue;
|
||||
}
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
labels: [targetSizeLabel],
|
||||
});
|
||||
- name: Apply maintainer or trusted-contributor label
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
script: |
|
||||
const login = context.payload.pull_request?.user?.login;
|
||||
if (!login) {
|
||||
return;
|
||||
}
|
||||
|
||||
const repo = `${context.repo.owner}/${context.repo.repo}`;
|
||||
// const trustedLabel = "trusted-contributor";
|
||||
// const experiencedLabel = "experienced-contributor";
|
||||
// const trustedThreshold = 4;
|
||||
// const experiencedThreshold = 10;
|
||||
|
||||
let isMaintainer = false;
|
||||
try {
|
||||
const membership = await github.rest.teams.getMembershipForUserInOrg({
|
||||
org: context.repo.owner,
|
||||
team_slug: "maintainer",
|
||||
username: login,
|
||||
});
|
||||
isMaintainer = membership?.data?.state === "active";
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMaintainer) {
|
||||
await github.rest.issues.addLabels({
|
||||
...context.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
labels: ["maintainer"],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// trusted-contributor and experienced-contributor labels disabled.
|
||||
// const mergedQuery = `repo:${repo} is:pr is:merged author:${login}`;
|
||||
// let mergedCount = 0;
|
||||
// try {
|
||||
// const merged = await github.rest.search.issuesAndPullRequests({
|
||||
// q: mergedQuery,
|
||||
// per_page: 1,
|
||||
// });
|
||||
// mergedCount = merged?.data?.total_count ?? 0;
|
||||
// } catch (error) {
|
||||
// if (error?.status !== 422) {
|
||||
// throw error;
|
||||
// }
|
||||
// core.warning(`Skipping merged search for ${login}; treating as 0.`);
|
||||
// }
|
||||
//
|
||||
// if (mergedCount >= experiencedThreshold) {
|
||||
// await github.rest.issues.addLabels({
|
||||
// ...context.repo,
|
||||
// issue_number: context.payload.pull_request.number,
|
||||
// labels: [experiencedLabel],
|
||||
// });
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// if (mergedCount >= trustedThreshold) {
|
||||
// await github.rest.issues.addLabels({
|
||||
// ...context.repo,
|
||||
// issue_number: context.payload.pull_request.number,
|
||||
// labels: [trustedLabel],
|
||||
// });
|
||||
// }
|
||||
- name: Apply too-many-prs label
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
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 activePrLimitLabel = "r: too-many-prs";
|
||||
const activePrLimitOverrideLabel = "r: too-many-prs-override";
|
||||
const activePrLimit = 10;
|
||||
const labelColor = "B60205";
|
||||
const labelDescription = `Author has more than ${activePrLimit} active PRs in this repo`;
|
||||
const authorLogin = pullRequest.user?.login;
|
||||
if (!authorLogin) {
|
||||
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 labelNames = new Set(
|
||||
currentLabels
|
||||
.map((label) => (typeof label === "string" ? label : label?.name))
|
||||
.filter((name) => typeof name === "string"),
|
||||
);
|
||||
|
||||
if (labelNames.has(activePrLimitOverrideLabel)) {
|
||||
if (labelNames.has(activePrLimitLabel)) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
name: activePrLimitLabel,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const ensureLabelExists = async () => {
|
||||
try {
|
||||
await github.rest.issues.getLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: activePrLimitLabel,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
await github.rest.issues.createLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: activePrLimitLabel,
|
||||
color: labelColor,
|
||||
description: labelDescription,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isPrivilegedAuthor = async () => {
|
||||
if (pullRequest.author_association === "OWNER") {
|
||||
return true;
|
||||
}
|
||||
|
||||
let isMaintainer = false;
|
||||
try {
|
||||
const membership = await github.rest.teams.getMembershipForUserInOrg({
|
||||
org: context.repo.owner,
|
||||
team_slug: "maintainer",
|
||||
username: authorLogin,
|
||||
});
|
||||
isMaintainer = membership?.data?.state === "active";
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMaintainer) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const permission = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
username: authorLogin,
|
||||
});
|
||||
const roleName = (permission?.data?.role_name ?? "").toLowerCase();
|
||||
return roleName === "admin" || roleName === "maintain";
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
if (await isPrivilegedAuthor()) {
|
||||
if (labelNames.has(activePrLimitLabel)) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
name: activePrLimitLabel,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let openPrCount = 0;
|
||||
try {
|
||||
const result = await github.rest.search.issuesAndPullRequests({
|
||||
q: `repo:${context.repo.owner}/${context.repo.repo} is:pr is:open author:${authorLogin}`,
|
||||
per_page: 1,
|
||||
});
|
||||
openPrCount = result?.data?.total_count ?? 0;
|
||||
} catch (error) {
|
||||
if (error?.status !== 422) {
|
||||
throw error;
|
||||
}
|
||||
core.warning(`Skipping open PR count for ${authorLogin}; treating as 0.`);
|
||||
}
|
||||
|
||||
if (openPrCount > activePrLimit) {
|
||||
await ensureLabelExists();
|
||||
if (!labelNames.has(activePrLimitLabel)) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
labels: [activePrLimitLabel],
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (labelNames.has(activePrLimitLabel)) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
name: activePrLimitLabel,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
backfill-pr-labels:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token-fallback
|
||||
if: steps.app-token.outcome == 'failure'
|
||||
with:
|
||||
app-id: "2971289"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
|
||||
- name: Backfill PR labels
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const repoFull = `${owner}/${repo}`;
|
||||
const inputs = context.payload.inputs ?? {};
|
||||
const maxPrsInput = inputs.max_prs ?? "200";
|
||||
const perPageInput = inputs.per_page ?? "50";
|
||||
const parsedMaxPrs = Number.parseInt(maxPrsInput, 10);
|
||||
const parsedPerPage = Number.parseInt(perPageInput, 10);
|
||||
const maxPrs = Number.isFinite(parsedMaxPrs) ? parsedMaxPrs : 200;
|
||||
const perPage = Number.isFinite(parsedPerPage) ? Math.min(100, Math.max(1, parsedPerPage)) : 50;
|
||||
const processAll = maxPrs <= 0;
|
||||
const maxCount = processAll ? Number.POSITIVE_INFINITY : Math.max(1, maxPrs);
|
||||
|
||||
const sizeLabels = ["size: XS", "size: S", "size: M", "size: L", "size: XL"];
|
||||
const labelColor = "b76e79";
|
||||
// const trustedLabel = "trusted-contributor";
|
||||
// const experiencedLabel = "experienced-contributor";
|
||||
// const trustedThreshold = 4;
|
||||
// const experiencedThreshold = 10;
|
||||
|
||||
const contributorCache = new Map();
|
||||
|
||||
async function ensureSizeLabels() {
|
||||
for (const label of sizeLabels) {
|
||||
try {
|
||||
await github.rest.issues.getLabel({
|
||||
owner,
|
||||
repo,
|
||||
name: label,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
await github.rest.issues.createLabel({
|
||||
owner,
|
||||
repo,
|
||||
name: label,
|
||||
color: labelColor,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveContributorLabel(login) {
|
||||
if (contributorCache.has(login)) {
|
||||
return contributorCache.get(login);
|
||||
}
|
||||
|
||||
let isMaintainer = false;
|
||||
try {
|
||||
const membership = await github.rest.teams.getMembershipForUserInOrg({
|
||||
org: owner,
|
||||
team_slug: "maintainer",
|
||||
username: login,
|
||||
});
|
||||
isMaintainer = membership?.data?.state === "active";
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMaintainer) {
|
||||
contributorCache.set(login, "maintainer");
|
||||
return "maintainer";
|
||||
}
|
||||
|
||||
// trusted-contributor and experienced-contributor labels disabled.
|
||||
// const mergedQuery = `repo:${repoFull} is:pr is:merged author:${login}`;
|
||||
// let mergedCount = 0;
|
||||
// try {
|
||||
// const merged = await github.rest.search.issuesAndPullRequests({
|
||||
// q: mergedQuery,
|
||||
// per_page: 1,
|
||||
// });
|
||||
// mergedCount = merged?.data?.total_count ?? 0;
|
||||
// } catch (error) {
|
||||
// if (error?.status !== 422) {
|
||||
// throw error;
|
||||
// }
|
||||
// core.warning(`Skipping merged search for ${login}; treating as 0.`);
|
||||
// }
|
||||
|
||||
const label = null;
|
||||
// if (mergedCount >= experiencedThreshold) {
|
||||
// label = experiencedLabel;
|
||||
// } else if (mergedCount >= trustedThreshold) {
|
||||
// label = trustedLabel;
|
||||
// }
|
||||
|
||||
contributorCache.set(login, label);
|
||||
return label;
|
||||
}
|
||||
|
||||
async function applySizeLabel(pullRequest, currentLabels, labelNames) {
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullRequest.number,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const excludedLockfiles = new Set(["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb"]);
|
||||
const totalChangedLines = files.reduce((total, file) => {
|
||||
const path = file.filename ?? "";
|
||||
if (path === "docs.acp.md" || path.startsWith("docs/") || excludedLockfiles.has(path)) {
|
||||
return total;
|
||||
}
|
||||
return total + (file.additions ?? 0) + (file.deletions ?? 0);
|
||||
}, 0);
|
||||
|
||||
let targetSizeLabel = "size: XL";
|
||||
if (totalChangedLines < 50) {
|
||||
targetSizeLabel = "size: XS";
|
||||
} else if (totalChangedLines < 200) {
|
||||
targetSizeLabel = "size: S";
|
||||
} else if (totalChangedLines < 500) {
|
||||
targetSizeLabel = "size: M";
|
||||
} else if (totalChangedLines < 1000) {
|
||||
targetSizeLabel = "size: L";
|
||||
}
|
||||
|
||||
for (const label of currentLabels) {
|
||||
const name = label.name ?? "";
|
||||
if (!sizeLabels.includes(name)) {
|
||||
continue;
|
||||
}
|
||||
if (name === targetSizeLabel) {
|
||||
continue;
|
||||
}
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pullRequest.number,
|
||||
name,
|
||||
});
|
||||
labelNames.delete(name);
|
||||
}
|
||||
|
||||
if (!labelNames.has(targetSizeLabel)) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pullRequest.number,
|
||||
labels: [targetSizeLabel],
|
||||
});
|
||||
labelNames.add(targetSizeLabel);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyContributorLabel(pullRequest, labelNames) {
|
||||
const login = pullRequest.user?.login;
|
||||
if (!login) {
|
||||
return;
|
||||
}
|
||||
|
||||
const label = await resolveContributorLabel(login);
|
||||
if (!label) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (labelNames.has(label)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pullRequest.number,
|
||||
labels: [label],
|
||||
});
|
||||
labelNames.add(label);
|
||||
}
|
||||
|
||||
await ensureSizeLabels();
|
||||
|
||||
let page = 1;
|
||||
let processed = 0;
|
||||
|
||||
while (processed < maxCount) {
|
||||
const remaining = maxCount - processed;
|
||||
const pageSize = processAll ? perPage : Math.min(perPage, remaining);
|
||||
const { data: pullRequests } = await github.rest.pulls.list({
|
||||
owner,
|
||||
repo,
|
||||
state: "open",
|
||||
per_page: pageSize,
|
||||
page,
|
||||
});
|
||||
|
||||
if (pullRequests.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (const pullRequest of pullRequests) {
|
||||
if (!processAll && processed >= maxCount) {
|
||||
break;
|
||||
}
|
||||
|
||||
const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pullRequest.number,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const labelNames = new Set(
|
||||
currentLabels.map((label) => label.name).filter((name) => typeof name === "string"),
|
||||
);
|
||||
|
||||
await applySizeLabel(pullRequest, currentLabels, labelNames);
|
||||
await applyContributorLabel(pullRequest, labelNames);
|
||||
|
||||
processed += 1;
|
||||
}
|
||||
|
||||
if (pullRequests.length < pageSize) {
|
||||
break;
|
||||
}
|
||||
|
||||
page += 1;
|
||||
}
|
||||
|
||||
core.info(`Processed ${processed} pull requests.`);
|
||||
|
||||
label-issues:
|
||||
permissions:
|
||||
issues: write
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token-fallback
|
||||
if: steps.app-token.outcome == 'failure'
|
||||
with:
|
||||
app-id: "2971289"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
|
||||
- name: Apply maintainer or trusted-contributor label
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
script: |
|
||||
const login = context.payload.issue?.user?.login;
|
||||
if (!login) {
|
||||
return;
|
||||
}
|
||||
|
||||
const repo = `${context.repo.owner}/${context.repo.repo}`;
|
||||
// const trustedLabel = "trusted-contributor";
|
||||
// const experiencedLabel = "experienced-contributor";
|
||||
// const trustedThreshold = 4;
|
||||
// const experiencedThreshold = 10;
|
||||
|
||||
let isMaintainer = false;
|
||||
try {
|
||||
const membership = await github.rest.teams.getMembershipForUserInOrg({
|
||||
org: context.repo.owner,
|
||||
team_slug: "maintainer",
|
||||
username: login,
|
||||
});
|
||||
isMaintainer = membership?.data?.state === "active";
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMaintainer) {
|
||||
await github.rest.issues.addLabels({
|
||||
...context.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
labels: ["maintainer"],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// trusted-contributor and experienced-contributor labels disabled.
|
||||
// const mergedQuery = `repo:${repo} is:pr is:merged author:${login}`;
|
||||
// let mergedCount = 0;
|
||||
// try {
|
||||
// const merged = await github.rest.search.issuesAndPullRequests({
|
||||
// q: mergedQuery,
|
||||
// per_page: 1,
|
||||
// });
|
||||
// mergedCount = merged?.data?.total_count ?? 0;
|
||||
// } catch (error) {
|
||||
// if (error?.status !== 422) {
|
||||
// throw error;
|
||||
// }
|
||||
// core.warning(`Skipping merged search for ${login}; treating as 0.`);
|
||||
// }
|
||||
//
|
||||
// if (mergedCount >= experiencedThreshold) {
|
||||
// await github.rest.issues.addLabels({
|
||||
// ...context.repo,
|
||||
// issue_number: context.payload.issue.number,
|
||||
// labels: [experiencedLabel],
|
||||
// });
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// if (mergedCount >= trustedThreshold) {
|
||||
// await github.rest.issues.addLabels({
|
||||
// ...context.repo,
|
||||
// issue_number: context.payload.issue.number,
|
||||
// labels: [trustedLabel],
|
||||
// });
|
||||
// }
|
||||
79
.github/workflows/openclaw-npm-release.yml
vendored
79
.github/workflows/openclaw-npm-release.yml
vendored
@@ -1,79 +0,0 @@
|
||||
name: OpenClaw NPM Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
concurrency:
|
||||
group: openclaw-npm-release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
NODE_VERSION: "24.x"
|
||||
PNPM_VERSION: "10.23.0"
|
||||
|
||||
jobs:
|
||||
publish_openclaw_npm:
|
||||
# npm trusted publishing + provenance requires a GitHub-hosted runner.
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
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: Validate release tag and package metadata
|
||||
env:
|
||||
RELEASE_SHA: ${{ github.sha }}
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
RELEASE_MAIN_REF: origin/main
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# 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
|
||||
echo "openclaw@${PACKAGE_VERSION} is already published on npm."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Publishing openclaw@${PACKAGE_VERSION}"
|
||||
|
||||
- name: Check
|
||||
run: pnpm check
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Verify release contents
|
||||
run: pnpm release:check
|
||||
|
||||
- name: Publish
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE_VERSION=$(node -p "require('./package.json').version")
|
||||
|
||||
if [[ "$PACKAGE_VERSION" == *-beta.* ]]; then
|
||||
npm publish --access public --tag beta --provenance
|
||||
else
|
||||
npm publish --access public --provenance
|
||||
fi
|
||||
59
.github/workflows/sandbox-common-smoke.yml
vendored
59
.github/workflows/sandbox-common-smoke.yml
vendored
@@ -1,59 +0,0 @@
|
||||
name: Sandbox Common Smoke
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- Dockerfile.sandbox
|
||||
- Dockerfile.sandbox-common
|
||||
- scripts/sandbox-common-setup.sh
|
||||
pull_request:
|
||||
paths:
|
||||
- Dockerfile.sandbox
|
||||
- Dockerfile.sandbox-common
|
||||
- scripts/sandbox-common-setup.sh
|
||||
|
||||
concurrency:
|
||||
group: sandbox-common-smoke-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
sandbox-common-smoke:
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Set up Docker Builder
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build minimal sandbox base (USER sandbox)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
docker build -t openclaw-sandbox-smoke-base:bookworm-slim - <<'EOF'
|
||||
FROM debian:bookworm-slim
|
||||
RUN useradd --create-home --shell /bin/bash sandbox
|
||||
USER sandbox
|
||||
WORKDIR /home/sandbox
|
||||
EOF
|
||||
|
||||
- name: Build sandbox-common image (root for installs, sandbox at runtime)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BASE_IMAGE="openclaw-sandbox-smoke-base:bookworm-slim" \
|
||||
TARGET_IMAGE="openclaw-sandbox-common-smoke:bookworm-slim" \
|
||||
PACKAGES="ca-certificates" \
|
||||
INSTALL_PNPM=0 \
|
||||
INSTALL_BUN=0 \
|
||||
INSTALL_BREW=0 \
|
||||
FINAL_USER=sandbox \
|
||||
scripts/sandbox-common-setup.sh
|
||||
|
||||
u="$(docker run --rm openclaw-sandbox-common-smoke:bookworm-slim sh -lc 'id -un')"
|
||||
test "$u" = "sandbox"
|
||||
214
.github/workflows/stale.yml
vendored
214
.github/workflows/stale.yml
vendored
@@ -1,214 +0,0 @@
|
||||
name: Stale
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 3 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token-fallback
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: "2971289"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
|
||||
- name: Mark stale issues and pull requests (primary)
|
||||
id: stale-primary
|
||||
continue-on-error: true
|
||||
uses: actions/stale@v9
|
||||
with:
|
||||
repo-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
days-before-issue-stale: 7
|
||||
days-before-issue-close: 5
|
||||
days-before-pr-stale: 5
|
||||
days-before-pr-close: 3
|
||||
stale-issue-label: stale
|
||||
stale-pr-label: stale
|
||||
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale
|
||||
exempt-pr-labels: maintainer,no-stale
|
||||
operations-per-run: 2000
|
||||
ascending: true
|
||||
exempt-all-assignees: true
|
||||
remove-stale-when-updated: true
|
||||
stale-issue-message: |
|
||||
This issue has been automatically marked as stale due to inactivity.
|
||||
Please add updates or it will be closed.
|
||||
stale-pr-message: |
|
||||
This pull request has been automatically marked as stale due to inactivity.
|
||||
Please add updates or it will be closed.
|
||||
close-issue-message: |
|
||||
Closing due to inactivity.
|
||||
If this is still an issue, please retry on the latest OpenClaw release and share updated details.
|
||||
If you are absolutely sure it still happens on the latest release, open a new issue with fresh repro steps.
|
||||
close-issue-reason: not_planned
|
||||
close-pr-message: |
|
||||
Closing due to inactivity.
|
||||
If you believe this PR should be revived, post in #pr-thunderdome-dangerzone on Discord to talk to a maintainer.
|
||||
That channel is the escape hatch for high-quality PRs that get auto-closed.
|
||||
- name: Check stale state cache
|
||||
id: stale-state
|
||||
if: always()
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token-fallback.outputs.token || steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const cacheKey = "_state";
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
try {
|
||||
const { data } = await github.rest.actions.getActionsCacheList({
|
||||
owner,
|
||||
repo,
|
||||
key: cacheKey,
|
||||
});
|
||||
const caches = data.actions_caches ?? [];
|
||||
const hasState = caches.some(cache => cache.key === cacheKey);
|
||||
core.setOutput("has_state", hasState ? "true" : "false");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
core.warning(`Failed to check stale state cache: ${message}`);
|
||||
core.setOutput("has_state", "false");
|
||||
}
|
||||
- name: Mark stale issues and pull requests (fallback)
|
||||
if: (steps.stale-primary.outcome == 'failure' || steps.stale-state.outputs.has_state == 'true') && steps.app-token-fallback.outputs.token != ''
|
||||
uses: actions/stale@v9
|
||||
with:
|
||||
repo-token: ${{ steps.app-token-fallback.outputs.token }}
|
||||
days-before-issue-stale: 7
|
||||
days-before-issue-close: 5
|
||||
days-before-pr-stale: 5
|
||||
days-before-pr-close: 3
|
||||
stale-issue-label: stale
|
||||
stale-pr-label: stale
|
||||
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale
|
||||
exempt-pr-labels: maintainer,no-stale
|
||||
operations-per-run: 2000
|
||||
ascending: true
|
||||
exempt-all-assignees: true
|
||||
remove-stale-when-updated: true
|
||||
stale-issue-message: |
|
||||
This issue has been automatically marked as stale due to inactivity.
|
||||
Please add updates or it will be closed.
|
||||
stale-pr-message: |
|
||||
This pull request has been automatically marked as stale due to inactivity.
|
||||
Please add updates or it will be closed.
|
||||
close-issue-message: |
|
||||
Closing due to inactivity.
|
||||
If this is still an issue, please retry on the latest OpenClaw release and share updated details.
|
||||
If you are absolutely sure it still happens on the latest release, open a new issue with fresh repro steps.
|
||||
close-issue-reason: not_planned
|
||||
close-pr-message: |
|
||||
Closing due to inactivity.
|
||||
If you believe this PR should be revived, post in #pr-thunderdome-dangerzone on Discord to talk to a maintainer.
|
||||
That channel is the escape hatch for high-quality PRs that get auto-closed.
|
||||
|
||||
lock-closed-issues:
|
||||
permissions:
|
||||
issues: write
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- name: Lock closed issues after 48h of no comments
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const lockAfterHours = 48;
|
||||
const lockAfterMs = lockAfterHours * 60 * 60 * 1000;
|
||||
const perPage = 100;
|
||||
const cutoffMs = Date.now() - lockAfterMs;
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
let locked = 0;
|
||||
let inspected = 0;
|
||||
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const { data: issues } = await github.rest.issues.listForRepo({
|
||||
owner,
|
||||
repo,
|
||||
state: "closed",
|
||||
sort: "updated",
|
||||
direction: "desc",
|
||||
per_page: perPage,
|
||||
page,
|
||||
});
|
||||
|
||||
if (issues.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (const issue of issues) {
|
||||
if (issue.pull_request) {
|
||||
continue;
|
||||
}
|
||||
if (issue.locked) {
|
||||
continue;
|
||||
}
|
||||
if (!issue.closed_at) {
|
||||
continue;
|
||||
}
|
||||
|
||||
inspected += 1;
|
||||
const closedAtMs = Date.parse(issue.closed_at);
|
||||
if (!Number.isFinite(closedAtMs)) {
|
||||
continue;
|
||||
}
|
||||
if (closedAtMs > cutoffMs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let lastCommentMs = 0;
|
||||
if (issue.comments > 0) {
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issue.number,
|
||||
per_page: 1,
|
||||
page: 1,
|
||||
sort: "created",
|
||||
direction: "desc",
|
||||
});
|
||||
|
||||
if (comments.length > 0) {
|
||||
lastCommentMs = Date.parse(comments[0].created_at);
|
||||
}
|
||||
}
|
||||
|
||||
const lastActivityMs = Math.max(closedAtMs, lastCommentMs || 0);
|
||||
if (lastActivityMs > cutoffMs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await github.rest.issues.lock({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issue.number,
|
||||
lock_reason: "resolved",
|
||||
});
|
||||
|
||||
locked += 1;
|
||||
}
|
||||
|
||||
page += 1;
|
||||
}
|
||||
|
||||
core.info(`Inspected ${inspected} closed issues; locked ${locked}.`);
|
||||
32
.github/workflows/workflow-sanity.yml
vendored
32
.github/workflows/workflow-sanity.yml
vendored
@@ -3,15 +3,10 @@ name: Workflow Sanity
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: workflow-sanity-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
no-tabs:
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -40,28 +35,3 @@ jobs:
|
||||
print(f"- {path}")
|
||||
sys.exit(1)
|
||||
PY
|
||||
|
||||
actionlint:
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install actionlint
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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}"
|
||||
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
|
||||
|
||||
- name: Lint workflows
|
||||
run: actionlint
|
||||
|
||||
- name: Disallow direct inputs interpolation in composite run blocks
|
||||
run: python3 scripts/check-composite-action-input-interpolation.py
|
||||
|
||||
83
.gitignore
vendored
83
.gitignore
vendored
@@ -1,15 +1,12 @@
|
||||
node_modules
|
||||
**/node_modules/
|
||||
.env
|
||||
docker-compose.extra.yml
|
||||
dist
|
||||
*.bun-build
|
||||
pnpm-lock.yaml
|
||||
bun.lock
|
||||
bun.lockb
|
||||
coverage
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.tsbuildinfo
|
||||
.pnpm-store
|
||||
.worktrees/
|
||||
.DS_Store
|
||||
@@ -17,49 +14,26 @@ __pycache__/
|
||||
ui/src/ui/__screenshots__/
|
||||
ui/playwright-report/
|
||||
ui/test-results/
|
||||
packages/dashboard-next/.next/
|
||||
packages/dashboard-next/out/
|
||||
|
||||
# Mise configuration files
|
||||
mise.toml
|
||||
|
||||
# Android build artifacts
|
||||
apps/android/.gradle/
|
||||
apps/android/app/build/
|
||||
apps/android/.cxx/
|
||||
apps/android/.kotlin/
|
||||
|
||||
# Bun build artifacts
|
||||
*.bun-build
|
||||
apps/macos/.build/
|
||||
apps/shared/MoltbotKit/.build/
|
||||
apps/shared/OpenClawKit/.build/
|
||||
apps/shared/OpenClawKit/Package.resolved
|
||||
apps/shared/ClawdbotKit/.build/
|
||||
**/ModuleCache/
|
||||
bin/
|
||||
bin/clawdbot-mac
|
||||
bin/docs-list
|
||||
apps/macos/.build-local/
|
||||
apps/macos/.swiftpm/
|
||||
apps/shared/MoltbotKit/.swiftpm/
|
||||
apps/shared/OpenClawKit/.swiftpm/
|
||||
apps/shared/ClawdbotKit/.swiftpm/
|
||||
Core/
|
||||
apps/ios/*.xcodeproj/
|
||||
apps/ios/*.xcworkspace/
|
||||
apps/ios/.swiftpm/
|
||||
apps/ios/.derivedData/
|
||||
apps/ios/.local-signing.xcconfig
|
||||
vendor/
|
||||
apps/ios/Clawdbot.xcodeproj/
|
||||
apps/ios/Clawdbot.xcodeproj/**
|
||||
apps/macos/.build/**
|
||||
**/*.bun-build
|
||||
apps/ios/*.xcfilelist
|
||||
|
||||
# Vendor build artifacts
|
||||
vendor/a2ui/renderers/lit/dist/
|
||||
src/canvas-host/a2ui/*.bundle.js
|
||||
src/canvas-host/a2ui/*.map
|
||||
.bundle.hash
|
||||
|
||||
# fastlane (iOS)
|
||||
@@ -69,7 +43,6 @@ apps/ios/fastlane/Preview.html
|
||||
apps/ios/fastlane/screenshots/
|
||||
apps/ios/fastlane/test_output/
|
||||
apps/ios/fastlane/logs/
|
||||
apps/ios/fastlane/.env
|
||||
|
||||
# fastlane build artifacts (local)
|
||||
apps/ios/*.ipa
|
||||
@@ -77,57 +50,11 @@ apps/ios/*.dSYM.zip
|
||||
|
||||
# provisioning profiles (local)
|
||||
apps/ios/*.mobileprovision
|
||||
.env
|
||||
|
||||
# Local untracked files
|
||||
.local/
|
||||
docs/.local/
|
||||
tmp/
|
||||
.vscode/
|
||||
IDENTITY.md
|
||||
USER.md
|
||||
.tgz
|
||||
.idea
|
||||
|
||||
# local tooling
|
||||
.serena/
|
||||
|
||||
# Agent credentials and memory (NEVER COMMIT)
|
||||
/memory/
|
||||
.agent/*.json
|
||||
!.agent/workflows/
|
||||
/local/
|
||||
package-lock.json
|
||||
.claude/
|
||||
.agents/
|
||||
.agents
|
||||
.agent/
|
||||
skills-lock.json
|
||||
|
||||
# Local iOS signing overrides
|
||||
apps/ios/LocalSigning.xcconfig
|
||||
|
||||
# Xcode build directories (xcodebuild output)
|
||||
apps/ios/build/
|
||||
apps/shared/OpenClawKit/build/
|
||||
Swabble/build/
|
||||
|
||||
# Generated protocol schema (produced via pnpm protocol:gen)
|
||||
dist/protocol.schema.json
|
||||
.ant-colony/
|
||||
|
||||
# Eclipse
|
||||
**/.project
|
||||
**/.classpath
|
||||
**/.settings/
|
||||
**/.gradle/
|
||||
|
||||
# Synthing
|
||||
**/.stfolder/
|
||||
.dev-state
|
||||
docs/superpowers/plans/2026-03-10-collapsed-side-nav.md
|
||||
docs/superpowers/specs/2026-03-10-collapsed-side-nav-design.md
|
||||
.gitignore
|
||||
test/config-form.analyze.telegram.test.ts
|
||||
ui/src/ui/theme-variants.browser.test.ts
|
||||
ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png
|
||||
ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png
|
||||
ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png
|
||||
|
||||
4
.gitmodules
vendored
Normal file
4
.gitmodules
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
[submodule "Peekaboo"]
|
||||
path = Peekaboo
|
||||
url = https://github.com/steipete/Peekaboo.git
|
||||
branch = main
|
||||
13
.mailmap
13
.mailmap
@@ -1,13 +0,0 @@
|
||||
# Canonical contributor identity mappings for cherry-picked commits.
|
||||
bmendonca3 <208517100+bmendonca3@users.noreply.github.com> <brianmendonca@Brians-MacBook-Air.local>
|
||||
hcl <7755017+hclsys@users.noreply.github.com> <chenglunhu@gmail.com>
|
||||
Glucksberg <80581902+Glucksberg@users.noreply.github.com> <markuscontasul@gmail.com>
|
||||
JackyWay <53031570+JackyWay@users.noreply.github.com> <jackybbc@gmail.com>
|
||||
Marcus Castro <7562095+mcaxtr@users.noreply.github.com> <mcaxtr@gmail.com>
|
||||
Marc Gratch <2238658+mgratch@users.noreply.github.com> <me@marcgratch.com>
|
||||
Peter Machona <7957943+chilu18@users.noreply.github.com> <chilu.machona@icloud.com>
|
||||
Ben Marvell <92585+easternbloc@users.noreply.github.com> <ben@marvell.consulting>
|
||||
zerone0x <39543393+zerone0x@users.noreply.github.com> <hi@trine.dev>
|
||||
Marco Di Dionisio <3519682+marcodd23@users.noreply.github.com> <m.didionisio23@gmail.com>
|
||||
mujiannan <46643837+mujiannan@users.noreply.github.com> <shennan@mujiannan.com>
|
||||
Santhanakrishnan <239082898+bitfoundry-ai@users.noreply.github.com> <noreply@anthropic.com>
|
||||
@@ -1,52 +0,0 @@
|
||||
{
|
||||
"globs": ["docs/**/*.md", "docs/**/*.mdx", "README.md"],
|
||||
"ignores": ["docs/zh-CN/**", "docs/.i18n/**", "docs/reference/templates/**", "**/.local/**"],
|
||||
"config": {
|
||||
"default": true,
|
||||
|
||||
"MD013": false,
|
||||
"MD025": false,
|
||||
"MD029": false,
|
||||
|
||||
"MD033": {
|
||||
"allowed_elements": [
|
||||
"Note",
|
||||
"Info",
|
||||
"Tip",
|
||||
"Warning",
|
||||
"Card",
|
||||
"CardGroup",
|
||||
"Columns",
|
||||
"Steps",
|
||||
"Step",
|
||||
"Tabs",
|
||||
"Tab",
|
||||
"Accordion",
|
||||
"AccordionGroup",
|
||||
"CodeGroup",
|
||||
"Frame",
|
||||
"Callout",
|
||||
"ParamField",
|
||||
"ResponseField",
|
||||
"RequestExample",
|
||||
"ResponseExample",
|
||||
"img",
|
||||
"a",
|
||||
"br",
|
||||
"details",
|
||||
"summary",
|
||||
"p",
|
||||
"strong",
|
||||
"picture",
|
||||
"source",
|
||||
"Tooltip",
|
||||
"Check",
|
||||
],
|
||||
},
|
||||
|
||||
"MD036": false,
|
||||
"MD040": false,
|
||||
"MD041": false,
|
||||
"MD046": false,
|
||||
},
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
**/node_modules/
|
||||
2
.npmrc
2
.npmrc
@@ -1 +1 @@
|
||||
# pnpm build-script allowlist lives in package.json -> pnpm.onlyBuiltDependencies.
|
||||
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty,@lydell/node-pty
|
||||
|
||||
@@ -1,26 +1,5 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"experimentalSortImports": {
|
||||
"newlinesBetween": false,
|
||||
},
|
||||
"experimentalSortPackageJson": {
|
||||
"sortScripts": true,
|
||||
},
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"ignorePatterns": [
|
||||
"apps/",
|
||||
"assets/",
|
||||
"CLAUDE.md",
|
||||
"docker-compose.yml",
|
||||
"dist/",
|
||||
"docs/_layouts/",
|
||||
"node_modules/",
|
||||
"patches/",
|
||||
"pnpm-lock.yaml/",
|
||||
"src/gateway/server-methods/CLAUDE.md",
|
||||
"src/auto-reply/reply/export-html/",
|
||||
"Swabble/",
|
||||
"vendor/",
|
||||
],
|
||||
"indentWidth": 2,
|
||||
"printWidth": 100
|
||||
}
|
||||
|
||||
@@ -1,39 +1,12 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["unicorn", "typescript", "oxc"],
|
||||
"plugins": [
|
||||
"unicorn",
|
||||
"typescript",
|
||||
"oxc"
|
||||
],
|
||||
"categories": {
|
||||
"correctness": "error",
|
||||
"perf": "error",
|
||||
"suspicious": "error"
|
||||
"correctness": "error"
|
||||
},
|
||||
"rules": {
|
||||
"curly": "error",
|
||||
"eslint-plugin-unicorn/prefer-array-find": "off",
|
||||
"eslint/no-await-in-loop": "off",
|
||||
"eslint/no-new": "off",
|
||||
"eslint/no-shadow": "off",
|
||||
"eslint/no-unmodified-loop-condition": "off",
|
||||
"oxc/no-accumulating-spread": "off",
|
||||
"oxc/no-async-endpoint-handlers": "off",
|
||||
"oxc/no-map-spread": "off",
|
||||
"typescript/no-explicit-any": "error",
|
||||
"typescript/no-extraneous-class": "off",
|
||||
"typescript/no-unsafe-type-assertion": "off",
|
||||
"unicorn/consistent-function-scoping": "off",
|
||||
"unicorn/require-post-message-target-origin": "off"
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"assets/",
|
||||
"dist/",
|
||||
"docs/_layouts/",
|
||||
"extensions/",
|
||||
"node_modules/",
|
||||
"patches/",
|
||||
"pnpm-lock.yaml",
|
||||
"skills/",
|
||||
"src/auto-reply/reply/export-html/template.js",
|
||||
"src/canvas-host/a2ui/a2ui.bundle.js",
|
||||
"Swabble/",
|
||||
"vendor/"
|
||||
]
|
||||
"ignorePatterns": ["src/canvas-host/a2ui/a2ui.bundle.js"]
|
||||
}
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
/**
|
||||
* Diff Extension
|
||||
*
|
||||
* /diff command shows modified/deleted/new files from git status and opens
|
||||
* the selected file in VS Code's diff view.
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { showPagedSelectList } from "./ui/paged-select";
|
||||
|
||||
interface FileInfo {
|
||||
status: string;
|
||||
statusLabel: string;
|
||||
file: string;
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerCommand("diff", {
|
||||
description: "Show git changes and open in VS Code diff view",
|
||||
handler: async (_args, ctx) => {
|
||||
if (!ctx.hasUI) {
|
||||
ctx.ui.notify("No UI available", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get changed files from git status
|
||||
const result = await pi.exec("git", ["status", "--porcelain"], { cwd: ctx.cwd });
|
||||
|
||||
if (result.code !== 0) {
|
||||
ctx.ui.notify(`git status failed: ${result.stderr}`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.stdout || !result.stdout.trim()) {
|
||||
ctx.ui.notify("No changes in working tree", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse git status output
|
||||
// Format: XY filename (where XY is two-letter status, then space, then filename)
|
||||
const lines = result.stdout.split("\n");
|
||||
const files: FileInfo[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.length < 4) {
|
||||
continue;
|
||||
} // Need at least "XY f"
|
||||
|
||||
const status = line.slice(0, 2);
|
||||
const file = line.slice(2).trimStart();
|
||||
|
||||
// Translate status codes to short labels
|
||||
let statusLabel: string;
|
||||
if (status.includes("M")) {
|
||||
statusLabel = "M";
|
||||
} else if (status.includes("A")) {
|
||||
statusLabel = "A";
|
||||
} else if (status.includes("D")) {
|
||||
statusLabel = "D";
|
||||
} else if (status.includes("?")) {
|
||||
statusLabel = "?";
|
||||
} else if (status.includes("R")) {
|
||||
statusLabel = "R";
|
||||
} else if (status.includes("C")) {
|
||||
statusLabel = "C";
|
||||
} else {
|
||||
statusLabel = status.trim() || "~";
|
||||
}
|
||||
|
||||
files.push({ status: statusLabel, statusLabel, file });
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
ctx.ui.notify("No changes found", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
const openSelected = async (fileInfo: FileInfo): Promise<void> => {
|
||||
try {
|
||||
// Open in VS Code diff view.
|
||||
// For untracked files, git difftool won't work, so fall back to just opening the file.
|
||||
if (fileInfo.status === "?") {
|
||||
await pi.exec("code", ["-g", fileInfo.file], { cwd: ctx.cwd });
|
||||
return;
|
||||
}
|
||||
|
||||
const diffResult = await pi.exec(
|
||||
"git",
|
||||
["difftool", "-y", "--tool=vscode", fileInfo.file],
|
||||
{
|
||||
cwd: ctx.cwd,
|
||||
},
|
||||
);
|
||||
if (diffResult.code !== 0) {
|
||||
await pi.exec("code", ["-g", fileInfo.file], { cwd: ctx.cwd });
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
ctx.ui.notify(`Failed to open ${fileInfo.file}: ${message}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const items = files.map((file) => ({
|
||||
value: file,
|
||||
label: `${file.status} ${file.file}`,
|
||||
}));
|
||||
await showPagedSelectList({
|
||||
ctx,
|
||||
title: " Select file to diff",
|
||||
items,
|
||||
onSelect: (item) => {
|
||||
void openSelected(item.value as FileInfo);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
/**
|
||||
* Files Extension
|
||||
*
|
||||
* /files command lists all files the model has read/written/edited in the active session branch,
|
||||
* coalesced by path and sorted newest first. Selecting a file opens it in VS Code.
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { showPagedSelectList } from "./ui/paged-select";
|
||||
|
||||
interface FileEntry {
|
||||
path: string;
|
||||
operations: Set<"read" | "write" | "edit">;
|
||||
lastTimestamp: number;
|
||||
}
|
||||
|
||||
type FileToolName = "read" | "write" | "edit";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerCommand("files", {
|
||||
description: "Show files read/written/edited in this session",
|
||||
handler: async (_args, ctx) => {
|
||||
if (!ctx.hasUI) {
|
||||
ctx.ui.notify("No UI available", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the current branch (path from leaf to root)
|
||||
const branch = ctx.sessionManager.getBranch();
|
||||
|
||||
// First pass: collect tool calls (id -> {path, name}) from assistant messages
|
||||
const toolCalls = new Map<string, { path: string; name: FileToolName; timestamp: number }>();
|
||||
|
||||
for (const entry of branch) {
|
||||
if (entry.type !== "message") {
|
||||
continue;
|
||||
}
|
||||
const msg = entry.message;
|
||||
|
||||
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "toolCall") {
|
||||
const name = block.name;
|
||||
if (name === "read" || name === "write" || name === "edit") {
|
||||
const path = block.arguments?.path;
|
||||
if (path && typeof path === "string") {
|
||||
toolCalls.set(block.id, { path, name, timestamp: msg.timestamp });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: match tool results to get the actual execution timestamp
|
||||
const fileMap = new Map<string, FileEntry>();
|
||||
|
||||
for (const entry of branch) {
|
||||
if (entry.type !== "message") {
|
||||
continue;
|
||||
}
|
||||
const msg = entry.message;
|
||||
|
||||
if (msg.role === "toolResult") {
|
||||
const toolCall = toolCalls.get(msg.toolCallId);
|
||||
if (!toolCall) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { path, name } = toolCall;
|
||||
const timestamp = msg.timestamp;
|
||||
|
||||
const existing = fileMap.get(path);
|
||||
if (existing) {
|
||||
existing.operations.add(name);
|
||||
if (timestamp > existing.lastTimestamp) {
|
||||
existing.lastTimestamp = timestamp;
|
||||
}
|
||||
} else {
|
||||
fileMap.set(path, {
|
||||
path,
|
||||
operations: new Set([name]),
|
||||
lastTimestamp: timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fileMap.size === 0) {
|
||||
ctx.ui.notify("No files read/written/edited in this session", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by most recent first
|
||||
const files = Array.from(fileMap.values()).toSorted(
|
||||
(a, b) => b.lastTimestamp - a.lastTimestamp,
|
||||
);
|
||||
|
||||
const openSelected = async (file: FileEntry): Promise<void> => {
|
||||
try {
|
||||
await pi.exec("code", ["-g", file.path], { cwd: ctx.cwd });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
ctx.ui.notify(`Failed to open ${file.path}: ${message}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const items = files.map((file) => {
|
||||
const ops: string[] = [];
|
||||
if (file.operations.has("read")) {
|
||||
ops.push("R");
|
||||
}
|
||||
if (file.operations.has("write")) {
|
||||
ops.push("W");
|
||||
}
|
||||
if (file.operations.has("edit")) {
|
||||
ops.push("E");
|
||||
}
|
||||
return {
|
||||
value: file,
|
||||
label: `${ops.join("")} ${file.path}`,
|
||||
};
|
||||
});
|
||||
await showPagedSelectList({
|
||||
ctx,
|
||||
title: " Select file to open",
|
||||
items,
|
||||
onSelect: (item) => {
|
||||
void openSelected(item.value as FileEntry);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
import {
|
||||
DynamicBorder,
|
||||
type ExtensionAPI,
|
||||
type ExtensionContext,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import { Container, Text } from "@mariozechner/pi-tui";
|
||||
|
||||
const PR_PROMPT_PATTERN = /^\s*You are given one or more GitHub PR URLs:\s*(\S+)/im;
|
||||
const ISSUE_PROMPT_PATTERN = /^\s*Analyze GitHub issue\(s\):\s*(\S+)/im;
|
||||
|
||||
type PromptMatch = {
|
||||
kind: "pr" | "issue";
|
||||
url: string;
|
||||
};
|
||||
|
||||
type GhMetadata = {
|
||||
title?: string;
|
||||
author?: {
|
||||
login?: string;
|
||||
name?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
function extractPromptMatch(prompt: string): PromptMatch | undefined {
|
||||
const prMatch = prompt.match(PR_PROMPT_PATTERN);
|
||||
if (prMatch?.[1]) {
|
||||
return { kind: "pr", url: prMatch[1].trim() };
|
||||
}
|
||||
|
||||
const issueMatch = prompt.match(ISSUE_PROMPT_PATTERN);
|
||||
if (issueMatch?.[1]) {
|
||||
return { kind: "issue", url: issueMatch[1].trim() };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function fetchGhMetadata(
|
||||
pi: ExtensionAPI,
|
||||
kind: PromptMatch["kind"],
|
||||
url: string,
|
||||
): Promise<GhMetadata | undefined> {
|
||||
const args =
|
||||
kind === "pr"
|
||||
? ["pr", "view", url, "--json", "title,author"]
|
||||
: ["issue", "view", url, "--json", "title,author"];
|
||||
|
||||
try {
|
||||
const result = await pi.exec("gh", args);
|
||||
if (result.code !== 0 || !result.stdout) {
|
||||
return undefined;
|
||||
}
|
||||
return JSON.parse(result.stdout) as GhMetadata;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function formatAuthor(author?: GhMetadata["author"]): string | undefined {
|
||||
if (!author) {
|
||||
return undefined;
|
||||
}
|
||||
const name = author.name?.trim();
|
||||
const login = author.login?.trim();
|
||||
if (name && login) {
|
||||
return `${name} (@${login})`;
|
||||
}
|
||||
if (login) {
|
||||
return `@${login}`;
|
||||
}
|
||||
if (name) {
|
||||
return name;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
|
||||
const setWidget = (
|
||||
ctx: ExtensionContext,
|
||||
match: PromptMatch,
|
||||
title?: string,
|
||||
authorText?: string,
|
||||
) => {
|
||||
ctx.ui.setWidget("prompt-url", (_tui, thm) => {
|
||||
const titleText = title ? thm.fg("accent", title) : thm.fg("accent", match.url);
|
||||
const authorLine = authorText ? thm.fg("muted", authorText) : undefined;
|
||||
const urlLine = thm.fg("dim", match.url);
|
||||
|
||||
const lines = [titleText];
|
||||
if (authorLine) {
|
||||
lines.push(authorLine);
|
||||
}
|
||||
lines.push(urlLine);
|
||||
|
||||
const container = new Container();
|
||||
container.addChild(new DynamicBorder((s: string) => thm.fg("muted", s)));
|
||||
container.addChild(new Text(lines.join("\n"), 1, 0));
|
||||
return container;
|
||||
});
|
||||
};
|
||||
|
||||
const applySessionName = (ctx: ExtensionContext, match: PromptMatch, title?: string) => {
|
||||
const label = match.kind === "pr" ? "PR" : "Issue";
|
||||
const trimmedTitle = title?.trim();
|
||||
const fallbackName = `${label}: ${match.url}`;
|
||||
const desiredName = trimmedTitle ? `${label}: ${trimmedTitle} (${match.url})` : fallbackName;
|
||||
const currentName = pi.getSessionName()?.trim();
|
||||
if (!currentName) {
|
||||
pi.setSessionName(desiredName);
|
||||
return;
|
||||
}
|
||||
if (currentName === match.url || currentName === fallbackName) {
|
||||
pi.setSessionName(desiredName);
|
||||
}
|
||||
};
|
||||
|
||||
const renderPromptMatch = (ctx: ExtensionContext, match: PromptMatch) => {
|
||||
setWidget(ctx, match);
|
||||
applySessionName(ctx, match);
|
||||
void fetchGhMetadata(pi, match.kind, match.url).then((meta) => {
|
||||
const title = meta?.title?.trim();
|
||||
const authorText = formatAuthor(meta?.author);
|
||||
setWidget(ctx, match, title, authorText);
|
||||
applySessionName(ctx, match, title);
|
||||
});
|
||||
};
|
||||
|
||||
pi.on("before_agent_start", async (event, ctx) => {
|
||||
if (!ctx.hasUI) {
|
||||
return;
|
||||
}
|
||||
const match = extractPromptMatch(event.prompt);
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
|
||||
renderPromptMatch(ctx, match);
|
||||
});
|
||||
|
||||
pi.on("session_switch", async (_event, ctx) => {
|
||||
rebuildFromSession(ctx);
|
||||
});
|
||||
|
||||
const getUserText = (content: string | { type: string; text?: string }[] | undefined): string => {
|
||||
if (!content) {
|
||||
return "";
|
||||
}
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
return (
|
||||
content
|
||||
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
||||
.map((block) => block.text)
|
||||
.join("\n") ?? ""
|
||||
);
|
||||
};
|
||||
|
||||
const rebuildFromSession = (ctx: ExtensionContext) => {
|
||||
if (!ctx.hasUI) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = ctx.sessionManager.getEntries();
|
||||
const lastMatch = [...entries].toReversed().find((entry) => {
|
||||
if (entry.type !== "message" || entry.message.role !== "user") {
|
||||
return false;
|
||||
}
|
||||
const text = getUserText(entry.message.content);
|
||||
return !!extractPromptMatch(text);
|
||||
});
|
||||
|
||||
const content =
|
||||
lastMatch?.type === "message" && lastMatch.message.role === "user"
|
||||
? lastMatch.message.content
|
||||
: undefined;
|
||||
const text = getUserText(content);
|
||||
const match = text ? extractPromptMatch(text) : undefined;
|
||||
if (!match) {
|
||||
ctx.ui.setWidget("prompt-url", undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
renderPromptMatch(ctx, match);
|
||||
};
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
rebuildFromSession(ctx);
|
||||
});
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
/**
|
||||
* Redraws Extension
|
||||
*
|
||||
* Exposes /tui to show TUI redraw stats.
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { Text } from "@mariozechner/pi-tui";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerCommand("tui", {
|
||||
description: "Show TUI stats",
|
||||
handler: async (_args, ctx) => {
|
||||
if (!ctx.hasUI) {
|
||||
return;
|
||||
}
|
||||
let redraws = 0;
|
||||
await ctx.ui.custom<void>((tui, _theme, _keybindings, done) => {
|
||||
redraws = tui.fullRedraws;
|
||||
done(undefined);
|
||||
return new Text("", 0, 0);
|
||||
});
|
||||
ctx.ui.notify(`TUI full redraws: ${redraws}`, "info");
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
|
||||
import {
|
||||
Container,
|
||||
Key,
|
||||
matchesKey,
|
||||
type SelectItem,
|
||||
SelectList,
|
||||
Text,
|
||||
} from "@mariozechner/pi-tui";
|
||||
|
||||
type CustomUiContext = {
|
||||
ui: {
|
||||
custom: <T>(
|
||||
render: (
|
||||
tui: { requestRender: () => void },
|
||||
theme: {
|
||||
fg: (tone: string, text: string) => string;
|
||||
bold: (text: string) => string;
|
||||
},
|
||||
kb: unknown,
|
||||
done: () => void,
|
||||
) => {
|
||||
render: (width: number) => string;
|
||||
invalidate: () => void;
|
||||
handleInput: (data: string) => void;
|
||||
},
|
||||
) => Promise<T>;
|
||||
};
|
||||
};
|
||||
|
||||
export async function showPagedSelectList(params: {
|
||||
ctx: CustomUiContext;
|
||||
title: string;
|
||||
items: SelectItem[];
|
||||
onSelect: (item: SelectItem) => void;
|
||||
}): Promise<void> {
|
||||
await params.ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
||||
const container = new Container();
|
||||
|
||||
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
||||
container.addChild(new Text(theme.fg("accent", theme.bold(params.title)), 0, 0));
|
||||
|
||||
const visibleRows = Math.min(params.items.length, 15);
|
||||
let currentIndex = 0;
|
||||
|
||||
const selectList = new SelectList(params.items, visibleRows, {
|
||||
selectedPrefix: (text) => theme.fg("accent", text),
|
||||
selectedText: (text) => text,
|
||||
description: (text) => theme.fg("muted", text),
|
||||
scrollInfo: (text) => theme.fg("dim", text),
|
||||
noMatch: (text) => theme.fg("warning", text),
|
||||
});
|
||||
selectList.onSelect = (item) => params.onSelect(item);
|
||||
selectList.onCancel = () => done();
|
||||
selectList.onSelectionChange = (item) => {
|
||||
currentIndex = params.items.indexOf(item);
|
||||
};
|
||||
container.addChild(selectList);
|
||||
|
||||
container.addChild(
|
||||
new Text(theme.fg("dim", " ↑↓ navigate • ←→ page • enter open • esc close"), 0, 0),
|
||||
);
|
||||
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
||||
|
||||
return {
|
||||
render: (width) => container.render(width),
|
||||
invalidate: () => container.invalidate(),
|
||||
handleInput: (data) => {
|
||||
if (matchesKey(data, Key.left)) {
|
||||
currentIndex = Math.max(0, currentIndex - visibleRows);
|
||||
selectList.setSelectedIndex(currentIndex);
|
||||
} else if (matchesKey(data, Key.right)) {
|
||||
currentIndex = Math.min(params.items.length - 1, currentIndex + visibleRows);
|
||||
selectList.setSelectedIndex(currentIndex);
|
||||
} else {
|
||||
selectList.handleInput(data);
|
||||
}
|
||||
tui.requestRender();
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
2
.pi/git/.gitignore
vendored
2
.pi/git/.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -1,58 +0,0 @@
|
||||
---
|
||||
description: Audit changelog entries before release
|
||||
---
|
||||
|
||||
Audit changelog entries for all commits since the last release.
|
||||
|
||||
## Process
|
||||
|
||||
1. **Find the last release tag:**
|
||||
|
||||
```bash
|
||||
git tag --sort=-version:refname | head -1
|
||||
```
|
||||
|
||||
2. **List all commits since that tag:**
|
||||
|
||||
```bash
|
||||
git log <tag>..HEAD --oneline
|
||||
```
|
||||
|
||||
3. **Read each package's [Unreleased] section:**
|
||||
- packages/ai/CHANGELOG.md
|
||||
- packages/tui/CHANGELOG.md
|
||||
- packages/coding-agent/CHANGELOG.md
|
||||
|
||||
4. **For each commit, check:**
|
||||
- Skip: changelog updates, doc-only changes, release housekeeping
|
||||
- Determine which package(s) the commit affects (use `git show <hash> --stat`)
|
||||
- Verify a changelog entry exists in the affected package(s)
|
||||
- For external contributions (PRs), verify format: `Description ([#N](url) by [@user](url))`
|
||||
|
||||
5. **Cross-package duplication rule:**
|
||||
Changes in `ai`, `agent` or `tui` that affect end users should be duplicated to `coding-agent` changelog, since coding-agent is the user-facing package that depends on them.
|
||||
|
||||
6. **Add New Features section after changelog fixes:**
|
||||
- Insert a `### New Features` section at the start of `## [Unreleased]` in `packages/coding-agent/CHANGELOG.md`.
|
||||
- Propose the top new features to the user for confirmation before writing them.
|
||||
- Link to relevant docs and sections whenever possible.
|
||||
|
||||
7. **Report:**
|
||||
- List commits with missing entries
|
||||
- List entries that need cross-package duplication
|
||||
- Add any missing entries directly
|
||||
|
||||
## Changelog Format Reference
|
||||
|
||||
Sections (in order):
|
||||
|
||||
- `### Breaking Changes` - API changes requiring migration
|
||||
- `### Added` - New features
|
||||
- `### Changed` - Changes to existing functionality
|
||||
- `### Fixed` - Bug fixes
|
||||
- `### Removed` - Removed features
|
||||
|
||||
Attribution:
|
||||
|
||||
- Internal: `Fixed foo ([#123](https://github.com/badlogic/pi-mono/issues/123))`
|
||||
- External: `Added bar ([#456](https://github.com/badlogic/pi-mono/pull/456) by [@user](https://github.com/user))`
|
||||
@@ -1,22 +0,0 @@
|
||||
---
|
||||
description: Analyze GitHub issues (bugs or feature requests)
|
||||
---
|
||||
|
||||
Analyze GitHub issue(s): $ARGUMENTS
|
||||
|
||||
For each issue:
|
||||
|
||||
1. Read the issue in full, including all comments and linked issues/PRs.
|
||||
|
||||
2. **For bugs**:
|
||||
- Ignore any root cause analysis in the issue (likely wrong)
|
||||
- Read all related code files in full (no truncation)
|
||||
- Trace the code path and identify the actual root cause
|
||||
- Propose a fix
|
||||
|
||||
3. **For feature requests**:
|
||||
- Read all related code files in full (no truncation)
|
||||
- Propose the most concise implementation approach
|
||||
- List affected files and changes needed
|
||||
|
||||
Do NOT implement unless explicitly asked. Analyze and propose only.
|
||||
@@ -1,73 +0,0 @@
|
||||
---
|
||||
description: Land a PR (merge with proper workflow)
|
||||
---
|
||||
|
||||
Input
|
||||
|
||||
- PR: $1 <number|url>
|
||||
- If missing: use the most recent PR mentioned in the conversation.
|
||||
- If ambiguous: ask.
|
||||
|
||||
Do (end-to-end)
|
||||
Goal: PR must end in GitHub state = MERGED (never CLOSED). Prefer `gh pr merge --squash`; use `--rebase` only when preserving commit history is required.
|
||||
|
||||
1. Assign PR to self:
|
||||
- `gh pr edit <PR> --add-assignee @me`
|
||||
2. Repo clean: `git status`.
|
||||
3. Identify PR meta (author + head branch):
|
||||
|
||||
```sh
|
||||
gh pr view <PR> --json number,title,author,headRefName,baseRefName,headRepository --jq '{number,title,author:.author.login,head:.headRefName,base:.baseRefName,headRepo:.headRepository.nameWithOwner}'
|
||||
contrib=$(gh pr view <PR> --json author --jq .author.login)
|
||||
head=$(gh pr view <PR> --json headRefName --jq .headRefName)
|
||||
head_repo_url=$(gh pr view <PR> --json headRepository --jq .headRepository.url)
|
||||
```
|
||||
|
||||
4. Fast-forward base:
|
||||
- `git checkout main`
|
||||
- `git pull --ff-only`
|
||||
5. Create temp base branch from main:
|
||||
- `git checkout -b temp/landpr-<ts-or-pr>`
|
||||
6. Check out PR branch locally:
|
||||
- `gh pr checkout <PR>`
|
||||
7. Rebase PR branch onto temp base:
|
||||
- `git rebase temp/landpr-<ts-or-pr>`
|
||||
- Fix conflicts; keep history tidy.
|
||||
8. Fix + tests + changelog:
|
||||
- Implement fixes + add/adjust tests
|
||||
- Update `CHANGELOG.md` and mention `#<PR>` + `@$contrib`
|
||||
9. Decide merge strategy:
|
||||
- Squash (preferred): use when we want a single clean commit
|
||||
- Rebase: use only when we explicitly want to preserve commit history
|
||||
- If unclear, ask
|
||||
10. Full gate (BEFORE commit):
|
||||
- `pnpm lint && pnpm build && pnpm test`
|
||||
11. Commit via committer (final merge commit only includes PR # + thanks):
|
||||
- For the final merge-ready commit: `committer "fix: <summary> (#<PR>) (thanks @$contrib)" CHANGELOG.md <changed files>`
|
||||
- If you need intermediate fix commits before the final merge commit, keep those messages concise and **omit** PR number/thanks.
|
||||
- `land_sha=$(git rev-parse HEAD)`
|
||||
12. Push updated PR branch (rebase => usually needs force):
|
||||
|
||||
```sh
|
||||
git remote add prhead "$head_repo_url.git" 2>/dev/null || git remote set-url prhead "$head_repo_url.git"
|
||||
git push --force-with-lease prhead HEAD:$head
|
||||
```
|
||||
|
||||
13. Merge PR (must show MERGED on GitHub):
|
||||
- Squash (preferred): `gh pr merge <PR> --squash`
|
||||
- Rebase (history-preserving fallback): `gh pr merge <PR> --rebase`
|
||||
- Never `gh pr close` (closing is wrong)
|
||||
14. Sync main:
|
||||
- `git checkout main`
|
||||
- `git pull --ff-only`
|
||||
15. Comment on PR with what we did + SHAs + thanks:
|
||||
|
||||
```sh
|
||||
merge_sha=$(gh pr view <PR> --json mergeCommit --jq '.mergeCommit.oid')
|
||||
gh pr comment <PR> --body "Landed via temp rebase onto main.\n\n- Gate: pnpm lint && pnpm build && pnpm test\n- Land commit: $land_sha\n- Merge commit: $merge_sha\n\nThanks @$contrib!"
|
||||
```
|
||||
|
||||
16. Verify PR state == MERGED:
|
||||
- `gh pr view <PR> --json state --jq .state`
|
||||
17. Delete temp branch:
|
||||
- `git branch -D temp/landpr-<ts-or-pr>`
|
||||
@@ -1,134 +0,0 @@
|
||||
---
|
||||
description: Review a PR thoroughly without merging
|
||||
---
|
||||
|
||||
Input
|
||||
|
||||
- PR: $1 <number|url>
|
||||
- If missing: use the most recent PR mentioned in the conversation.
|
||||
- If ambiguous: ask.
|
||||
|
||||
Do (review-only)
|
||||
Goal: produce a thorough review and a clear recommendation (READY FOR /landpr vs NEEDS WORK vs INVALID CLAIM). Do NOT merge, do NOT push, do NOT make changes in the repo as part of this command.
|
||||
|
||||
0. Truthfulness + reality gate (required for bug-fix claims)
|
||||
- Do not trust the issue text or PR summary by default; verify in code and evidence.
|
||||
- If the PR claims to fix a bug linked to an issue, confirm the bug exists now (repro steps, logs, failing test, or clear code-path proof).
|
||||
- Prove root cause with exact location (`path/file.ts:line` + explanation of why behavior is wrong).
|
||||
- Verify fix targets the same code path as the root cause.
|
||||
- Require a regression test when feasible (fails before fix, passes after fix). If not feasible, require explicit justification + manual verification evidence.
|
||||
- Hallucination/BS red flags (treat as BLOCKER until disproven):
|
||||
- claimed behavior not present in repo,
|
||||
- issue/PR says "fixes #..." but changed files do not touch implicated path,
|
||||
- only docs/comments changed for a runtime bug claim,
|
||||
- vague AI-generated rationale without concrete evidence.
|
||||
|
||||
1. Identify PR meta + context
|
||||
|
||||
```sh
|
||||
gh pr view <PR> --json number,title,state,isDraft,author,baseRefName,headRefName,headRepository,url,body,labels,assignees,reviewRequests,files,additions,deletions --jq '{number,title,url,state,isDraft,author:.author.login,base:.baseRefName,head:.headRefName,headRepo:.headRepository.nameWithOwner,additions,deletions,files:.files|length}'
|
||||
```
|
||||
|
||||
2. Read the PR description carefully
|
||||
- Summarize the stated goal, scope, and any "why now?" rationale.
|
||||
- Call out any missing context: motivation, alternatives considered, rollout/compat notes, risk.
|
||||
|
||||
3. Read the diff thoroughly (prefer full diff)
|
||||
|
||||
```sh
|
||||
gh pr diff <PR>
|
||||
# If you need more surrounding context for files:
|
||||
gh pr checkout <PR> # optional; still review-only
|
||||
git show --stat
|
||||
```
|
||||
|
||||
4. Validate the change is needed / valuable
|
||||
- What user/customer/dev pain does this solve?
|
||||
- Is this change the smallest reasonable fix?
|
||||
- Are we introducing complexity for marginal benefit?
|
||||
- Are we changing behavior/contract in a way that needs docs or a release note?
|
||||
|
||||
5. Evaluate implementation quality + optimality
|
||||
- Correctness: edge cases, error handling, null/undefined, concurrency, ordering.
|
||||
- Design: is the abstraction/architecture appropriate or over/under-engineered?
|
||||
- Performance: hot paths, allocations, queries, network, N+1s, caching.
|
||||
- Security/privacy: authz/authn, input validation, secrets, logging PII.
|
||||
- Backwards compatibility: public APIs, config, migrations.
|
||||
- Style consistency: formatting, naming, patterns used elsewhere.
|
||||
|
||||
6. Tests & verification
|
||||
- Identify what's covered by tests (unit/integration/e2e).
|
||||
- Are there regression tests for the bug fixed / scenario added?
|
||||
- Missing tests? Call out exact cases that should be added.
|
||||
- If tests are present, do they actually assert the important behavior (not just snapshots / happy path)?
|
||||
|
||||
7. Follow-up refactors / cleanup suggestions
|
||||
- Any code that should be simplified before merge?
|
||||
- Any TODOs that should be tickets vs addressed now?
|
||||
- Any deprecations, docs, types, or lint rules we should adjust?
|
||||
|
||||
8. Key questions to answer explicitly
|
||||
- Is the core claim substantiated by evidence, or is it likely invalid/hallucinated?
|
||||
- Can we fix everything ourselves in a follow-up, or does the contributor need to update this PR?
|
||||
- Any blocking concerns (must-fix before merge)?
|
||||
- Is this PR ready to land, or does it need work?
|
||||
|
||||
9. Output (structured)
|
||||
Produce a review with these sections:
|
||||
|
||||
A) TL;DR recommendation
|
||||
|
||||
- One of: READY FOR /landpr | NEEDS WORK | INVALID CLAIM (issue/bug not substantiated) | NEEDS DISCUSSION
|
||||
- 1–3 sentence rationale.
|
||||
|
||||
B) Claim verification matrix (required)
|
||||
|
||||
- Fill this table:
|
||||
|
||||
| Field | Evidence |
|
||||
| ----------------------------------------------- | -------- |
|
||||
| Claimed problem | ... |
|
||||
| Evidence observed (repro/log/test/code) | ... |
|
||||
| Root cause location (`path:line`) | ... |
|
||||
| Why this fix addresses that root cause | ... |
|
||||
| Regression coverage (test name or manual proof) | ... |
|
||||
|
||||
- If any row is missing/weak, default to `NEEDS WORK` or `INVALID CLAIM`.
|
||||
|
||||
C) What changed
|
||||
|
||||
- Brief bullet summary of the diff/behavioral changes.
|
||||
|
||||
D) What's good
|
||||
|
||||
- Bullets: correctness, simplicity, tests, docs, ergonomics, etc.
|
||||
|
||||
E) Concerns / questions (actionable)
|
||||
|
||||
- Numbered list.
|
||||
- Mark each item as:
|
||||
- BLOCKER (must fix before merge)
|
||||
- IMPORTANT (should fix before merge)
|
||||
- NIT (optional)
|
||||
- For each: point to the file/area and propose a concrete fix or alternative.
|
||||
- If evidence for the core bug claim is missing, add a `BLOCKER` explicitly.
|
||||
|
||||
F) Tests
|
||||
|
||||
- What exists.
|
||||
- What's missing (specific scenarios).
|
||||
- State clearly whether there is a regression test for the claimed bug.
|
||||
|
||||
G) Follow-ups (optional)
|
||||
|
||||
- Non-blocking refactors/tickets to open later.
|
||||
|
||||
H) Suggested PR comment (optional)
|
||||
|
||||
- Offer: "Want me to draft a PR comment to the author?"
|
||||
- If yes, provide a ready-to-paste comment summarizing the above, with clear asks.
|
||||
|
||||
Rules / Guardrails
|
||||
|
||||
- Review only: do not merge (`gh pr merge`), do not push branches, do not edit code.
|
||||
- If you need clarification, ask questions rather than guessing.
|
||||
@@ -1,157 +0,0 @@
|
||||
# Pre-commit hooks for openclaw
|
||||
# Install: prek install
|
||||
# Run manually: prek run --all-files
|
||||
#
|
||||
# See https://pre-commit.com for more information
|
||||
|
||||
repos:
|
||||
# Basic file hygiene
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
exclude: '^(docs/|dist/|vendor/|.*\.snap$)'
|
||||
- id: end-of-file-fixer
|
||||
exclude: '^(docs/|dist/|vendor/|.*\.snap$)'
|
||||
- id: check-yaml
|
||||
args: [--allow-multiple-documents]
|
||||
- id: check-added-large-files
|
||||
args: [--maxkb=500]
|
||||
- id: check-merge-conflict
|
||||
- id: detect-private-key
|
||||
exclude: '(^|/)(\.secrets\.baseline$|\.detect-secrets\.cfg$|\.pre-commit-config\.yaml$|apps/ios/fastlane/Fastfile$|.*\.test\.ts$)'
|
||||
|
||||
# Secret detection (same as CI)
|
||||
- repo: https://github.com/Yelp/detect-secrets
|
||||
rev: v1.5.0
|
||||
hooks:
|
||||
- id: detect-secrets
|
||||
args:
|
||||
- --baseline
|
||||
- .secrets.baseline
|
||||
- --exclude-files
|
||||
- '(^|/)pnpm-lock\.yaml$'
|
||||
- --exclude-lines
|
||||
- 'key_content\.include\?\("BEGIN PRIVATE KEY"\)'
|
||||
- --exclude-lines
|
||||
- 'case \.apiKeyEnv: "API key \(env var\)"'
|
||||
- --exclude-lines
|
||||
- 'case apikey = "apiKey"'
|
||||
- --exclude-lines
|
||||
- '"gateway\.remote\.password"'
|
||||
- --exclude-lines
|
||||
- '"gateway\.auth\.password"'
|
||||
- --exclude-lines
|
||||
- '"talk\.apiKey"'
|
||||
- --exclude-lines
|
||||
- '=== "string"'
|
||||
- --exclude-lines
|
||||
- 'typeof remote\?\.password === "string"'
|
||||
- --exclude-lines
|
||||
- "OPENCLAW_DOCKER_GPG_FINGERPRINT="
|
||||
- --exclude-lines
|
||||
- '"secretShape": "(secret_input|sibling_ref)"'
|
||||
- --exclude-lines
|
||||
- 'API key rotation \(provider-specific\): set `\*_API_KEYS`'
|
||||
- --exclude-lines
|
||||
- 'password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway\.auth\.password` -> `gateway\.remote\.password`'
|
||||
- --exclude-lines
|
||||
- 'password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway\.remote\.password` -> `gateway\.auth\.password`'
|
||||
- --exclude-files
|
||||
- '^src/gateway/client\.watchdog\.test\.ts$'
|
||||
- --exclude-lines
|
||||
- 'export CUSTOM_API_K[E]Y="your-key"'
|
||||
- --exclude-lines
|
||||
- 'grep -q ''N[O]DE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache'' ~/.bashrc \|\| cat >> ~/.bashrc <<''EOF'''
|
||||
- --exclude-lines
|
||||
- 'env: \{ MISTRAL_API_K[E]Y: "sk-\.\.\." \},'
|
||||
- --exclude-lines
|
||||
- '"ap[i]Key": "xxxxx"(,)?'
|
||||
- --exclude-lines
|
||||
- 'ap[i]Key: "A[I]za\.\.\.",'
|
||||
- --exclude-lines
|
||||
- '"ap[i]Key": "(resolved|normalized|legacy)-key"(,)?'
|
||||
- --exclude-lines
|
||||
- 'sparkle:edSignature="[A-Za-z0-9+/=]+"'
|
||||
# Shell script linting
|
||||
- repo: https://github.com/koalaman/shellcheck-precommit
|
||||
rev: v0.11.0
|
||||
hooks:
|
||||
- id: shellcheck
|
||||
args: [--severity=error] # Only fail on errors, not warnings/info
|
||||
# Exclude vendor and scripts with embedded code or known issues
|
||||
exclude: "^(vendor/|scripts/e2e/)"
|
||||
|
||||
# GitHub Actions linting
|
||||
- repo: https://github.com/rhysd/actionlint
|
||||
rev: v1.7.10
|
||||
hooks:
|
||||
- id: actionlint
|
||||
|
||||
# GitHub Actions security audit
|
||||
- repo: https://github.com/zizmorcore/zizmor-pre-commit
|
||||
rev: v1.22.0
|
||||
hooks:
|
||||
- id: zizmor
|
||||
args: [--persona=regular, --min-severity=medium, --min-confidence=medium]
|
||||
exclude: "^(vendor/|Swabble/)"
|
||||
|
||||
# Python checks for skills scripts
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.14.1
|
||||
hooks:
|
||||
- id: ruff
|
||||
files: "^skills/.*\\.py$"
|
||||
args: [--config, pyproject.toml]
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: skills-python-tests
|
||||
name: skills python tests
|
||||
entry: pytest -q skills
|
||||
language: python
|
||||
additional_dependencies: [pytest>=8, <9]
|
||||
pass_filenames: false
|
||||
files: "^skills/.*\\.py$"
|
||||
|
||||
# Project checks (same commands as CI)
|
||||
- repo: local
|
||||
hooks:
|
||||
# pnpm audit --prod --audit-level=high
|
||||
- id: pnpm-audit-prod
|
||||
name: pnpm-audit-prod
|
||||
entry: pnpm audit --prod --audit-level=high
|
||||
language: system
|
||||
pass_filenames: false
|
||||
|
||||
# oxlint --type-aware src test
|
||||
- id: oxlint
|
||||
name: oxlint
|
||||
entry: scripts/pre-commit/run-node-tool.sh oxlint --type-aware src test
|
||||
language: system
|
||||
pass_filenames: false
|
||||
types_or: [javascript, jsx, ts, tsx]
|
||||
|
||||
# oxfmt --check src test
|
||||
- id: oxfmt
|
||||
name: oxfmt
|
||||
entry: scripts/pre-commit/run-node-tool.sh oxfmt --check src test
|
||||
language: system
|
||||
pass_filenames: false
|
||||
types_or: [javascript, jsx, ts, tsx]
|
||||
|
||||
# swiftlint (same as CI)
|
||||
- id: swiftlint
|
||||
name: swiftlint
|
||||
entry: swiftlint --config .swiftlint.yml
|
||||
language: system
|
||||
pass_filenames: false
|
||||
types: [swift]
|
||||
|
||||
# swiftformat --lint (same as CI)
|
||||
- id: swiftformat
|
||||
name: swiftformat
|
||||
entry: swiftformat --lint apps/macos/Sources --config .swiftformat
|
||||
language: system
|
||||
pass_filenames: false
|
||||
types: [swift]
|
||||
12855
.secrets.baseline
12855
.secrets.baseline
File diff suppressed because it is too large
Load Diff
@@ -1,25 +0,0 @@
|
||||
# ShellCheck configuration
|
||||
# https://www.shellcheck.net/wiki/
|
||||
|
||||
# Disable common false positives and style suggestions
|
||||
|
||||
# SC2034: Variable appears unused (often exported or used indirectly)
|
||||
disable=SC2034
|
||||
|
||||
# SC2155: Declare and assign separately (common idiom, rarely causes issues)
|
||||
disable=SC2155
|
||||
|
||||
# SC2295: Expansions inside ${..} need quoting (info-level, rarely causes issues)
|
||||
disable=SC2295
|
||||
|
||||
# SC1012: \r is literal (tr -d '\r' works as intended on most systems)
|
||||
disable=SC1012
|
||||
|
||||
# SC2026: Word outside quotes (info-level, often intentional)
|
||||
disable=SC2026
|
||||
|
||||
# SC2016: Expressions don't expand in single quotes (often intentional in sed/awk)
|
||||
disable=SC2016
|
||||
|
||||
# SC2129: Consider using { cmd1; cmd2; } >> file (style preference)
|
||||
disable=SC2129
|
||||
@@ -23,7 +23,7 @@
|
||||
# Whitespace
|
||||
--trimwhitespace always
|
||||
--emptybraces no-space
|
||||
--nospaceoperators ...,..<
|
||||
--nospaceoperators ...,..<
|
||||
--ranges no-space
|
||||
--someAny true
|
||||
--voidtype void
|
||||
@@ -48,4 +48,4 @@
|
||||
--allman false
|
||||
|
||||
# Exclusions
|
||||
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Peekaboo,Swabble,apps/android,apps/ios,apps/shared,apps/macos/Sources/OpenClawProtocol,apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift
|
||||
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Peekaboo,Swabble,apps/android,apps/ios,apps/shared,apps/macos/Sources/ClawdisProtocol,apps/macos/Sources/ClawdbotProtocol
|
||||
|
||||
@@ -18,9 +18,7 @@ excluded:
|
||||
- coverage
|
||||
- "*.playground"
|
||||
# Generated (protocol-gen-swift.ts)
|
||||
- apps/macos/Sources/OpenClawProtocol/GatewayModels.swift
|
||||
# Generated (generate-host-env-security-policy-swift.mjs)
|
||||
- apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift
|
||||
- apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift
|
||||
|
||||
analyzer_rules:
|
||||
- unused_declaration
|
||||
|
||||
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"recommendations": ["oxc.oxc-vscode"]
|
||||
}
|
||||
22
.vscode/settings.json
vendored
22
.vscode/settings.json
vendored
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"files.insertFinalNewline": true,
|
||||
"files.trimFinalNewlines": true,
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode"
|
||||
},
|
||||
"typescript.preferences.importModuleSpecifierEnding": "js",
|
||||
"typescript.reportStyleChecksAsWarnings": false,
|
||||
"typescript.updateImportsOnFileMove.enabled": "always",
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"typescript.experimental.useTsgo": true
|
||||
}
|
||||
254
AGENTS.md
254
AGENTS.md
@@ -1,223 +1,97 @@
|
||||
# Repository Guidelines
|
||||
|
||||
- Repo: https://github.com/openclaw/openclaw
|
||||
- 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.
|
||||
|
||||
## 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.
|
||||
- Repo: https://github.com/clawdbot/clawdbot
|
||||
- GitHub issues: use literal multiline strings or $'...' for newlines; avoid "\\n" escapes in `gh issue create/edit`.
|
||||
|
||||
## 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/`.
|
||||
- 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).
|
||||
- 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`
|
||||
- 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).
|
||||
- Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
|
||||
|
||||
## Docs Linking (Mintlify)
|
||||
|
||||
- Docs are hosted on Mintlify (docs.openclaw.ai).
|
||||
- Docs are hosted on Mintlify (docs.clawd.bot).
|
||||
- Internal doc links in `docs/**/*.md`: root-relative, no `.md`/`.mdx` (example: `[Config](/configuration)`).
|
||||
- When working with documentation, read the mintlify skill.
|
||||
- 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 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.
|
||||
- When Peter asks for links, reply with full `https://docs.clawd.bot/...` URLs (not root-relative).
|
||||
- When you touch docs, end the reply with the `https://docs.clawd.bot/...` URLs you referenced.
|
||||
- README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) 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 (zh-CN)
|
||||
|
||||
- `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.
|
||||
|
||||
## exe.dev VM ops (general)
|
||||
|
||||
- Access: stable path is `ssh exe.dev` then `ssh vm-name` (assume SSH key already set).
|
||||
- SSH flaky: use exe.dev web terminal or Shelley (web agent); keep a tmux session for long ops.
|
||||
- Update: `sudo npm i -g openclaw@latest` (global install needs root on `/usr/lib/node_modules`).
|
||||
- Config: use `openclaw config set ...`; ensure `gateway.mode=local` is set.
|
||||
- Discord: store raw token only (no `DISCORD_BOT_TOKEN=` prefix).
|
||||
- Restart: stop old gateway and run:
|
||||
`pkill -9 -f openclaw-gateway || true; nohup openclaw gateway run --bind loopback --port 18789 --force > /tmp/openclaw-gateway.log 2>&1 &`
|
||||
- Verify: `openclaw channels status --probe`, `ss -ltnp | rg 18789`, `tail -n 120 /tmp/openclaw-gateway.log`.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
- 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` (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`.
|
||||
- Run CLI in dev: `pnpm clawdbot ...` (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. Release checklist: `docs/platforms/mac/release.md`.
|
||||
- Type-check/build: `pnpm build`
|
||||
- TypeScript checks: `pnpm tsgo`
|
||||
- Lint/format: `pnpm check`
|
||||
- Format check: `pnpm format` (oxfmt --check)
|
||||
- Format fix: `pnpm format:fix` (oxfmt --write)
|
||||
- Type-check/build: `pnpm build` (tsc)
|
||||
- Lint/format: `pnpm lint` (oxlint), `pnpm format` (oxfmt)
|
||||
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
|
||||
- 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.
|
||||
- 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.
|
||||
- Formatting/linting via Oxlint and Oxfmt; run `pnpm lint` before commits.
|
||||
- Add brief code comments for tricky or non-obvious logic.
|
||||
- Keep files concise; extract helpers instead of “V2” copies. Use existing patterns for CLI options and dependency injection via `createDefaultDeps`.
|
||||
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.
|
||||
- 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 Channels (Naming)
|
||||
|
||||
- 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).
|
||||
- Naming: use **Clawdbot** for product/app/docs headings; use `clawdbot` for CLI command, package/binary, paths, and config keys.
|
||||
|
||||
## 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`.
|
||||
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
|
||||
- Do not set test workers above 16; tried already.
|
||||
- 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`.
|
||||
- Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (Clawdbot-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.
|
||||
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
|
||||
- Mobile: before using a simulator, check for connected real devices (iOS + Android) and prefer them when available.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
**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.
|
||||
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
|
||||
- Group related changes; avoid bundling unrelated refactors.
|
||||
- PR submission template (canonical): `.github/pull_request_template.md`
|
||||
- Issue submission templates (canonical): `.github/ISSUE_TEMPLATE/`
|
||||
- Changelog workflow: keep latest released version at top (no `Unreleased`); after publishing, bump version and start a new top section.
|
||||
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
|
||||
- PR review flow: when given a PR link, review via `gh pr view`/`gh pr diff` and do **not** change branches.
|
||||
- PR merge flow: create a temp branch from `main`, merge the PR branch into it (prefer squash unless commit history is important; use rebase/merge when it is). Always try to merge the PR unless it’s truly difficult, then use another approach. If we squash, add the PR author as a co-contributor. Apply fixes, add changelog entry (include PR # + thanks), run full gate before the final commit, commit, merge back to `main`, delete the temp branch, and end on `main`.
|
||||
- If you review a PR and later do work on it, land via merge/squash (no direct-main commits) and always add the PR author as a co-contributor.
|
||||
- When working on a PR: add a changelog entry with the PR number and thank the contributor.
|
||||
- When working on an issue: reference the issue in the changelog entry.
|
||||
- When merging a PR: leave a PR comment that explains exactly what we did and include the SHA hashes.
|
||||
- When merging a PR from a new contributor: add their avatar to the README “Thanks to all clawtributors” thumbnail list.
|
||||
- After merging a PR: run `bun scripts/update-clawtributors.ts` if the contributor is missing, then commit the regenerated README.
|
||||
|
||||
## Shorthand Commands
|
||||
- `sync up`: 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`.
|
||||
|
||||
- `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>`.
|
||||
- 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)"'`
|
||||
### PR Workflow (Review vs Land)
|
||||
- **Review mode (PR link only):** read `gh pr view/diff`; **do not** switch branches; **do not** change code.
|
||||
- **Landing mode:** create an integration branch from `main`, bring in PR commits (**prefer rebase** for linear history; **merge allowed** when complexity/conflicts make it safer), apply fixes, add changelog (+ thanks + PR #), run full gate **locally before committing** (`pnpm lint && pnpm build && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing). Important: contributor needs to be in git graph after this!
|
||||
|
||||
## 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.
|
||||
- Web provider stores creds at `~/.clawdbot/credentials/`; rerun `clawdbot login` if logged out.
|
||||
- Pi sessions live under `~/.clawdbot/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: 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.
|
||||
|
||||
## 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
|
||||
- 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.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Rebrand/migration issues or legacy config/service warnings: run `openclaw doctor` (see `docs/gateway/doctor.md`).
|
||||
- Rebrand/migration issues or legacy config/service warnings: run `clawdbot doctor` (see `docs/gateway/doctor.md`).
|
||||
|
||||
## Agent-Specific Notes
|
||||
|
||||
- Vocabulary: "makeup" = "mac app".
|
||||
- Never edit `node_modules` (global/Homebrew/npm/git installs too). Updates overwrite. Skill notes go in `tools.md` or `AGENTS.md`.
|
||||
- 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.**
|
||||
- macOS logs: use `./scripts/clawlog.sh` to query unified logs for the OpenClaw subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
|
||||
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` 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.**
|
||||
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for the Clawdbot subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
|
||||
- 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), `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).
|
||||
- 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/Clawdbot/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).
|
||||
- **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.
|
||||
- iOS Team ID lookup: `security find-identity -p codesigning -v` → use Apple Development (…) TEAMID. Fallback: `defaults read com.apple.dt.Xcode IDEProvisioningTeamIdentifiers`.
|
||||
@@ -230,68 +104,42 @@
|
||||
- **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.
|
||||
- **Multi-agent safety:** when you see unrecognized files, keep going; focus on your changes and commit only those.
|
||||
- Lint/format churn:
|
||||
- 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.
|
||||
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/main/sessions/*.jsonl` (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.
|
||||
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
|
||||
- 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.
|
||||
- Command template should stay `clawdbot-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`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
|
||||
- For manual `clawdbot 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)
|
||||
## Exclamation Mark Escaping Workaround
|
||||
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot message send` with messages containing exclamation marks, use heredoc syntax:
|
||||
|
||||
- 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.
|
||||
```bash
|
||||
# WRONG - will send "Hello\\!" with backslash
|
||||
clawdbot message send --to "+1234" --message 'Hello!'
|
||||
|
||||
## Changelog Release Notes
|
||||
# CORRECT - use heredoc to avoid escaping
|
||||
clawdbot message send --to "+1234" --message "$(cat <<'EOF'
|
||||
Hello!
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
- 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.
|
||||
This is a Claude Code quirk, not a clawdbot bug.
|
||||
|
||||
3704
CHANGELOG.md
3704
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
174
CONTRIBUTING.md
174
CONTRIBUTING.md
@@ -1,194 +1,42 @@
|
||||
# Contributing to OpenClaw
|
||||
# Contributing to Clawdbot
|
||||
|
||||
Welcome to the lobster tank! 🦞
|
||||
|
||||
## Quick Links
|
||||
|
||||
- **GitHub:** https://github.com/openclaw/openclaw
|
||||
- **Vision:** [`VISION.md`](VISION.md)
|
||||
- **GitHub:** https://github.com/clawdbot/clawdbot
|
||||
- **Discord:** https://discord.gg/qkhbAGHRBT
|
||||
- **X/Twitter:** [@steipete](https://x.com/steipete) / [@openclaw](https://x.com/openclaw)
|
||||
- **X/Twitter:** [@steipete](https://x.com/steipete) / [@clawdbot](https://x.com/clawdbot)
|
||||
|
||||
## Maintainers
|
||||
|
||||
- **Peter Steinberger** - Benevolent Dictator
|
||||
- GitHub: [@steipete](https://github.com/steipete) · X: [@steipete](https://x.com/steipete)
|
||||
|
||||
- **Shadow** - Discord subsystem, Discord admin, Clawhub, all community moderation
|
||||
- GitHub: [@thewilloftheshadow](https://github.com/thewilloftheshadow) · X: [@4shadowed](https://x.com/4shadowed)
|
||||
|
||||
- **Vignesh** - Memory (QMD), formal modeling, TUI, IRC, and Lobster
|
||||
- GitHub: [@vignesh07](https://github.com/vignesh07) · X: [@\_vgnsh](https://x.com/_vgnsh)
|
||||
- **Shadow** - Discord + Slack subsystem
|
||||
- GitHub: [@thewilloftheshadow](https://github.com/thewilloftheshadow) · X: [@4shad0wed](https://x.com/4shad0wed)
|
||||
|
||||
- **Jos** - Telegram, API, Nix mode
|
||||
- GitHub: [@joshp123](https://github.com/joshp123) · X: [@jjpcodes](https://x.com/jjpcodes)
|
||||
|
||||
- **Ayaan Zaidi** - Telegram subsystem, iOS app
|
||||
- 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)
|
||||
|
||||
- **Mariano Belinky** - iOS app, Security
|
||||
- GitHub: [@mbelinky](https://github.com/mbelinky) · X: [@belimad](https://x.com/belimad)
|
||||
|
||||
- **Nimrod Gutman** - iOS app, macOS app and crustacean features
|
||||
- GitHub: [@ngutman](https://github.com/ngutman) · X: [@theguti](https://x.com/theguti)
|
||||
|
||||
- **Vincent Koc** - Agents, Telemetry, Hooks, Security
|
||||
- GitHub: [@vincentkoc](https://github.com/vincentkoc) · X: [@vincent_koc](https://x.com/vincent_koc)
|
||||
|
||||
- **Val Alexander** - UI/UX, Docs, and Agent DevX
|
||||
- GitHub: [@BunsDev](https://github.com/BunsDev) · X: [@BunsDev](https://x.com/BunsDev)
|
||||
|
||||
- **Seb Slight** - Docs, Agent Reliability, Runtime Hardening
|
||||
- GitHub: [@sebslight](https://github.com/sebslight) · X: [@sebslig](https://x.com/sebslig)
|
||||
|
||||
- **Christoph Nakazawa** - JS Infra
|
||||
- GitHub: [@cpojer](https://github.com/cpojer) · X: [@cnakazawa](https://x.com/cnakazawa)
|
||||
|
||||
- **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
|
||||
- GitHub: [@onutc](https://github.com/onutc), [@osolmaz](https://github.com/osolmaz) · X: [@onusoz](https://x.com/onusoz)
|
||||
|
||||
- **Josh Avant** - Core, CLI, Gateway, Security, Agents
|
||||
- GitHub: [@joshavant](https://github.com/joshavant) · X: [@joshavant](https://x.com/joshavant)
|
||||
|
||||
- **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_)
|
||||
|
||||
- **Radek Sienkiewicz** - Control UI + WebChat correctness
|
||||
- GitHub [@velvet-shark](https://github.com/velvet-shark) · X: [@velvet_shark](https://twitter.com/velvet_shark)
|
||||
|
||||
- **Muhammed Mukhthar** - Mattermost, CLI
|
||||
- GitHub [@mukhtharcm](https://github.com/mukhtharcm) · X: [@mukhtharcm](https://x.com/mukhtharcm)
|
||||
|
||||
- **Altay** - Agents, CLI, error handling
|
||||
- GitHub [@altaywtf](https://github.com/altaywtf) · X: [@altaywtf](https://x.com/altaywtf)
|
||||
|
||||
- **Robin Waslander** - Security, PR triage, bug fixes
|
||||
- GitHub: [@hydro13](https://github.com/hydro13) · X: [@Robin_waslander](https://x.com/Robin_waslander)
|
||||
|
||||
- **Tengji (George) Zhang** - Chinese model APIs, cloud, pi
|
||||
- GitHub: [@odysseus0](https://github.com/odysseus0) · X: [@odysseus0z](https://x.com/odysseus0z)
|
||||
|
||||
## 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. **Questions** → Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828)
|
||||
2. **New features / architecture** → Start a [GitHub Discussion](https://github.com/clawdbot/clawdbot/discussions) or ask in Discord first
|
||||
3. **Questions** → Discord #setup-help
|
||||
|
||||
## Before You PR
|
||||
|
||||
- Test locally with your OpenClaw instance
|
||||
- Run tests: `pnpm build && pnpm check && pnpm test`
|
||||
- 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.
|
||||
- Ensure CI checks pass
|
||||
- Keep PRs focused (one thing per PR; do not mix unrelated concerns)
|
||||
- Test locally with your Clawdbot instance
|
||||
- Run linter: `npm run lint`
|
||||
- Keep PRs focused (one thing per PR)
|
||||
- Describe what & why
|
||||
- Reply to or resolve bot review conversations you addressed before asking for review again
|
||||
- **Include screenshots** — one showing the problem/before, one showing the fix/after (for UI or visual changes)
|
||||
- Use American English spelling and grammar in code, comments, docs, and UI strings
|
||||
|
||||
## Review Conversations Are Author-Owned
|
||||
|
||||
If a review bot leaves review conversations on your PR, you are expected to handle the follow-through:
|
||||
|
||||
- Resolve the conversation yourself once the code or explanation fully addresses the bot's concern
|
||||
- Reply and leave it open only when you need maintainer or reviewer judgment
|
||||
- Do not leave "fixed" bot review conversations for maintainers to clean up for you
|
||||
- If Codex leaves comments, address every relevant one or resolve it with a short explanation when it is not applicable to your change
|
||||
- If GitHub Codex review does not trigger for some reason, run `codex review --base origin/main` locally anyway and treat that output as required review work
|
||||
|
||||
This applies to both human-authored and AI-assisted PRs.
|
||||
|
||||
## Control UI Decorators
|
||||
|
||||
The Control UI uses Lit with **legacy** decorators (current Rollup parsing does not support
|
||||
`accessor` fields required for standard decorators). When adding reactive fields, keep the
|
||||
legacy style:
|
||||
|
||||
```ts
|
||||
@state() foo = "bar";
|
||||
@property({ type: Number }) count = 0;
|
||||
```
|
||||
|
||||
The root `tsconfig.json` is configured for legacy decorators (`experimentalDecorators: true`)
|
||||
with `useDefineForClassFields: false`. Avoid flipping these unless you are also updating the UI
|
||||
build tooling to support standard decorators.
|
||||
|
||||
## AI/Vibe-Coded PRs Welcome! 🤖
|
||||
|
||||
Built with Codex, Claude, or other AI tools? **Awesome - just mark it!**
|
||||
|
||||
Please include in your PR:
|
||||
|
||||
- [ ] Mark as AI-assisted in the PR title or description
|
||||
- [ ] Note the degree of testing (untested / lightly tested / fully tested)
|
||||
- [ ] Include prompts or session logs if possible (super helpful!)
|
||||
- [ ] Confirm you understand what the code does
|
||||
- [ ] If you have access to Codex, run `codex review --base origin/main` locally and address the findings before asking for review
|
||||
- [ ] Resolve or reply to bot review conversations after you address them
|
||||
|
||||
AI PRs are first-class citizens here. We just want transparency so reviewers know what to look for. If you are using an LLM coding agent, instruct it to resolve bot review conversations it has addressed instead of leaving them for maintainers.
|
||||
|
||||
## Current Focus & Roadmap 🗺
|
||||
|
||||
We are currently prioritizing:
|
||||
|
||||
- **Stability**: Fixing edge cases in channel connections (WhatsApp/Telegram).
|
||||
- **UX**: Improving the onboarding wizard and error messages.
|
||||
- **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" labels!
|
||||
|
||||
## Maintainers
|
||||
|
||||
We're selectively expanding the maintainer team.
|
||||
If you're an experienced contributor who wants to help shape OpenClaw's direction — whether through code, docs, or community — we'd like to hear from you.
|
||||
|
||||
Being a maintainer is a responsibility, not an honorary title. We expect active, consistent involvement — triaging issues, reviewing PRs, and helping move the project forward.
|
||||
|
||||
Still interested? Email contributing@openclaw.ai with:
|
||||
|
||||
- Links to your PRs on OpenClaw (if you don't have any, start there first)
|
||||
- Links to open source projects you maintain or actively contribute to
|
||||
- Your GitHub, Discord, and X/Twitter handles
|
||||
- A brief intro: background, experience, and areas of interest
|
||||
- Languages you speak and where you're based
|
||||
- How much time you can realistically commit
|
||||
|
||||
We welcome people across all skill sets — engineering, documentation, community management, and more.
|
||||
We review every human-only-written application carefully and add maintainers slowly and deliberately.
|
||||
Please allow a few weeks for a response.
|
||||
|
||||
## Report a Vulnerability
|
||||
|
||||
We take security reports seriously. Report vulnerabilities directly to the repository where the issue lives:
|
||||
|
||||
- **Core CLI and gateway** — [openclaw/openclaw](https://github.com/openclaw/openclaw)
|
||||
- **macOS desktop app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/macos)
|
||||
- **iOS app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/ios)
|
||||
- **Android app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/android)
|
||||
- **ClawHub** — [openclaw/clawhub](https://github.com/openclaw/clawhub)
|
||||
- **Trust and threat model** — [openclaw/trust](https://github.com/openclaw/trust)
|
||||
|
||||
For issues that don't fit a specific repo, or if you're unsure, email **security@openclaw.ai** and we'll route it.
|
||||
|
||||
### Required in Reports
|
||||
|
||||
1. **Title**
|
||||
2. **Severity Assessment**
|
||||
3. **Impact**
|
||||
4. **Affected Component**
|
||||
5. **Technical Reproduction**
|
||||
6. **Demonstrated Impact**
|
||||
7. **Environment**
|
||||
8. **Remediation Advice**
|
||||
|
||||
Reports without reproduction steps, demonstrated impact, and remediation advice will be deprioritized. Given the volume of AI-generated scanner findings, we must ensure we're receiving vetted reports from researchers who understand the issues.
|
||||
AI PRs are first-class citizens here. We just want transparency so reviewers know what to look for.
|
||||
|
||||
247
Dockerfile
247
Dockerfile
@@ -1,248 +1,35 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
FROM node:22-bookworm
|
||||
|
||||
# Opt-in extension dependencies at build time (space-separated directory names).
|
||||
# Example: docker build --build-arg OPENCLAW_EXTENSIONS="diagnostics-otel matrix" .
|
||||
#
|
||||
# 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
|
||||
# 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_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"
|
||||
ARG OPENCLAW_NODE_BOOKWORM_SLIM_DIGEST="sha256:e8e2e91b1378f83c5b2dd15f0247f34110e2fe895f6ca7719dbb780f929368eb"
|
||||
|
||||
# Base images are pinned to SHA256 digests for reproducible builds.
|
||||
# Trade-off: digests must be updated manually when upstream tags move.
|
||||
# To update, run: docker buildx imagetools inspect node:24-bookworm (or podman)
|
||||
# and replace the digest below with the current multi-arch manifest list entry.
|
||||
|
||||
FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS ext-deps
|
||||
ARG OPENCLAW_EXTENSIONS
|
||||
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/extensions/$ext/package.json" ]; then \
|
||||
mkdir -p "/out/$ext" && \
|
||||
cp "/tmp/extensions/$ext/package.json" "/out/$ext/package.json"; \
|
||||
fi; \
|
||||
done
|
||||
|
||||
# ── Stage 2: Build ──────────────────────────────────────────────
|
||||
FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS build
|
||||
|
||||
# Install Bun (required for build scripts). Retry the whole bootstrap flow to
|
||||
# tolerate transient 5xx failures from bun.sh/GitHub during CI image builds.
|
||||
RUN set -eux; \
|
||||
for attempt in 1 2 3 4 5; do \
|
||||
if curl --retry 5 --retry-all-errors --retry-delay 2 -fsSL https://bun.sh/install | bash; then \
|
||||
break; \
|
||||
fi; \
|
||||
if [ "$attempt" -eq 5 ]; then \
|
||||
exit 1; \
|
||||
fi; \
|
||||
sleep $((attempt * 2)); \
|
||||
done
|
||||
# Install Bun (required for build scripts)
|
||||
RUN curl -fsSL https://bun.sh/install | bash
|
||||
ENV PATH="/root/.bun/bin:${PATH}"
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG CLAWDBOT_DOCKER_APT_PACKAGES=""
|
||||
RUN if [ -n "$CLAWDBOT_DOCKER_APT_PACKAGES" ]; then \
|
||||
apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $CLAWDBOT_DOCKER_APT_PACKAGES && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
|
||||
fi
|
||||
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
|
||||
COPY ui/package.json ./ui/package.json
|
||||
COPY patches ./patches
|
||||
COPY scripts ./scripts
|
||||
|
||||
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).
|
||||
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \
|
||||
NODE_OPTIONS=--max-old-space-size=2048 pnpm install --frozen-lockfile
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
|
||||
# Normalize extension paths now so runtime COPY preserves safe modes
|
||||
# without adding a second full extensions layer.
|
||||
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 {} +; \
|
||||
fi; \
|
||||
done
|
||||
|
||||
# A2UI bundle may fail under QEMU cross-compilation (e.g. building amd64
|
||||
# on Apple Silicon). CI builds natively per-arch so this is a no-op there.
|
||||
# Stub it so local cross-arch builds still succeed.
|
||||
RUN pnpm canvas:a2ui:bundle || \
|
||||
(echo "A2UI bundle: creating stub (non-fatal)" && \
|
||||
mkdir -p src/canvas-host/a2ui && \
|
||||
echo "/* A2UI bundle unavailable in this build */" > src/canvas-host/a2ui/a2ui.bundle.js && \
|
||||
echo "stub" > src/canvas-host/a2ui/.bundle.hash && \
|
||||
rm -rf vendor/a2ui apps/shared/OpenClawKit/Tools/CanvasA2UI)
|
||||
RUN pnpm build:docker
|
||||
RUN pnpm build
|
||||
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
|
||||
ENV OPENCLAW_PREFER_PNPM=1
|
||||
ENV CLAWDBOT_PREFER_PNPM=1
|
||||
RUN pnpm ui:install
|
||||
RUN pnpm ui:build
|
||||
|
||||
# Prune dev dependencies and strip build-only metadata before copying
|
||||
# runtime assets into the final image.
|
||||
FROM build AS runtime-assets
|
||||
RUN CI=true pnpm prune --prod && \
|
||||
find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete
|
||||
|
||||
# ── Runtime base images ─────────────────────────────────────────
|
||||
FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS base-default
|
||||
ARG OPENCLAW_NODE_BOOKWORM_DIGEST
|
||||
LABEL org.opencontainers.image.base.name="docker.io/library/node:24-bookworm" \
|
||||
org.opencontainers.image.base.digest="${OPENCLAW_NODE_BOOKWORM_DIGEST}"
|
||||
|
||||
FROM ${OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE} AS base-slim
|
||||
ARG OPENCLAW_NODE_BOOKWORM_SLIM_DIGEST
|
||||
LABEL org.opencontainers.image.base.name="docker.io/library/node:24-bookworm-slim" \
|
||||
org.opencontainers.image.base.digest="${OPENCLAW_NODE_BOOKWORM_SLIM_DIGEST}"
|
||||
|
||||
# ── Stage 3: Runtime ────────────────────────────────────────────
|
||||
FROM base-${OPENCLAW_VARIANT}
|
||||
ARG OPENCLAW_VARIANT
|
||||
|
||||
# OCI base-image metadata for downstream image consumers.
|
||||
# If you change these annotations, also update:
|
||||
# - docs/install/docker.md ("Base image metadata" section)
|
||||
# - https://docs.openclaw.ai/install/docker
|
||||
LABEL org.opencontainers.image.source="https://github.com/openclaw/openclaw" \
|
||||
org.opencontainers.image.url="https://openclaw.ai" \
|
||||
org.opencontainers.image.documentation="https://docs.openclaw.ai/install/docker" \
|
||||
org.opencontainers.image.licenses="MIT" \
|
||||
org.opencontainers.image.title="OpenClaw" \
|
||||
org.opencontainers.image.description="OpenClaw gateway and CLI runtime container image"
|
||||
|
||||
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).
|
||||
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 && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
procps hostname curl git openssl
|
||||
|
||||
RUN chown node:node /app
|
||||
|
||||
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/extensions ./extensions
|
||||
COPY --from=runtime-assets --chown=node:node /app/skills ./skills
|
||||
COPY --from=runtime-assets --chown=node:node /app/docs ./docs
|
||||
|
||||
# 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
|
||||
# first-run network fetch when invoking pnpm.
|
||||
ENV COREPACK_HOME=/usr/local/share/corepack
|
||||
RUN install -d -m 0755 "$COREPACK_HOME" && \
|
||||
corepack enable && \
|
||||
for attempt in 1 2 3 4 5; do \
|
||||
if corepack prepare "$(node -p "require('./package.json').packageManager")" --activate; then \
|
||||
break; \
|
||||
fi; \
|
||||
if [ "$attempt" -eq 5 ]; then \
|
||||
exit 1; \
|
||||
fi; \
|
||||
sleep $((attempt * 2)); \
|
||||
done && \
|
||||
chmod -R a+rX "$COREPACK_HOME"
|
||||
|
||||
# Install additional system packages needed by your skills or extensions.
|
||||
# Example: docker build --build-arg OPENCLAW_DOCKER_APT_PACKAGES="python3 wget" .
|
||||
ARG OPENCLAW_DOCKER_APT_PACKAGES=""
|
||||
RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
|
||||
if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \
|
||||
apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $OPENCLAW_DOCKER_APT_PACKAGES; \
|
||||
fi
|
||||
|
||||
# Optionally install Chromium and Xvfb for browser automation.
|
||||
# Build with: docker build --build-arg OPENCLAW_INSTALL_BROWSER=1 ...
|
||||
# Adds ~300MB but eliminates the 60-90s Playwright install on every container start.
|
||||
# Must run after node_modules COPY so playwright-core is available.
|
||||
ARG OPENCLAW_INSTALL_BROWSER=""
|
||||
RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
|
||||
if [ -n "$OPENCLAW_INSTALL_BROWSER" ]; then \
|
||||
apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends xvfb && \
|
||||
mkdir -p /home/node/.cache/ms-playwright && \
|
||||
PLAYWRIGHT_BROWSERS_PATH=/home/node/.cache/ms-playwright \
|
||||
node /app/node_modules/playwright-core/cli.js install --with-deps chromium && \
|
||||
chown -R node:node /home/node/.cache/ms-playwright; \
|
||||
fi
|
||||
|
||||
# Optionally install Docker CLI for sandbox container management.
|
||||
# Build with: docker build --build-arg OPENCLAW_INSTALL_DOCKER_CLI=1 ...
|
||||
# Adds ~50MB. Only the CLI is installed — no Docker daemon.
|
||||
# Required for agents.defaults.sandbox to function in Docker deployments.
|
||||
ARG OPENCLAW_INSTALL_DOCKER_CLI=""
|
||||
ARG OPENCLAW_DOCKER_GPG_FINGERPRINT="9DC858229FC7DD38854AE2D88D81803C0EBFCD88"
|
||||
RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
|
||||
if [ -n "$OPENCLAW_INSTALL_DOCKER_CLI" ]; then \
|
||||
apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl gnupg && \
|
||||
install -m 0755 -d /etc/apt/keyrings && \
|
||||
# Verify Docker apt signing key fingerprint before trusting it as a root key.
|
||||
# Update OPENCLAW_DOCKER_GPG_FINGERPRINT when Docker rotates release keys.
|
||||
curl -fsSL https://download.docker.com/linux/debian/gpg -o /tmp/docker.gpg.asc && \
|
||||
expected_fingerprint="$(printf '%s' "$OPENCLAW_DOCKER_GPG_FINGERPRINT" | tr '[:lower:]' '[:upper:]' | tr -d '[:space:]')" && \
|
||||
actual_fingerprint="$(gpg --batch --show-keys --with-colons /tmp/docker.gpg.asc | awk -F: '$1 == "fpr" { print toupper($10); exit }')" && \
|
||||
if [ -z "$actual_fingerprint" ] || [ "$actual_fingerprint" != "$expected_fingerprint" ]; then \
|
||||
echo "ERROR: Docker apt key fingerprint mismatch (expected $expected_fingerprint, got ${actual_fingerprint:-<empty>})" >&2; \
|
||||
exit 1; \
|
||||
fi && \
|
||||
gpg --dearmor -o /etc/apt/keyrings/docker.gpg /tmp/docker.gpg.asc && \
|
||||
rm -f /tmp/docker.gpg.asc && \
|
||||
chmod a+r /etc/apt/keyrings/docker.gpg && \
|
||||
printf 'deb [arch=%s signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian bookworm stable\n' \
|
||||
"$(dpkg --print-architecture)" > /etc/apt/sources.list.d/docker.list && \
|
||||
apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
docker-ce-cli docker-compose-plugin; \
|
||||
fi
|
||||
|
||||
# Expose the CLI binary without requiring npm global writes as non-root.
|
||||
RUN ln -sf /app/openclaw.mjs /usr/local/bin/openclaw \
|
||||
&& chmod 755 /app/openclaw.mjs
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Security hardening: Run as non-root user
|
||||
# The node:24-bookworm image includes a 'node' user (uid 1000)
|
||||
# This reduces the attack surface by preventing container escape via root privileges
|
||||
USER node
|
||||
|
||||
# Start gateway server with default config.
|
||||
# Binds to loopback (127.0.0.1) by default for security.
|
||||
#
|
||||
# IMPORTANT: With Docker bridge networking (-p 18789:18789), loopback bind
|
||||
# makes the gateway unreachable from the host. Either:
|
||||
# - Use --network host, OR
|
||||
# - Override --bind to "lan" (0.0.0.0) and set auth credentials
|
||||
#
|
||||
# Built-in probe endpoints for container health checks:
|
||||
# - GET /healthz (liveness) and GET /readyz (readiness)
|
||||
# - aliases: /health and /ready
|
||||
# For external access from host/ingress, override bind to "lan" and set auth.
|
||||
HEALTHCHECK --interval=3m --timeout=10s --start-period=15s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:18789/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
CMD ["node", "openclaw.mjs", "gateway", "--allow-unconfigured"]
|
||||
CMD ["node", "dist/index.js"]
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM debian:bookworm-slim@sha256:98f4b71de414932439ac6ac690d7060df1f27161073c5036a7553723881bffbe
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN --mount=type=cache,id=openclaw-sandbox-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=openclaw-sandbox-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
|
||||
apt-get update \
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
ca-certificates \
|
||||
@@ -14,10 +10,7 @@ RUN --mount=type=cache,id=openclaw-sandbox-bookworm-apt-cache,target=/var/cache/
|
||||
git \
|
||||
jq \
|
||||
python3 \
|
||||
ripgrep
|
||||
|
||||
RUN useradd --create-home --shell /bin/bash sandbox
|
||||
USER sandbox
|
||||
WORKDIR /home/sandbox
|
||||
ripgrep \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
CMD ["sleep", "infinity"]
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM debian:bookworm-slim@sha256:98f4b71de414932439ac6ac690d7060df1f27161073c5036a7553723881bffbe
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN --mount=type=cache,id=openclaw-sandbox-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=openclaw-sandbox-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
|
||||
apt-get update \
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
ca-certificates \
|
||||
@@ -21,14 +17,12 @@ RUN --mount=type=cache,id=openclaw-sandbox-bookworm-apt-cache,target=/var/cache/
|
||||
socat \
|
||||
websockify \
|
||||
x11vnc \
|
||||
xvfb
|
||||
xvfb \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --chmod=755 scripts/sandbox-browser-entrypoint.sh /usr/local/bin/openclaw-sandbox-browser
|
||||
|
||||
RUN useradd --create-home --shell /bin/bash sandbox
|
||||
USER sandbox
|
||||
WORKDIR /home/sandbox
|
||||
COPY scripts/sandbox-browser-entrypoint.sh /usr/local/bin/clawdbot-sandbox-browser
|
||||
RUN chmod +x /usr/local/bin/clawdbot-sandbox-browser
|
||||
|
||||
EXPOSE 9222 5900 6080
|
||||
|
||||
CMD ["openclaw-sandbox-browser"]
|
||||
CMD ["clawdbot-sandbox-browser"]
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
ARG BASE_IMAGE=openclaw-sandbox:bookworm-slim
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
USER root
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
ARG PACKAGES="curl wget jq coreutils grep nodejs npm python3 git ca-certificates golang-go rustc cargo unzip pkg-config libasound2-dev build-essential file"
|
||||
ARG INSTALL_PNPM=1
|
||||
ARG INSTALL_BUN=1
|
||||
ARG BUN_INSTALL_DIR=/opt/bun
|
||||
ARG INSTALL_BREW=1
|
||||
ARG BREW_INSTALL_DIR=/home/linuxbrew/.linuxbrew
|
||||
ARG FINAL_USER=sandbox
|
||||
|
||||
ENV BUN_INSTALL=${BUN_INSTALL_DIR}
|
||||
ENV HOMEBREW_PREFIX=${BREW_INSTALL_DIR}
|
||||
ENV HOMEBREW_CELLAR=${BREW_INSTALL_DIR}/Cellar
|
||||
ENV HOMEBREW_REPOSITORY=${BREW_INSTALL_DIR}/Homebrew
|
||||
ENV PATH=${BUN_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/sbin:${PATH}
|
||||
|
||||
RUN --mount=type=cache,id=openclaw-sandbox-common-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=openclaw-sandbox-common-apt-lists,target=/var/lib/apt,sharing=locked \
|
||||
apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ${PACKAGES}
|
||||
|
||||
RUN if [ "${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm; fi
|
||||
|
||||
RUN if [ "${INSTALL_BUN}" = "1" ]; then \
|
||||
curl -fsSL https://bun.sh/install | bash; \
|
||||
ln -sf "${BUN_INSTALL_DIR}/bin/bun" /usr/local/bin/bun; \
|
||||
fi
|
||||
|
||||
RUN if [ "${INSTALL_BREW}" = "1" ]; then \
|
||||
if ! id -u linuxbrew >/dev/null 2>&1; then useradd -m -s /bin/bash linuxbrew; fi; \
|
||||
mkdir -p "${BREW_INSTALL_DIR}"; \
|
||||
chown -R linuxbrew:linuxbrew "$(dirname "${BREW_INSTALL_DIR}")"; \
|
||||
su - linuxbrew -c "NONINTERACTIVE=1 CI=1 /bin/bash -c '$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)'"; \
|
||||
if [ ! -e "${BREW_INSTALL_DIR}/Library" ]; then ln -s "${BREW_INSTALL_DIR}/Homebrew/Library" "${BREW_INSTALL_DIR}/Library"; fi; \
|
||||
if [ ! -x "${BREW_INSTALL_DIR}/bin/brew" ]; then echo \"brew install failed\"; exit 1; fi; \
|
||||
ln -sf "${BREW_INSTALL_DIR}/bin/brew" /usr/local/bin/brew; \
|
||||
fi
|
||||
|
||||
# Default is sandbox, but allow BASE_IMAGE overrides to select another final user.
|
||||
USER ${FINAL_USER}
|
||||
1
Peekaboo
Submodule
1
Peekaboo
Submodule
Submodule Peekaboo added at 5c195f5e46
BIN
README-header.png
Normal file
BIN
README-header.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
466
README.md
466
README.md
@@ -1,10 +1,7 @@
|
||||
# 🦞 OpenClaw — Personal AI Assistant
|
||||
# 🦞 Clawdbot — Personal AI Assistant
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<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>
|
||||
<img src="https://raw.githubusercontent.com/clawdbot/clawdbot/main/docs/whatsapp-clawd.jpg" alt="Clawdbot" width="400">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -12,50 +9,43 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/openclaw/openclaw/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/openclaw/openclaw/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
|
||||
<a href="https://github.com/openclaw/openclaw/releases"><img src="https://img.shields.io/github/v/release/openclaw/openclaw?include_prereleases&style=for-the-badge" alt="GitHub release"></a>
|
||||
<a href="https://github.com/clawdbot/clawdbot/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/clawdbot/clawdbot/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
|
||||
<a href="https://github.com/clawdbot/clawdbot/releases"><img src="https://img.shields.io/github/v/release/clawdbot/clawdbot?include_prereleases&style=for-the-badge" alt="GitHub release"></a>
|
||||
<a href="https://discord.gg/clawd"><img src="https://img.shields.io/discord/1456350064065904867?label=Discord&logo=discord&logoColor=white&color=5865F2&style=for-the-badge" alt="Discord"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
|
||||
</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, 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.
|
||||
**Clawdbot** is a *personal AI assistant* you run on your own devices.
|
||||
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat), 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) · [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)
|
||||
[Website](https://clawdbot.com) · [Docs](https://docs.clawd.bot) · [Getting Started](https://docs.clawd.bot/start/getting-started) · [Updating](https://docs.clawd.bot/install/updating) · [Showcase](https://docs.clawd.bot/start/showcase) · [FAQ](https://docs.clawd.bot/start/faq) · [Wizard](https://docs.clawd.bot/start/wizard) · [Nix](https://github.com/clawdbot/nix-clawdbot) · [Docker](https://docs.clawd.bot/install/docker) · [Discord](https://discord.gg/clawd)
|
||||
|
||||
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)**.
|
||||
Preferred setup: run the onboarding wizard (`clawdbot onboard`). It walks through 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
|
||||
|
||||
| OpenAI | Vercel | Blacksmith | Convex |
|
||||
| ----------------------------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------------- |
|
||||
| [](https://openai.com/) | [](https://vercel.com/) | [](https://blacksmith.sh/) | [](https://www.convex.dev/) |
|
||||
New install? Start here: [Getting started](https://docs.clawd.bot/start/getting-started)
|
||||
|
||||
**Subscriptions (OAuth):**
|
||||
|
||||
- **[Anthropic](https://www.anthropic.com/)** (Claude Pro/Max)
|
||||
- **[OpenAI](https://openai.com/)** (ChatGPT/Codex)
|
||||
|
||||
Model note: while many providers/models are supported, for the best experience and lower prompt-injection risk use the strongest latest-generation model available to you. See [Onboarding](https://docs.openclaw.ai/start/onboarding).
|
||||
Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.5** for long‑context strength and better prompt‑injection resistance. See [Onboarding](https://docs.clawd.bot/start/onboarding).
|
||||
|
||||
## Models (selection + auth)
|
||||
|
||||
- Models config + CLI: [Models](https://docs.openclaw.ai/concepts/models)
|
||||
- Auth profile rotation (OAuth vs API keys) + fallbacks: [Model failover](https://docs.openclaw.ai/concepts/model-failover)
|
||||
- Models config + CLI: [Models](https://docs.clawd.bot/concepts/models)
|
||||
- Auth profile rotation (OAuth vs API keys) + fallbacks: [Model failover](https://docs.clawd.bot/concepts/model-failover)
|
||||
|
||||
## Install (recommended)
|
||||
|
||||
Runtime: **Node ≥22**.
|
||||
|
||||
```bash
|
||||
npm install -g openclaw@latest
|
||||
# or: pnpm add -g openclaw@latest
|
||||
npm install -g clawdbot@latest
|
||||
# or: pnpm add -g clawdbot@latest
|
||||
|
||||
openclaw onboard --install-daemon
|
||||
clawdbot onboard --install-daemon
|
||||
```
|
||||
|
||||
The wizard installs the Gateway daemon (launchd/systemd user service) so it stays running.
|
||||
@@ -64,128 +54,112 @@ The wizard installs the Gateway daemon (launchd/systemd user service) so it stay
|
||||
|
||||
Runtime: **Node ≥22**.
|
||||
|
||||
Full beginner guide (auth, pairing, channels): [Getting started](https://docs.openclaw.ai/start/getting-started)
|
||||
Full beginner guide (auth, pairing, channels): [Getting started](https://docs.clawd.bot/start/getting-started)
|
||||
|
||||
```bash
|
||||
openclaw onboard --install-daemon
|
||||
clawdbot onboard --install-daemon
|
||||
|
||||
openclaw gateway --port 18789 --verbose
|
||||
clawdbot gateway --port 18789 --verbose
|
||||
|
||||
# Send a message
|
||||
openclaw message send --to +1234567890 --message "Hello from OpenClaw"
|
||||
clawdbot message send --to +1234567890 --message "Hello from Clawdbot"
|
||||
|
||||
# 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
|
||||
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord/Microsoft Teams)
|
||||
clawdbot agent --message "Ship checklist" --thinking high
|
||||
```
|
||||
|
||||
Upgrading? [Updating guide](https://docs.openclaw.ai/install/updating) (and run `openclaw doctor`).
|
||||
|
||||
## Development channels
|
||||
|
||||
- **stable**: tagged releases (`vYYYY.M.D` or `vYYYY.M.D-<patch>`), npm dist-tag `latest`.
|
||||
- **beta**: prerelease tags (`vYYYY.M.D-beta.N`), npm dist-tag `beta` (macOS app may be missing).
|
||||
- **dev**: moving head of `main`, npm dist-tag `dev` (when published).
|
||||
|
||||
Switch channels (git + npm): `openclaw update --channel stable|beta|dev`.
|
||||
Details: [Development channels](https://docs.openclaw.ai/install/development-channels).
|
||||
Upgrading? [Updating guide](https://docs.clawd.bot/install/updating) (and run `clawdbot doctor`).
|
||||
|
||||
## From source (development)
|
||||
|
||||
Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/openclaw/openclaw.git
|
||||
cd openclaw
|
||||
git clone https://github.com/clawdbot/clawdbot.git
|
||||
cd clawdbot
|
||||
|
||||
pnpm install
|
||||
pnpm ui:build # auto-installs UI deps on first run
|
||||
pnpm build
|
||||
|
||||
pnpm openclaw onboard --install-daemon
|
||||
pnpm clawdbot onboard --install-daemon
|
||||
|
||||
# Dev loop (auto-reload on TS changes)
|
||||
pnpm gateway:watch
|
||||
```
|
||||
|
||||
Note: `pnpm openclaw ...` runs TypeScript directly (via `tsx`). `pnpm build` produces `dist/` for running via Node / the packaged `openclaw` binary.
|
||||
Note: `pnpm clawdbot ...` runs TypeScript directly (via `tsx`). `pnpm build` produces `dist/` for running via Node / the packaged `clawdbot` binary.
|
||||
|
||||
## Security defaults (DM access)
|
||||
|
||||
OpenClaw connects to real messaging surfaces. Treat inbound DMs as **untrusted input**.
|
||||
Clawdbot connects to real messaging surfaces. Treat inbound DMs as **untrusted input**.
|
||||
|
||||
Full security guide: [Security](https://docs.openclaw.ai/gateway/security)
|
||||
Full security guide: [Security](https://docs.clawd.bot/gateway/security)
|
||||
|
||||
Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/Discord/Google Chat/Slack:
|
||||
Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/Discord/Slack:
|
||||
- **DM pairing** (`dmPolicy="pairing"` / `channels.discord.dm.policy="pairing"` / `channels.slack.dm.policy="pairing"`): unknown senders receive a short pairing code and the bot does not process their message.
|
||||
- Approve with: `clawdbot pairing approve <channel> <code>` (then the sender is added to a local allowlist store).
|
||||
- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the channel allowlist (`allowFrom` / `channels.discord.dm.allowFrom` / `channels.slack.dm.allowFrom`).
|
||||
|
||||
- **DM pairing** (`dmPolicy="pairing"` / `channels.discord.dmPolicy="pairing"` / `channels.slack.dmPolicy="pairing"`; legacy: `channels.discord.dm.policy`, `channels.slack.dm.policy`): unknown senders receive a short pairing code and the bot does not process their message.
|
||||
- Approve with: `openclaw pairing approve <channel> <code>` (then the sender is added to a local allowlist store).
|
||||
- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the channel allowlist (`allowFrom` / `channels.discord.allowFrom` / `channels.slack.allowFrom`; legacy: `channels.discord.dm.allowFrom`, `channels.slack.dm.allowFrom`).
|
||||
|
||||
Run `openclaw doctor` to surface risky/misconfigured DM policies.
|
||||
Run `clawdbot 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, 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)** — wizard-driven setup with bundled/managed/workspace skills.
|
||||
- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, channels, tools, and events.
|
||||
- **[Multi-channel inbox](https://docs.clawd.bot/channels)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat, macOS, iOS/Android.
|
||||
- **[Multi-agent routing](https://docs.clawd.bot/gateway/configuration)** — route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions).
|
||||
- **[Voice Wake](https://docs.clawd.bot/nodes/voicewake) + [Talk Mode](https://docs.clawd.bot/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
|
||||
- **[Live Canvas](https://docs.clawd.bot/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui).
|
||||
- **[First-class tools](https://docs.clawd.bot/tools)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions.
|
||||
- **[Companion apps](https://docs.clawd.bot/platforms/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.clawd.bot/nodes).
|
||||
- **[Onboarding](https://docs.clawd.bot/start/wizard) + [skills](https://docs.clawd.bot/tools/skills)** — wizard-driven setup with bundled/managed/workspace skills.
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#openclaw/openclaw&type=date&legend=top-left)
|
||||
[](https://www.star-history.com/#clawdbot/clawdbot&type=date&legend=top-left)
|
||||
|
||||
## Everything we built so far
|
||||
|
||||
### 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, [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).
|
||||
- [Gateway WS control plane](https://docs.clawd.bot/gateway) with sessions, presence, config, cron, webhooks, [Control UI](https://docs.clawd.bot/web), and [Canvas host](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui).
|
||||
- [CLI surface](https://docs.clawd.bot/tools/agent-send): gateway, agent, send, [wizard](https://docs.clawd.bot/start/wizard), and [doctor](https://docs.clawd.bot/gateway/doctor).
|
||||
- [Pi agent runtime](https://docs.clawd.bot/concepts/agent) in RPC mode with tool streaming and block streaming.
|
||||
- [Session model](https://docs.clawd.bot/concepts/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.clawd.bot/concepts/groups).
|
||||
- [Media pipeline](https://docs.clawd.bot/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/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), [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).
|
||||
- [Channels](https://docs.clawd.bot/channels): [WhatsApp](https://docs.clawd.bot/channels/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/channels/telegram) (grammY), [Slack](https://docs.clawd.bot/channels/slack) (Bolt), [Discord](https://docs.clawd.bot/channels/discord) (discord.js), [Signal](https://docs.clawd.bot/channels/signal) (signal-cli), [iMessage](https://docs.clawd.bot/channels/imessage) (imsg), [Microsoft Teams](https://docs.clawd.bot/channels/msteams) (Bot Framework), [WebChat](https://docs.clawd.bot/web/webchat).
|
||||
- [Group routing](https://docs.clawd.bot/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.clawd.bot/channels).
|
||||
|
||||
### Apps + nodes
|
||||
|
||||
- [macOS app](https://docs.openclaw.ai/platforms/macos): menu bar control plane, [Voice Wake](https://docs.openclaw.ai/nodes/voicewake)/PTT, [Talk Mode](https://docs.openclaw.ai/nodes/talk) overlay, [WebChat](https://docs.openclaw.ai/web/webchat), debug tools, [remote gateway](https://docs.openclaw.ai/gateway/remote) control.
|
||||
- [iOS node](https://docs.openclaw.ai/platforms/ios): [Canvas](https://docs.openclaw.ai/platforms/mac/canvas), [Voice Wake](https://docs.openclaw.ai/nodes/voicewake), [Talk Mode](https://docs.openclaw.ai/nodes/talk), camera, screen recording, Bonjour + device pairing.
|
||||
- [Android node](https://docs.openclaw.ai/platforms/android): Connect tab (setup code/manual), chat sessions, voice tab, [Canvas](https://docs.openclaw.ai/platforms/mac/canvas), camera/screen recording, and Android device commands (notifications/location/SMS/photos/contacts/calendar/motion/app update).
|
||||
- [macOS node mode](https://docs.openclaw.ai/nodes): system.run/notify + canvas/camera exposure.
|
||||
- [macOS app](https://docs.clawd.bot/platforms/macos): menu bar control plane, [Voice Wake](https://docs.clawd.bot/nodes/voicewake)/PTT, [Talk Mode](https://docs.clawd.bot/nodes/talk) overlay, [WebChat](https://docs.clawd.bot/web/webchat), debug tools, [remote gateway](https://docs.clawd.bot/gateway/remote) control.
|
||||
- [iOS node](https://docs.clawd.bot/platforms/ios): [Canvas](https://docs.clawd.bot/platforms/mac/canvas), [Voice Wake](https://docs.clawd.bot/nodes/voicewake), [Talk Mode](https://docs.clawd.bot/nodes/talk), camera, screen recording, Bonjour pairing.
|
||||
- [Android node](https://docs.clawd.bot/platforms/android): [Canvas](https://docs.clawd.bot/platforms/mac/canvas), [Talk Mode](https://docs.clawd.bot/nodes/talk), camera, screen recording, optional SMS.
|
||||
- [macOS node mode](https://docs.clawd.bot/nodes): system.run/notify + canvas/camera exposure.
|
||||
|
||||
### Tools + automation
|
||||
|
||||
- [Browser control](https://docs.openclaw.ai/tools/browser): dedicated openclaw Chrome/Chromium, snapshots, actions, uploads, profiles.
|
||||
- [Canvas](https://docs.openclaw.ai/platforms/mac/canvas): [A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui) push/reset, eval, snapshot.
|
||||
- [Nodes](https://docs.openclaw.ai/nodes): camera snap/clip, screen record, [location.get](https://docs.openclaw.ai/nodes/location-command), notifications.
|
||||
- [Cron + wakeups](https://docs.openclaw.ai/automation/cron-jobs); [webhooks](https://docs.openclaw.ai/automation/webhook); [Gmail Pub/Sub](https://docs.openclaw.ai/automation/gmail-pubsub).
|
||||
- [Skills platform](https://docs.openclaw.ai/tools/skills): bundled, managed, and workspace skills with install gating + UI.
|
||||
- [Browser control](https://docs.clawd.bot/tools/browser): dedicated clawd Chrome/Chromium, snapshots, actions, uploads, profiles.
|
||||
- [Canvas](https://docs.clawd.bot/platforms/mac/canvas): [A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui) push/reset, eval, snapshot.
|
||||
- [Nodes](https://docs.clawd.bot/nodes): camera snap/clip, screen record, [location.get](https://docs.clawd.bot/nodes/location-command), notifications.
|
||||
- [Cron + wakeups](https://docs.clawd.bot/automation/cron-jobs); [webhooks](https://docs.clawd.bot/automation/webhook); [Gmail Pub/Sub](https://docs.clawd.bot/automation/gmail-pubsub).
|
||||
- [Skills platform](https://docs.clawd.bot/tools/skills): bundled, managed, and workspace skills with install gating + UI.
|
||||
|
||||
### Runtime + safety
|
||||
|
||||
- [Channel routing](https://docs.openclaw.ai/channels/channel-routing), [retry policy](https://docs.openclaw.ai/concepts/retry), and [streaming/chunking](https://docs.openclaw.ai/concepts/streaming).
|
||||
- [Presence](https://docs.openclaw.ai/concepts/presence), [typing indicators](https://docs.openclaw.ai/concepts/typing-indicators), and [usage tracking](https://docs.openclaw.ai/concepts/usage-tracking).
|
||||
- [Models](https://docs.openclaw.ai/concepts/models), [model failover](https://docs.openclaw.ai/concepts/model-failover), and [session pruning](https://docs.openclaw.ai/concepts/session-pruning).
|
||||
- [Security](https://docs.openclaw.ai/gateway/security) and [troubleshooting](https://docs.openclaw.ai/channels/troubleshooting).
|
||||
- [Channel routing](https://docs.clawd.bot/concepts/channel-routing), [retry policy](https://docs.clawd.bot/concepts/retry), and [streaming/chunking](https://docs.clawd.bot/concepts/streaming).
|
||||
- [Presence](https://docs.clawd.bot/concepts/presence), [typing indicators](https://docs.clawd.bot/concepts/typing-indicators), and [usage tracking](https://docs.clawd.bot/concepts/usage-tracking).
|
||||
- [Models](https://docs.clawd.bot/concepts/models), [model failover](https://docs.clawd.bot/concepts/model-failover), and [session pruning](https://docs.clawd.bot/concepts/session-pruning).
|
||||
- [Security](https://docs.clawd.bot/gateway/security) and [troubleshooting](https://docs.clawd.bot/channels/troubleshooting).
|
||||
|
||||
### Ops + packaging
|
||||
|
||||
- [Control UI](https://docs.openclaw.ai/web) + [WebChat](https://docs.openclaw.ai/web/webchat) served directly from the Gateway.
|
||||
- [Tailscale Serve/Funnel](https://docs.openclaw.ai/gateway/tailscale) or [SSH tunnels](https://docs.openclaw.ai/gateway/remote) with token/password auth.
|
||||
- [Nix mode](https://docs.openclaw.ai/install/nix) for declarative config; [Docker](https://docs.openclaw.ai/install/docker)-based installs.
|
||||
- [Doctor](https://docs.openclaw.ai/gateway/doctor) migrations, [logging](https://docs.openclaw.ai/logging).
|
||||
- [Control UI](https://docs.clawd.bot/web) + [WebChat](https://docs.clawd.bot/web/webchat) served directly from the Gateway.
|
||||
- [Tailscale Serve/Funnel](https://docs.clawd.bot/gateway/tailscale) or [SSH tunnels](https://docs.clawd.bot/gateway/remote) with token/password auth.
|
||||
- [Nix mode](https://docs.clawd.bot/install/nix) for declarative config; [Docker](https://docs.clawd.bot/install/docker)-based installs.
|
||||
- [Doctor](https://docs.clawd.bot/gateway/doctor) migrations, [logging](https://docs.clawd.bot/logging).
|
||||
|
||||
## 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 / WebChat
|
||||
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / Microsoft Teams / WebChat
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────┐
|
||||
@@ -195,7 +169,7 @@ WhatsApp / Telegram / Slack / Discord / Google Chat / Signal / iMessage / BlueBu
|
||||
└──────────────┬────────────────┘
|
||||
│
|
||||
├─ Pi agent (RPC)
|
||||
├─ CLI (openclaw …)
|
||||
├─ CLI (clawdbot …)
|
||||
├─ WebChat UI
|
||||
├─ macOS app
|
||||
└─ iOS / Android nodes
|
||||
@@ -203,29 +177,28 @@ WhatsApp / Telegram / Slack / Discord / Google Chat / Signal / iMessage / BlueBu
|
||||
|
||||
## Key subsystems
|
||||
|
||||
- **[Gateway WebSocket network](https://docs.openclaw.ai/concepts/architecture)** — single WS control plane for clients, tools, and events (plus ops: [Gateway runbook](https://docs.openclaw.ai/gateway)).
|
||||
- **[Tailscale exposure](https://docs.openclaw.ai/gateway/tailscale)** — Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://docs.openclaw.ai/gateway/remote)).
|
||||
- **[Browser control](https://docs.openclaw.ai/tools/browser)** — openclaw‑managed Chrome/Chromium with CDP control.
|
||||
- **[Canvas + A2UI](https://docs.openclaw.ai/platforms/mac/canvas)** — agent‑driven visual workspace (A2UI host: [Canvas/A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui)).
|
||||
- **[Voice Wake](https://docs.openclaw.ai/nodes/voicewake) + [Talk Mode](https://docs.openclaw.ai/nodes/talk)** — wake words on macOS/iOS plus continuous voice on Android.
|
||||
- **[Nodes](https://docs.openclaw.ai/nodes)** — Canvas, camera snap/clip, screen record, `location.get`, notifications, plus macOS‑only `system.run`/`system.notify`.
|
||||
- **[Gateway WebSocket network](https://docs.clawd.bot/concepts/architecture)** — single WS control plane for clients, tools, and events (plus ops: [Gateway runbook](https://docs.clawd.bot/gateway)).
|
||||
- **[Tailscale exposure](https://docs.clawd.bot/gateway/tailscale)** — Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://docs.clawd.bot/gateway/remote)).
|
||||
- **[Browser control](https://docs.clawd.bot/tools/browser)** — clawd‑managed Chrome/Chromium with CDP control.
|
||||
- **[Canvas + A2UI](https://docs.clawd.bot/platforms/mac/canvas)** — agent‑driven visual workspace (A2UI host: [Canvas/A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui)).
|
||||
- **[Voice Wake](https://docs.clawd.bot/nodes/voicewake) + [Talk Mode](https://docs.clawd.bot/nodes/talk)** — always‑on speech and continuous conversation.
|
||||
- **[Nodes](https://docs.clawd.bot/nodes)** — Canvas, camera snap/clip, screen record, `location.get`, notifications, plus macOS‑only `system.run`/`system.notify`.
|
||||
|
||||
## Tailscale access (Gateway dashboard)
|
||||
|
||||
OpenClaw can auto-configure Tailscale **Serve** (tailnet-only) or **Funnel** (public) while the Gateway stays bound to loopback. Configure `gateway.tailscale.mode`:
|
||||
Clawdbot can auto-configure Tailscale **Serve** (tailnet-only) or **Funnel** (public) while the Gateway stays bound to loopback. Configure `gateway.tailscale.mode`:
|
||||
|
||||
- `off`: no Tailscale automation (default).
|
||||
- `serve`: tailnet-only HTTPS via `tailscale serve` (uses Tailscale identity headers by default).
|
||||
- `funnel`: public HTTPS via `tailscale funnel` (requires shared password auth).
|
||||
|
||||
Notes:
|
||||
|
||||
- `gateway.bind` must stay `loopback` when Serve/Funnel is enabled (OpenClaw enforces this).
|
||||
- `gateway.bind` must stay `loopback` when Serve/Funnel is enabled (Clawdbot enforces this).
|
||||
- Serve can be forced to require a password by setting `gateway.auth.mode: "password"` or `gateway.auth.allowTailscale: false`.
|
||||
- Funnel refuses to start unless `gateway.auth.mode: "password"` is set.
|
||||
- Optional: `gateway.tailscale.resetOnExit` to undo Serve/Funnel on shutdown.
|
||||
|
||||
Details: [Tailscale guide](https://docs.openclaw.ai/gateway/tailscale) · [Web surfaces](https://docs.openclaw.ai/web)
|
||||
Details: [Tailscale guide](https://docs.clawd.bot/gateway/tailscale) · [Web surfaces](https://docs.clawd.bot/web)
|
||||
|
||||
## Remote Gateway (Linux is great)
|
||||
|
||||
@@ -233,9 +206,9 @@ It’s perfectly fine to run the Gateway on a small Linux instance. Clients (mac
|
||||
|
||||
- **Gateway host** runs the exec tool and channel connections by default.
|
||||
- **Device nodes** run device‑local actions (`system.run`, camera, screen recording, notifications) via `node.invoke`.
|
||||
In short: exec runs where the Gateway lives; device actions run where the device lives.
|
||||
In short: exec runs where the Gateway lives; device actions run where the device lives.
|
||||
|
||||
Details: [Remote access](https://docs.openclaw.ai/gateway/remote) · [Nodes](https://docs.openclaw.ai/nodes) · [Security](https://docs.openclaw.ai/gateway/security)
|
||||
Details: [Remote access](https://docs.clawd.bot/gateway/remote) · [Nodes](https://docs.clawd.bot/nodes) · [Security](https://docs.clawd.bot/gateway/security)
|
||||
|
||||
## macOS permissions via the Gateway protocol
|
||||
|
||||
@@ -250,33 +223,33 @@ Elevated bash (host permissions) is separate from macOS TCC:
|
||||
- Use `/elevated on|off` to toggle per‑session elevated access when enabled + allowlisted.
|
||||
- Gateway persists the per‑session toggle via `sessions.patch` (WS method) alongside `thinkingLevel`, `verboseLevel`, `model`, `sendPolicy`, and `groupActivation`.
|
||||
|
||||
Details: [Nodes](https://docs.openclaw.ai/nodes) · [macOS app](https://docs.openclaw.ai/platforms/macos) · [Gateway protocol](https://docs.openclaw.ai/concepts/architecture)
|
||||
Details: [Nodes](https://docs.clawd.bot/nodes) · [macOS app](https://docs.clawd.bot/platforms/macos) · [Gateway protocol](https://docs.clawd.bot/concepts/architecture)
|
||||
|
||||
## Agent to Agent (sessions\_\* tools)
|
||||
## Agent to Agent (sessions_* tools)
|
||||
|
||||
- Use these to coordinate work across sessions without jumping between chat surfaces.
|
||||
- `sessions_list` — discover active sessions (agents) and their metadata.
|
||||
- `sessions_history` — fetch transcript logs for a session.
|
||||
- `sessions_send` — message another session; optional reply‑back ping‑pong + announce step (`REPLY_SKIP`, `ANNOUNCE_SKIP`).
|
||||
|
||||
Details: [Session tools](https://docs.openclaw.ai/concepts/session-tool)
|
||||
Details: [Session tools](https://docs.clawd.bot/concepts/session-tool)
|
||||
|
||||
## Skills registry (ClawHub)
|
||||
## Skills registry (ClawdHub)
|
||||
|
||||
ClawHub is a minimal skill registry. With ClawHub enabled, the agent can search for skills automatically and pull in new ones as needed.
|
||||
ClawdHub is a minimal skill registry. With ClawdHub enabled, the agent can search for skills automatically and pull in new ones as needed.
|
||||
|
||||
[ClawHub](https://clawhub.com)
|
||||
[ClawdHub](https://ClawdHub.com)
|
||||
|
||||
## Chat commands
|
||||
|
||||
Send these in WhatsApp/Telegram/Slack/Google Chat/Microsoft Teams/WebChat (group commands are owner-only):
|
||||
Send these in WhatsApp/Telegram/Slack/Microsoft Teams/WebChat (group commands are owner-only):
|
||||
|
||||
- `/status` — compact session status (model + tokens, cost when available)
|
||||
- `/new` or `/reset` — reset the session
|
||||
- `/compact` — compact session context (summary)
|
||||
- `/think <level>` — off|minimal|low|medium|high|xhigh (GPT-5.2 + Codex models only)
|
||||
- `/verbose on|off`
|
||||
- `/usage off|tokens|full` — per-response usage footer
|
||||
- `/cost on|off` — append per-response token/cost usage lines
|
||||
- `/restart` — restart the gateway (owner-only in groups)
|
||||
- `/activation mention|always` — group activation toggle (groups only)
|
||||
|
||||
@@ -284,9 +257,14 @@ Send these in WhatsApp/Telegram/Slack/Google Chat/Microsoft Teams/WebChat (group
|
||||
|
||||
The Gateway alone delivers a great experience. All apps are optional and add extra features.
|
||||
|
||||
If you plan to build/run companion apps, follow the platform runbooks below.
|
||||
If you plan to build/run companion apps, initialize submodules first:
|
||||
|
||||
### macOS (OpenClaw.app) (optional)
|
||||
```bash
|
||||
git submodule update --init --recursive
|
||||
./scripts/restart-mac.sh
|
||||
```
|
||||
|
||||
### macOS (Clawdbot.app) (optional)
|
||||
|
||||
- Menu bar control for the Gateway and health.
|
||||
- Voice Wake + push-to-talk overlay.
|
||||
@@ -297,37 +275,37 @@ Note: signed builds required for macOS permissions to stick across rebuilds (see
|
||||
|
||||
### iOS node (optional)
|
||||
|
||||
- Pairs as a node over the Gateway WebSocket (device pairing).
|
||||
- Pairs as a node via the Bridge.
|
||||
- Voice trigger forwarding + Canvas surface.
|
||||
- Controlled via `openclaw nodes …`.
|
||||
- Controlled via `clawdbot nodes …`.
|
||||
|
||||
Runbook: [iOS connect](https://docs.openclaw.ai/platforms/ios).
|
||||
Runbook: [iOS connect](https://docs.clawd.bot/platforms/ios).
|
||||
|
||||
### Android node (optional)
|
||||
|
||||
- Pairs as a WS node via device pairing (`openclaw devices ...`).
|
||||
- Exposes Connect/Chat/Voice tabs plus Canvas, Camera, Screen capture, and Android device command families.
|
||||
- Runbook: [Android connect](https://docs.openclaw.ai/platforms/android).
|
||||
- Pairs via the same Bridge + pairing flow as iOS.
|
||||
- Exposes Canvas, Camera, and Screen capture commands.
|
||||
- Runbook: [Android connect](https://docs.clawd.bot/platforms/android).
|
||||
|
||||
## Agent workspace + skills
|
||||
|
||||
- Workspace root: `~/.openclaw/workspace` (configurable via `agents.defaults.workspace`).
|
||||
- Workspace root: `~/clawd` (configurable via `agents.defaults.workspace`).
|
||||
- Injected prompt files: `AGENTS.md`, `SOUL.md`, `TOOLS.md`.
|
||||
- Skills: `~/.openclaw/workspace/skills/<skill>/SKILL.md`.
|
||||
- Skills: `~/clawd/skills/<skill>/SKILL.md`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Minimal `~/.openclaw/openclaw.json` (model + defaults):
|
||||
Minimal `~/.clawdbot/clawdbot.json` (model + defaults):
|
||||
|
||||
```json5
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
},
|
||||
model: "anthropic/claude-opus-4-5"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
[Full configuration reference (all keys + examples).](https://docs.openclaw.ai/gateway/configuration)
|
||||
[Full configuration reference (all keys + examples).](https://docs.clawd.bot/gateway/configuration)
|
||||
|
||||
## Security model (important)
|
||||
|
||||
@@ -335,69 +313,63 @@ Minimal `~/.openclaw/openclaw.json` (model + defaults):
|
||||
- **Group/channel safety:** set `agents.defaults.sandbox.mode: "non-main"` to run **non‑main sessions** (groups/channels) inside per‑session Docker sandboxes; bash then runs in Docker for those sessions.
|
||||
- **Sandbox defaults:** allowlist `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`; denylist `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`.
|
||||
|
||||
Details: [Security guide](https://docs.openclaw.ai/gateway/security) · [Docker + sandboxing](https://docs.openclaw.ai/install/docker) · [Sandbox config](https://docs.openclaw.ai/gateway/configuration)
|
||||
Details: [Security guide](https://docs.clawd.bot/gateway/security) · [Docker + sandboxing](https://docs.clawd.bot/install/docker) · [Sandbox config](https://docs.clawd.bot/gateway/configuration)
|
||||
|
||||
### [WhatsApp](https://docs.openclaw.ai/channels/whatsapp)
|
||||
### [WhatsApp](https://docs.clawd.bot/channels/whatsapp)
|
||||
|
||||
- Link the device: `pnpm openclaw channels login` (stores creds in `~/.openclaw/credentials`).
|
||||
- Link the device: `pnpm clawdbot channels login` (stores creds in `~/.clawdbot/credentials`).
|
||||
- Allowlist who can talk to the assistant via `channels.whatsapp.allowFrom`.
|
||||
- If `channels.whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
||||
|
||||
### [Telegram](https://docs.openclaw.ai/channels/telegram)
|
||||
### [Telegram](https://docs.clawd.bot/channels/telegram)
|
||||
|
||||
- Set `TELEGRAM_BOT_TOKEN` or `channels.telegram.botToken` (env wins).
|
||||
- Optional: set `channels.telegram.groups` (with `channels.telegram.groups."*".requireMention`); when set, it is a group allowlist (include `"*"` to allow all). Also `channels.telegram.allowFrom` or `channels.telegram.webhookUrl` + `channels.telegram.webhookSecret` as needed.
|
||||
- Optional: set `channels.telegram.groups` (with `channels.telegram.groups."*".requireMention`); when set, it is a group allowlist (include `"*"` to allow all). Also `channels.telegram.allowFrom` or `channels.telegram.webhookUrl` as needed.
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "123456:ABCDEF",
|
||||
},
|
||||
},
|
||||
botToken: "123456:ABCDEF"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### [Slack](https://docs.openclaw.ai/channels/slack)
|
||||
### [Slack](https://docs.clawd.bot/channels/slack)
|
||||
|
||||
- Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` (or `channels.slack.botToken` + `channels.slack.appToken`).
|
||||
|
||||
### [Discord](https://docs.openclaw.ai/channels/discord)
|
||||
### [Discord](https://docs.clawd.bot/channels/discord)
|
||||
|
||||
- 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.
|
||||
- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `channels.discord.dm.allowFrom`, `channels.discord.guilds`, or `channels.discord.mediaMaxMb` as needed.
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
discord: {
|
||||
token: "1234abcd",
|
||||
},
|
||||
},
|
||||
token: "1234abcd"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### [Signal](https://docs.openclaw.ai/channels/signal)
|
||||
### [Signal](https://docs.clawd.bot/channels/signal)
|
||||
|
||||
- Requires `signal-cli` and a `channels.signal` config section.
|
||||
|
||||
### [BlueBubbles (iMessage)](https://docs.openclaw.ai/channels/bluebubbles)
|
||||
### [iMessage](https://docs.clawd.bot/channels/imessage)
|
||||
|
||||
- **Recommended** iMessage integration.
|
||||
- Configure `channels.bluebubbles.serverUrl` + `channels.bluebubbles.password` and a webhook (`channels.bluebubbles.webhookPath`).
|
||||
- The BlueBubbles server runs on macOS; the Gateway can run on macOS or elsewhere.
|
||||
|
||||
### [iMessage (legacy)](https://docs.openclaw.ai/channels/imessage)
|
||||
|
||||
- Legacy macOS-only integration via `imsg` (Messages must be signed in).
|
||||
- macOS only; Messages must be signed in.
|
||||
- If `channels.imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
||||
|
||||
### [Microsoft Teams](https://docs.openclaw.ai/channels/msteams)
|
||||
### [Microsoft Teams](https://docs.clawd.bot/channels/msteams)
|
||||
|
||||
- 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"`.
|
||||
|
||||
### [WebChat](https://docs.openclaw.ai/web/webchat)
|
||||
### [WebChat](https://docs.clawd.bot/web/webchat)
|
||||
|
||||
- Uses the Gateway WebSocket; no separate WebChat port/config.
|
||||
|
||||
@@ -407,153 +379,119 @@ Browser control (optional):
|
||||
{
|
||||
browser: {
|
||||
enabled: true,
|
||||
color: "#FF4500",
|
||||
},
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
color: "#FF4500"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Docs
|
||||
|
||||
Use these when you’re past the onboarding flow and want the deeper reference.
|
||||
|
||||
- [Start with the docs index for navigation and “what’s where.”](https://docs.openclaw.ai)
|
||||
- [Read the architecture overview for the gateway + protocol model.](https://docs.openclaw.ai/concepts/architecture)
|
||||
- [Use the full configuration reference when you need every key and example.](https://docs.openclaw.ai/gateway/configuration)
|
||||
- [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 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)
|
||||
- [Platform guides: Windows (WSL2)](https://docs.openclaw.ai/platforms/windows), [Linux](https://docs.openclaw.ai/platforms/linux), [macOS](https://docs.openclaw.ai/platforms/macos), [iOS](https://docs.openclaw.ai/platforms/ios), [Android](https://docs.openclaw.ai/platforms/android)
|
||||
- [Debug common failures with the troubleshooting guide.](https://docs.openclaw.ai/channels/troubleshooting)
|
||||
- [Review security guidance before exposing anything.](https://docs.openclaw.ai/gateway/security)
|
||||
- [Start with the docs index for navigation and “what’s where.”](https://docs.clawd.bot)
|
||||
- [Read the architecture overview for the gateway + protocol model.](https://docs.clawd.bot/concepts/architecture)
|
||||
- [Use the full configuration reference when you need every key and example.](https://docs.clawd.bot/gateway/configuration)
|
||||
- [Run the Gateway by the book with the operational runbook.](https://docs.clawd.bot/gateway)
|
||||
- [Learn how the Control UI/Web surfaces work and how to expose them safely.](https://docs.clawd.bot/web)
|
||||
- [Understand remote access over SSH tunnels or tailnets.](https://docs.clawd.bot/gateway/remote)
|
||||
- [Follow the onboarding wizard flow for a guided setup.](https://docs.clawd.bot/start/wizard)
|
||||
- [Wire external triggers via the webhook surface.](https://docs.clawd.bot/automation/webhook)
|
||||
- [Set up Gmail Pub/Sub triggers.](https://docs.clawd.bot/automation/gmail-pubsub)
|
||||
- [Learn the macOS menu bar companion details.](https://docs.clawd.bot/platforms/mac/menu-bar)
|
||||
- [Platform guides: Windows (WSL2)](https://docs.clawd.bot/platforms/windows), [Linux](https://docs.clawd.bot/platforms/linux), [macOS](https://docs.clawd.bot/platforms/macos), [iOS](https://docs.clawd.bot/platforms/ios), [Android](https://docs.clawd.bot/platforms/android)
|
||||
- [Debug common failures with the troubleshooting guide.](https://docs.clawd.bot/channels/troubleshooting)
|
||||
- [Review security guidance before exposing anything.](https://docs.clawd.bot/gateway/security)
|
||||
|
||||
## Advanced docs (discovery + control)
|
||||
|
||||
- [Discovery + transports](https://docs.openclaw.ai/gateway/discovery)
|
||||
- [Bonjour/mDNS](https://docs.openclaw.ai/gateway/bonjour)
|
||||
- [Gateway pairing](https://docs.openclaw.ai/gateway/pairing)
|
||||
- [Remote gateway README](https://docs.openclaw.ai/gateway/remote-gateway-readme)
|
||||
- [Control UI](https://docs.openclaw.ai/web/control-ui)
|
||||
- [Dashboard](https://docs.openclaw.ai/web/dashboard)
|
||||
- [Discovery + transports](https://docs.clawd.bot/gateway/discovery)
|
||||
- [Bonjour/mDNS](https://docs.clawd.bot/gateway/bonjour)
|
||||
- [Gateway pairing](https://docs.clawd.bot/gateway/pairing)
|
||||
- [Remote gateway README](https://docs.clawd.bot/gateway/remote-gateway-readme)
|
||||
- [Control UI](https://docs.clawd.bot/web/control-ui)
|
||||
- [Dashboard](https://docs.clawd.bot/web/dashboard)
|
||||
|
||||
## Operations & troubleshooting
|
||||
|
||||
- [Health checks](https://docs.openclaw.ai/gateway/health)
|
||||
- [Gateway lock](https://docs.openclaw.ai/gateway/gateway-lock)
|
||||
- [Background process](https://docs.openclaw.ai/gateway/background-process)
|
||||
- [Browser troubleshooting (Linux)](https://docs.openclaw.ai/tools/browser-linux-troubleshooting)
|
||||
- [Logging](https://docs.openclaw.ai/logging)
|
||||
- [Health checks](https://docs.clawd.bot/gateway/health)
|
||||
- [Gateway lock](https://docs.clawd.bot/gateway/gateway-lock)
|
||||
- [Background process](https://docs.clawd.bot/gateway/background-process)
|
||||
- [Browser troubleshooting (Linux)](https://docs.clawd.bot/tools/browser-linux-troubleshooting)
|
||||
- [Logging](https://docs.clawd.bot/logging)
|
||||
|
||||
## Deep dives
|
||||
|
||||
- [Agent loop](https://docs.openclaw.ai/concepts/agent-loop)
|
||||
- [Presence](https://docs.openclaw.ai/concepts/presence)
|
||||
- [TypeBox schemas](https://docs.openclaw.ai/concepts/typebox)
|
||||
- [RPC adapters](https://docs.openclaw.ai/reference/rpc)
|
||||
- [Queue](https://docs.openclaw.ai/concepts/queue)
|
||||
- [Agent loop](https://docs.clawd.bot/concepts/agent-loop)
|
||||
- [Presence](https://docs.clawd.bot/concepts/presence)
|
||||
- [TypeBox schemas](https://docs.clawd.bot/concepts/typebox)
|
||||
- [RPC adapters](https://docs.clawd.bot/reference/rpc)
|
||||
- [Queue](https://docs.clawd.bot/concepts/queue)
|
||||
|
||||
## Workspace & skills
|
||||
|
||||
- [Skills config](https://docs.openclaw.ai/tools/skills-config)
|
||||
- [Default AGENTS](https://docs.openclaw.ai/reference/AGENTS.default)
|
||||
- [Templates: AGENTS](https://docs.openclaw.ai/reference/templates/AGENTS)
|
||||
- [Templates: BOOTSTRAP](https://docs.openclaw.ai/reference/templates/BOOTSTRAP)
|
||||
- [Templates: IDENTITY](https://docs.openclaw.ai/reference/templates/IDENTITY)
|
||||
- [Templates: SOUL](https://docs.openclaw.ai/reference/templates/SOUL)
|
||||
- [Templates: TOOLS](https://docs.openclaw.ai/reference/templates/TOOLS)
|
||||
- [Templates: USER](https://docs.openclaw.ai/reference/templates/USER)
|
||||
- [Skills config](https://docs.clawd.bot/tools/skills-config)
|
||||
- [Default AGENTS](https://docs.clawd.bot/reference/AGENTS.default)
|
||||
- [Templates: AGENTS](https://docs.clawd.bot/reference/templates/AGENTS)
|
||||
- [Templates: BOOTSTRAP](https://docs.clawd.bot/reference/templates/BOOTSTRAP)
|
||||
- [Templates: IDENTITY](https://docs.clawd.bot/reference/templates/IDENTITY)
|
||||
- [Templates: SOUL](https://docs.clawd.bot/reference/templates/SOUL)
|
||||
- [Templates: TOOLS](https://docs.clawd.bot/reference/templates/TOOLS)
|
||||
- [Templates: USER](https://docs.clawd.bot/reference/templates/USER)
|
||||
|
||||
## Platform internals
|
||||
|
||||
- [macOS dev setup](https://docs.openclaw.ai/platforms/mac/dev-setup)
|
||||
- [macOS menu bar](https://docs.openclaw.ai/platforms/mac/menu-bar)
|
||||
- [macOS voice wake](https://docs.openclaw.ai/platforms/mac/voicewake)
|
||||
- [iOS node](https://docs.openclaw.ai/platforms/ios)
|
||||
- [Android node](https://docs.openclaw.ai/platforms/android)
|
||||
- [Windows (WSL2)](https://docs.openclaw.ai/platforms/windows)
|
||||
- [Linux app](https://docs.openclaw.ai/platforms/linux)
|
||||
- [macOS dev setup](https://docs.clawd.bot/platforms/mac/dev-setup)
|
||||
- [macOS menu bar](https://docs.clawd.bot/platforms/mac/menu-bar)
|
||||
- [macOS voice wake](https://docs.clawd.bot/platforms/mac/voicewake)
|
||||
- [iOS node](https://docs.clawd.bot/platforms/ios)
|
||||
- [Android node](https://docs.clawd.bot/platforms/android)
|
||||
- [Windows (WSL2)](https://docs.clawd.bot/platforms/windows)
|
||||
- [Linux app](https://docs.clawd.bot/platforms/linux)
|
||||
|
||||
## Email hooks (Gmail)
|
||||
|
||||
- [docs.openclaw.ai/gmail-pubsub](https://docs.openclaw.ai/automation/gmail-pubsub)
|
||||
- [docs.clawd.bot/gmail-pubsub](https://docs.clawd.bot/automation/gmail-pubsub)
|
||||
|
||||
## Molty
|
||||
## Clawd
|
||||
|
||||
OpenClaw was built for **Molty**, a space lobster AI assistant. 🦞
|
||||
Clawdbot was built for **Clawd**, a space lobster AI assistant. 🦞
|
||||
by Peter Steinberger and the community.
|
||||
|
||||
- [openclaw.ai](https://openclaw.ai)
|
||||
- [clawd.me](https://clawd.me)
|
||||
- [soul.md](https://soul.md)
|
||||
- [steipete.me](https://steipete.me)
|
||||
- [@openclaw](https://x.com/openclaw)
|
||||
|
||||
## Community
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
|
||||
AI/vibe-coded PRs welcome! 🤖
|
||||
|
||||
Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and for
|
||||
[pi-mono](https://github.com/badlogic/pi-mono).
|
||||
Special thanks to Adam Doppelt for lobster.bot.
|
||||
Special thanks to @andrewting19 for the Anthropic OAuth tool-name fix.
|
||||
|
||||
Core contributors:
|
||||
- @cpojer — Telegram onboarding UX + docs
|
||||
|
||||
Thanks to all clawtributors:
|
||||
|
||||
<p align="left">
|
||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/vincentkoc"><img src="https://avatars.githubusercontent.com/u/25068?v=4&s=48" width="48" height="48" alt="vincentkoc" title="vincentkoc"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a>
|
||||
<a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/Glucksberg"><img src="https://avatars.githubusercontent.com/u/80581902?v=4&s=48" width="48" height="48" alt="Glucksberg" title="Glucksberg"/></a> <a href="https://github.com/mcaxtr"><img src="https://avatars.githubusercontent.com/u/7562095?v=4&s=48" width="48" height="48" alt="mcaxtr" title="mcaxtr"/></a> <a href="https://github.com/quotentiroler"><img src="https://avatars.githubusercontent.com/u/40643627?v=4&s=48" width="48" height="48" alt="quotentiroler" title="quotentiroler"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/Sid-Qin"><img src="https://avatars.githubusercontent.com/u/201593046?v=4&s=48" width="48" height="48" alt="Sid-Qin" title="Sid-Qin"/></a> <a href="https://github.com/joshavant"><img src="https://avatars.githubusercontent.com/u/830519?v=4&s=48" width="48" height="48" alt="joshavant" title="joshavant"/></a> <a href="https://github.com/shakkernerd"><img src="https://avatars.githubusercontent.com/u/165377636?v=4&s=48" width="48" height="48" alt="shakkernerd" title="shakkernerd"/></a> <a href="https://github.com/bmendonca3"><img src="https://avatars.githubusercontent.com/u/208517100?v=4&s=48" width="48" height="48" alt="bmendonca3" title="bmendonca3"/></a>
|
||||
<a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/lailoo"><img src="https://avatars.githubusercontent.com/u/20536249?v=4&s=48" width="48" height="48" alt="lailoo" title="lailoo"/></a> <a href="https://github.com/arosstale"><img src="https://avatars.githubusercontent.com/u/117890364?v=4&s=48" width="48" height="48" alt="arosstale" title="arosstale"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a> <a href="https://github.com/0xRaini"><img src="https://avatars.githubusercontent.com/u/190923101?v=4&s=48" width="48" height="48" alt="Elonito" title="Elonito"/></a> <a href="https://github.com/Clawborn"><img src="https://avatars.githubusercontent.com/u/261310391?v=4&s=48" width="48" height="48" alt="Clawborn" title="Clawborn"/></a>
|
||||
<a href="https://github.com/yinghaosang"><img src="https://avatars.githubusercontent.com/u/261132136?v=4&s=48" width="48" height="48" alt="yinghaosang" title="yinghaosang"/></a> <a href="https://github.com/BunsDev"><img src="https://avatars.githubusercontent.com/u/68980965?v=4&s=48" width="48" height="48" alt="BunsDev" title="BunsDev"/></a> <a href="https://github.com/christianklotz"><img src="https://avatars.githubusercontent.com/u/69443?v=4&s=48" width="48" height="48" alt="christianklotz" title="christianklotz"/></a> <a href="https://github.com/echoVic"><img src="https://avatars.githubusercontent.com/u/16428813?v=4&s=48" width="48" height="48" alt="echoVic" title="echoVic"/></a> <a href="https://github.com/coygeek"><img src="https://avatars.githubusercontent.com/u/65363919?v=4&s=48" width="48" height="48" alt="coygeek" title="coygeek"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a>
|
||||
<a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/VeriteIgiraneza"><img src="https://avatars.githubusercontent.com/u/69280208?v=4&s=48" width="48" height="48" alt="Verite Igiraneza" title="Verite Igiraneza"/></a> <a href="https://github.com/widingmarcus-cyber"><img src="https://avatars.githubusercontent.com/u/245375637?v=4&s=48" width="48" height="48" alt="widingmarcus-cyber" title="widingmarcus-cyber"/></a> <a href="https://github.com/akramcodez"><img src="https://avatars.githubusercontent.com/u/179671552?v=4&s=48" width="48" height="48" alt="akramcodez" title="akramcodez"/></a> <a href="https://github.com/aether-ai-agent"><img src="https://avatars.githubusercontent.com/u/261339948?v=4&s=48" width="48" height="48" alt="aether-ai-agent" title="aether-ai-agent"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chilu18"><img src="https://avatars.githubusercontent.com/u/7957943?v=4&s=48" width="48" height="48" alt="chilu18" title="chilu18"/></a> <a href="https://github.com/byungsker"><img src="https://avatars.githubusercontent.com/u/72309817?v=4&s=48" width="48" height="48" alt="byungsker" title="byungsker"/></a>
|
||||
<a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/JayMishra-source"><img src="https://avatars.githubusercontent.com/u/82963117?v=4&s=48" width="48" height="48" alt="JayMishra-source" title="JayMishra-source"/></a> <a href="https://github.com/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></a> <a href="https://github.com/mudrii"><img src="https://avatars.githubusercontent.com/u/220262?v=4&s=48" width="48" height="48" alt="mudrii" title="mudrii"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/Solvely-Colin"><img src="https://avatars.githubusercontent.com/u/211764741?v=4&s=48" width="48" height="48" alt="Solvely-Colin" title="Solvely-Colin"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/advaitpaliwal"><img src="https://avatars.githubusercontent.com/u/66044327?v=4&s=48" width="48" height="48" alt="advaitpaliwal" title="advaitpaliwal"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a>
|
||||
<a href="https://github.com/HenryLoenwind"><img src="https://avatars.githubusercontent.com/u/1485873?v=4&s=48" width="48" height="48" alt="HenryLoenwind" title="HenryLoenwind"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/Lukavyi"><img src="https://avatars.githubusercontent.com/u/1013690?v=4&s=48" width="48" height="48" alt="Lukavyi" title="Lukavyi"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/brandonwise"><img src="https://avatars.githubusercontent.com/u/21148772?v=4&s=48" width="48" height="48" alt="brandonwise" title="brandonwise"/></a> <a href="https://github.com/conroywhitney"><img src="https://avatars.githubusercontent.com/u/249891?v=4&s=48" width="48" height="48" alt="conroywhitney" title="conroywhitney"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/davidrudduck"><img src="https://avatars.githubusercontent.com/u/47308254?v=4&s=48" width="48" height="48" alt="davidrudduck" title="davidrudduck"/></a> <a href="https://github.com/xinhuagu"><img src="https://avatars.githubusercontent.com/u/562450?v=4&s=48" width="48" height="48" alt="xinhuagu" title="xinhuagu"/></a> <a href="https://github.com/jaydenfyi"><img src="https://avatars.githubusercontent.com/u/213395523?v=4&s=48" width="48" height="48" alt="jaydenfyi" title="jaydenfyi"/></a>
|
||||
<a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/heyhudson"><img src="https://avatars.githubusercontent.com/u/258693705?v=4&s=48" width="48" height="48" alt="heyhudson" title="heyhudson"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/huntharo"><img src="https://avatars.githubusercontent.com/u/5617868?v=4&s=48" width="48" height="48" alt="huntharo" title="huntharo"/></a> <a href="https://github.com/omair445"><img src="https://avatars.githubusercontent.com/u/32237905?v=4&s=48" width="48" height="48" alt="omair445" title="omair445"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/adhitShet"><img src="https://avatars.githubusercontent.com/u/131381638?v=4&s=48" width="48" height="48" alt="adhitShet" title="adhitShet"/></a> <a href="https://github.com/smartprogrammer93"><img src="https://avatars.githubusercontent.com/u/33181301?v=4&s=48" width="48" height="48" alt="smartprogrammer93" title="smartprogrammer93"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/frankekn"><img src="https://avatars.githubusercontent.com/u/4488090?v=4&s=48" width="48" height="48" alt="frankekn" title="frankekn"/></a>
|
||||
<a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/shadril238"><img src="https://avatars.githubusercontent.com/u/63901551?v=4&s=48" width="48" height="48" alt="shadril238" title="shadril238"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/jonisjongithub"><img src="https://avatars.githubusercontent.com/u/86072337?v=4&s=48" width="48" height="48" alt="jonisjongithub" title="jonisjongithub"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/stakeswky"><img src="https://avatars.githubusercontent.com/u/64798754?v=4&s=48" width="48" height="48" alt="stakeswky" title="stakeswky"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/MisterGuy420"><img src="https://avatars.githubusercontent.com/u/255743668?v=4&s=48" width="48" height="48" alt="MisterGuy420" title="MisterGuy420"/></a>
|
||||
<a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/nabbilkhan"><img src="https://avatars.githubusercontent.com/u/203121263?v=4&s=48" width="48" height="48" alt="nabbilkhan" title="nabbilkhan"/></a> <a href="https://github.com/aldoeliacim"><img src="https://avatars.githubusercontent.com/u/17973757?v=4&s=48" width="48" height="48" alt="aldoeliacim" title="aldoeliacim"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/orlyjamie"><img src="https://avatars.githubusercontent.com/u/6668807?v=4&s=48" width="48" height="48" alt="orlyjamie" title="orlyjamie"/></a> <a href="https://github.com/Elarwei001"><img src="https://avatars.githubusercontent.com/u/168552401?v=4&s=48" width="48" height="48" alt="Elarwei001" title="Elarwei001"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/Phineas1500"><img src="https://avatars.githubusercontent.com/u/41450967?v=4&s=48" width="48" height="48" alt="Phineas1500" title="Phineas1500"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/sfo2001"><img src="https://avatars.githubusercontent.com/u/103369858?v=4&s=48" width="48" height="48" alt="sfo2001" title="sfo2001"/></a>
|
||||
<a href="https://github.com/Marvae"><img src="https://avatars.githubusercontent.com/u/11957602?v=4&s=48" width="48" height="48" alt="Marvae" title="Marvae"/></a> <a href="https://github.com/liuy"><img src="https://avatars.githubusercontent.com/u/1192888?v=4&s=48" width="48" height="48" alt="liuy" title="liuy"/></a> <a href="https://github.com/shtse8"><img src="https://avatars.githubusercontent.com/u/8020099?v=4&s=48" width="48" height="48" alt="shtse8" title="shtse8"/></a> <a href="https://github.com/thebenignhacker"><img src="https://avatars.githubusercontent.com/u/32418586?v=4&s=48" width="48" height="48" alt="thebenignhacker" title="thebenignhacker"/></a> <a href="https://github.com/carrotRakko"><img src="https://avatars.githubusercontent.com/u/24588751?v=4&s=48" width="48" height="48" alt="carrotRakko" title="carrotRakko"/></a> <a href="https://github.com/ranausmanai"><img src="https://avatars.githubusercontent.com/u/257128159?v=4&s=48" width="48" height="48" alt="ranausmanai" title="ranausmanai"/></a> <a href="https://github.com/kevinWangSheng"><img src="https://avatars.githubusercontent.com/u/118158941?v=4&s=48" width="48" height="48" alt="kevinWangSheng" title="kevinWangSheng"/></a> <a href="https://github.com/gregmousseau"><img src="https://avatars.githubusercontent.com/u/5036458?v=4&s=48" width="48" height="48" alt="gregmousseau" title="gregmousseau"/></a> <a href="https://github.com/rrenamed"><img src="https://avatars.githubusercontent.com/u/87486610?v=4&s=48" width="48" height="48" alt="rrenamed" title="rrenamed"/></a> <a href="https://github.com/akoscz"><img src="https://avatars.githubusercontent.com/u/1360047?v=4&s=48" width="48" height="48" alt="akoscz" title="akoscz"/></a>
|
||||
<a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/pandego"><img src="https://avatars.githubusercontent.com/u/7780875?v=4&s=48" width="48" height="48" alt="pandego" title="pandego"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/graysurf"><img src="https://avatars.githubusercontent.com/u/10785178?v=4&s=48" width="48" height="48" alt="graysurf" title="graysurf"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/nyanjou"><img src="https://avatars.githubusercontent.com/u/258645604?v=4&s=48" width="48" height="48" alt="nyanjou" title="nyanjou"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/gejifeng"><img src="https://avatars.githubusercontent.com/u/17561857?v=4&s=48" width="48" height="48" alt="gejifeng" title="gejifeng"/></a>
|
||||
<a href="https://github.com/ide-rea"><img src="https://avatars.githubusercontent.com/u/30512600?v=4&s=48" width="48" height="48" alt="ide-rea" title="ide-rea"/></a> <a href="https://github.com/leszekszpunar"><img src="https://avatars.githubusercontent.com/u/13106764?v=4&s=48" width="48" height="48" alt="leszekszpunar" title="leszekszpunar"/></a> <a href="https://github.com/Yida-Dev"><img src="https://avatars.githubusercontent.com/u/92713555?v=4&s=48" width="48" height="48" alt="Yida-Dev" title="Yida-Dev"/></a> <a href="https://github.com/AI-Reviewer-QS"><img src="https://avatars.githubusercontent.com/u/255312808?v=4&s=48" width="48" height="48" alt="AI-Reviewer-QS" title="AI-Reviewer-QS"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/hougangdev"><img src="https://avatars.githubusercontent.com/u/105773686?v=4&s=48" width="48" height="48" alt="hougangdev" title="hougangdev"/></a> <a href="https://github.com/Minidoracat"><img src="https://avatars.githubusercontent.com/u/11269639?v=4&s=48" width="48" height="48" alt="Minidoracat" title="Minidoracat"/></a> <a href="https://github.com/AnonO6"><img src="https://avatars.githubusercontent.com/u/124311066?v=4&s=48" width="48" height="48" alt="AnonO6" title="AnonO6"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a>
|
||||
<a href="https://github.com/YuzuruS"><img src="https://avatars.githubusercontent.com/u/1485195?v=4&s=48" width="48" height="48" alt="YuzuruS" title="YuzuruS"/></a> <a href="https://github.com/riccardogiorato"><img src="https://avatars.githubusercontent.com/u/4527364?v=4&s=48" width="48" height="48" alt="riccardogiorato" title="riccardogiorato"/></a> <a href="https://github.com/Bridgerz"><img src="https://avatars.githubusercontent.com/u/24499532?v=4&s=48" width="48" height="48" alt="Bridgerz" title="Bridgerz"/></a> <a href="https://github.com/Mrseenz"><img src="https://avatars.githubusercontent.com/u/101962919?v=4&s=48" width="48" height="48" alt="Mrseenz" title="Mrseenz"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a>
|
||||
<a href="https://github.com/buerbaumer"><img src="https://avatars.githubusercontent.com/u/44548809?v=4&s=48" width="48" height="48" alt="Harald Buerbaumer" title="Harald Buerbaumer"/></a> <a href="https://github.com/taw0002"><img src="https://avatars.githubusercontent.com/u/42811278?v=4&s=48" width="48" height="48" alt="taw0002" title="taw0002"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/openperf"><img src="https://avatars.githubusercontent.com/u/80630709?v=4&s=48" width="48" height="48" alt="openperf" title="openperf"/></a> <a href="https://github.com/BUGKillerKing"><img src="https://avatars.githubusercontent.com/u/117326392?v=4&s=48" width="48" height="48" alt="BUGKillerKing" title="BUGKillerKing"/></a> <a href="https://github.com/Oceanswave"><img src="https://avatars.githubusercontent.com/u/760674?v=4&s=48" width="48" height="48" alt="Oceanswave" title="Oceanswave"/></a> <a href="https://github.com/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="Hiren Patel" title="Hiren Patel"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a>
|
||||
<a href="https://github.com/jadilson12"><img src="https://avatars.githubusercontent.com/u/36805474?v=4&s=48" width="48" height="48" alt="jadilson12" title="jadilson12"/></a> <a href="https://github.com/sumleo"><img src="https://avatars.githubusercontent.com/u/29517764?v=4&s=48" width="48" height="48" alt="sumleo" title="sumleo"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/luijoc"><img src="https://avatars.githubusercontent.com/u/96428056?v=4&s=48" width="48" height="48" alt="luijoc" title="luijoc"/></a> <a href="https://github.com/niceysam"><img src="https://avatars.githubusercontent.com/u/256747835?v=4&s=48" width="48" height="48" alt="niceysam" title="niceysam"/></a> <a href="https://github.com/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/TsekaLuk"><img src="https://avatars.githubusercontent.com/u/79151285?v=4&s=48" width="48" height="48" alt="TsekaLuk" title="TsekaLuk"/></a> <a href="https://github.com/JustasMonkev"><img src="https://avatars.githubusercontent.com/u/59362982?v=4&s=48" width="48" height="48" alt="JustasM" title="JustasM"/></a> <a href="https://github.com/loiie45e"><img src="https://avatars.githubusercontent.com/u/15420100?v=4&s=48" width="48" height="48" alt="loiie45e" title="loiie45e"/></a>
|
||||
<a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/natefikru"><img src="https://avatars.githubusercontent.com/u/10344644?v=4&s=48" width="48" height="48" alt="natefikru" title="natefikru"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/simonemacario"><img src="https://avatars.githubusercontent.com/u/2116609?v=4&s=48" width="48" height="48" alt="Simone Macario" title="Simone Macario"/></a> <a href="https://github.com/openclaw-bot"><img src="https://avatars.githubusercontent.com/u/258178069?v=4&s=48" width="48" height="48" alt="openclaw-bot" title="openclaw-bot"/></a> <a href="https://github.com/ENCHIGO"><img src="https://avatars.githubusercontent.com/u/38551565?v=4&s=48" width="48" height="48" alt="ENCHIGO" title="ENCHIGO"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a>
|
||||
<a href="https://github.com/Blakeshannon"><img src="https://avatars.githubusercontent.com/u/257822860?v=4&s=48" width="48" height="48" alt="Blakeshannon" title="Blakeshannon"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/pejmanjohn"><img src="https://avatars.githubusercontent.com/u/481729?v=4&s=48" width="48" height="48" alt="pejmanjohn" title="pejmanjohn"/></a> <a href="https://github.com/durenzidu"><img src="https://avatars.githubusercontent.com/u/38130340?v=4&s=48" width="48" height="48" alt="durenzidu" title="durenzidu"/></a> <a href="https://github.com/Ryan-Haines"><img src="https://avatars.githubusercontent.com/u/1855752?v=4&s=48" width="48" height="48" alt="Ryan Haines" title="Ryan Haines"/></a> <a href="https://github.com/hclsys"><img src="https://avatars.githubusercontent.com/u/7755017?v=4&s=48" width="48" height="48" alt="hcl" title="hcl"/></a> <a href="https://github.com/xuhao1"><img src="https://avatars.githubusercontent.com/u/5087930?v=4&s=48" width="48" height="48" alt="XuHao" title="XuHao"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/bitfoundry-ai"><img src="https://avatars.githubusercontent.com/u/239082898?v=4&s=48" width="48" height="48" alt="bitfoundry-ai" title="bitfoundry-ai"/></a>
|
||||
<a href="https://github.com/HeMuling"><img src="https://avatars.githubusercontent.com/u/74801533?v=4&s=48" width="48" height="48" alt="HeMuling" title="HeMuling"/></a> <a href="https://github.com/markmusson"><img src="https://avatars.githubusercontent.com/u/4801649?v=4&s=48" width="48" height="48" alt="markmusson" title="markmusson"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/battman21"><img src="https://avatars.githubusercontent.com/u/2656916?v=4&s=48" width="48" height="48" alt="battman21" title="battman21"/></a> <a href="https://github.com/BinHPdev"><img src="https://avatars.githubusercontent.com/u/219093083?v=4&s=48" width="48" height="48" alt="BinHPdev" title="BinHPdev"/></a> <a href="https://github.com/dguido"><img src="https://avatars.githubusercontent.com/u/294844?v=4&s=48" width="48" height="48" alt="dguido" title="dguido"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/guirguispierre"><img src="https://avatars.githubusercontent.com/u/22091706?v=4&s=48" width="48" height="48" alt="guirguispierre" title="guirguispierre"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/joeykrug"><img src="https://avatars.githubusercontent.com/u/5925937?v=4&s=48" width="48" height="48" alt="joeykrug" title="joeykrug"/></a>
|
||||
<a href="https://github.com/loganprit"><img src="https://avatars.githubusercontent.com/u/72722788?v=4&s=48" width="48" height="48" alt="loganprit" title="loganprit"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/dbachelder"><img src="https://avatars.githubusercontent.com/u/325706?v=4&s=48" width="48" height="48" alt="dbachelder" title="dbachelder"/></a> <a href="https://github.com/divanoli"><img src="https://avatars.githubusercontent.com/u/12023205?v=4&s=48" width="48" height="48" alt="Divanoli Mydeen Pitchai" title="Divanoli Mydeen Pitchai"/></a> <a href="https://github.com/liuxiaopai-ai"><img src="https://avatars.githubusercontent.com/u/73659136?v=4&s=48" width="48" height="48" alt="liuxiaopai-ai" title="liuxiaopai-ai"/></a> <a href="https://github.com/theSamPadilla"><img src="https://avatars.githubusercontent.com/u/35386211?v=4&s=48" width="48" height="48" alt="Sam Padilla" title="Sam Padilla"/></a> <a href="https://github.com/pvtclawn"><img src="https://avatars.githubusercontent.com/u/258811507?v=4&s=48" width="48" height="48" alt="pvtclawn" title="pvtclawn"/></a> <a href="https://github.com/seheepeak"><img src="https://avatars.githubusercontent.com/u/134766597?v=4&s=48" width="48" height="48" alt="seheepeak" title="seheepeak"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a>
|
||||
<a href="https://github.com/misterdas"><img src="https://avatars.githubusercontent.com/u/170702047?v=4&s=48" width="48" height="48" alt="misterdas" title="misterdas"/></a> <a href="https://github.com/xzq-xu"><img src="https://avatars.githubusercontent.com/u/53989315?v=4&s=48" width="48" height="48" alt="LeftX" title="LeftX"/></a> <a href="https://github.com/badlogic"><img src="https://avatars.githubusercontent.com/u/514052?v=4&s=48" width="48" height="48" alt="badlogic" title="badlogic"/></a> <a href="https://github.com/Shuai-DaiDai"><img src="https://avatars.githubusercontent.com/u/134567396?v=4&s=48" width="48" height="48" alt="Shuai-DaiDai" title="Shuai-DaiDai"/></a> <a href="https://github.com/mousberg"><img src="https://avatars.githubusercontent.com/u/57605064?v=4&s=48" width="48" height="48" alt="mousberg" title="mousberg"/></a> <a href="https://github.com/harhogefoo"><img src="https://avatars.githubusercontent.com/u/11906529?v=4&s=48" width="48" height="48" alt="Masataka Shinohara" title="Masataka Shinohara"/></a> <a href="https://github.com/BillChirico"><img src="https://avatars.githubusercontent.com/u/13951316?v=4&s=48" width="48" height="48" alt="BillChirico" title="BillChirico"/></a> <a href="https://github.com/lewiswigmore"><img src="https://avatars.githubusercontent.com/u/58551848?v=4&s=48" width="48" height="48" alt="Lewis" title="Lewis"/></a> <a href="https://github.com/solstead"><img src="https://avatars.githubusercontent.com/u/168413654?v=4&s=48" width="48" height="48" alt="solstead" title="solstead"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a>
|
||||
<a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/sahilsatralkar"><img src="https://avatars.githubusercontent.com/u/62758655?v=4&s=48" width="48" height="48" alt="sahilsatralkar" title="sahilsatralkar"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/ryan-crabbe"><img src="https://avatars.githubusercontent.com/u/128659760?v=4&s=48" width="48" height="48" alt="ryan-crabbe" title="ryan-crabbe"/></a> <a href="https://github.com/miloudbelarebia"><img src="https://avatars.githubusercontent.com/u/136994453?v=4&s=48" width="48" height="48" alt="miloudbelarebia" title="miloudbelarebia"/></a> <a href="https://github.com/Mellowambience"><img src="https://avatars.githubusercontent.com/u/40958792?v=4&s=48" width="48" height="48" alt="Mars" title="Mars"/></a> <a href="https://github.com/El-Fitz"><img src="https://avatars.githubusercontent.com/u/8971906?v=4&s=48" width="48" height="48" alt="El-Fitz" title="El-Fitz"/></a> <a href="https://github.com/mcrolly"><img src="https://avatars.githubusercontent.com/u/60803337?v=4&s=48" width="48" height="48" alt="McRolly NWANGWU" title="McRolly NWANGWU"/></a>
|
||||
<a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/Dithilli"><img src="https://avatars.githubusercontent.com/u/41286037?v=4&s=48" width="48" height="48" alt="Dithilli" title="Dithilli"/></a> <a href="https://github.com/emonty"><img src="https://avatars.githubusercontent.com/u/95156?v=4&s=48" width="48" height="48" alt="emonty" title="emonty"/></a> <a href="https://github.com/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/benostein"><img src="https://avatars.githubusercontent.com/u/31802821?v=4&s=48" width="48" height="48" alt="benostein" title="benostein"/></a> <a href="https://github.com/PeterShanxin"><img src="https://avatars.githubusercontent.com/u/128674037?v=4&s=48" width="48" height="48" alt="LI SHANXIN" title="LI SHANXIN"/></a> <a href="https://github.com/magendary"><img src="https://avatars.githubusercontent.com/u/30611068?v=4&s=48" width="48" height="48" alt="magendary" title="magendary"/></a> <a href="https://github.com/mahanandhi"><img src="https://avatars.githubusercontent.com/u/46371575?v=4&s=48" width="48" height="48" alt="mahanandhi" title="mahanandhi"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a>
|
||||
<a href="https://github.com/j2h4u"><img src="https://avatars.githubusercontent.com/u/39818683?v=4&s=48" width="48" height="48" alt="j2h4u" title="j2h4u"/></a> <a href="https://github.com/bsormagec"><img src="https://avatars.githubusercontent.com/u/965219?v=4&s=48" width="48" height="48" alt="bsormagec" title="bsormagec"/></a> <a href="https://github.com/jessy2027"><img src="https://avatars.githubusercontent.com/u/89694096?v=4&s=48" width="48" height="48" alt="Jessy LANGE" title="Jessy LANGE"/></a> <a href="https://github.com/aerolalit"><img src="https://avatars.githubusercontent.com/u/17166039?v=4&s=48" width="48" height="48" alt="Lalit Singh" title="Lalit Singh"/></a> <a href="https://github.com/hyf0-agent"><img src="https://avatars.githubusercontent.com/u/258783736?v=4&s=48" width="48" height="48" alt="hyf0-agent" title="hyf0-agent"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/unisone"><img src="https://avatars.githubusercontent.com/u/32521398?v=4&s=48" width="48" height="48" alt="unisone" title="unisone"/></a> <a href="https://github.com/jeann2013"><img src="https://avatars.githubusercontent.com/u/3299025?v=4&s=48" width="48" height="48" alt="jeann2013" title="jeann2013"/></a> <a href="https://github.com/jogelin"><img src="https://avatars.githubusercontent.com/u/954509?v=4&s=48" width="48" height="48" alt="jogelin" title="jogelin"/></a> <a href="https://github.com/rmorse"><img src="https://avatars.githubusercontent.com/u/853547?v=4&s=48" width="48" height="48" alt="rmorse" title="rmorse"/></a>
|
||||
<a href="https://github.com/scz2011"><img src="https://avatars.githubusercontent.com/u/9337506?v=4&s=48" width="48" height="48" alt="scz2011" title="scz2011"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/popomore"><img src="https://avatars.githubusercontent.com/u/360661?v=4&s=48" width="48" height="48" alt="popomore" title="popomore"/></a> <a href="https://github.com/cathrynlavery"><img src="https://avatars.githubusercontent.com/u/50469282?v=4&s=48" width="48" height="48" alt="cathrynlavery" title="cathrynlavery"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/jscaldwell55"><img src="https://avatars.githubusercontent.com/u/111952840?v=4&s=48" width="48" height="48" alt="Jay Caldwell" title="Jay Caldwell"/></a> <a href="https://github.com/gut-puncture"><img src="https://avatars.githubusercontent.com/u/75851986?v=4&s=48" width="48" height="48" alt="Shailesh" title="Shailesh"/></a> <a href="https://github.com/KirillShchetinin"><img src="https://avatars.githubusercontent.com/u/13061871?v=4&s=48" width="48" height="48" alt="Kirill Shchetynin" title="Kirill Shchetynin"/></a> <a href="https://github.com/ruypang"><img src="https://avatars.githubusercontent.com/u/46941315?v=4&s=48" width="48" height="48" alt="ruypang" title="ruypang"/></a>
|
||||
<a href="https://github.com/mitchmcalister"><img src="https://avatars.githubusercontent.com/u/209334?v=4&s=48" width="48" height="48" alt="mitchmcalister" title="mitchmcalister"/></a> <a href="https://github.com/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="Paul van Oorschot" title="Paul van Oorschot"/></a> <a href="https://github.com/guxu11"><img src="https://avatars.githubusercontent.com/u/53551744?v=4&s=48" width="48" height="48" alt="Xu Gu" title="Xu Gu"/></a> <a href="https://github.com/lml2468"><img src="https://avatars.githubusercontent.com/u/39320777?v=4&s=48" width="48" height="48" alt="Menglin Li" title="Menglin Li"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/jackheuberger"><img src="https://avatars.githubusercontent.com/u/7830838?v=4&s=48" width="48" height="48" alt="jackheuberger" title="jackheuberger"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/Zitzak"><img src="https://avatars.githubusercontent.com/u/43185740?v=4&s=48" width="48" height="48" alt="Marvin" title="Marvin"/></a>
|
||||
<a href="https://github.com/DrCrinkle"><img src="https://avatars.githubusercontent.com/u/62564740?v=4&s=48" width="48" height="48" alt="Taylor Asplund" title="Taylor Asplund"/></a> <a href="https://github.com/dakshaymehta"><img src="https://avatars.githubusercontent.com/u/50276213?v=4&s=48" width="48" height="48" alt="dakshaymehta" title="dakshaymehta"/></a> <a href="https://github.com/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="Stefan Galescu" title="Stefan Galescu"/></a> <a href="https://github.com/lploc94"><img src="https://avatars.githubusercontent.com/u/28453843?v=4&s=48" width="48" height="48" alt="lploc94" title="lploc94"/></a> <a href="https://github.com/WalterSumbon"><img src="https://avatars.githubusercontent.com/u/45062253?v=4&s=48" width="48" height="48" alt="WalterSumbon" title="WalterSumbon"/></a> <a href="https://github.com/krizpoon"><img src="https://avatars.githubusercontent.com/u/1977532?v=4&s=48" width="48" height="48" alt="krizpoon" title="krizpoon"/></a> <a href="https://github.com/EnzeD"><img src="https://avatars.githubusercontent.com/u/9866900?v=4&s=48" width="48" height="48" alt="EnzeD" title="EnzeD"/></a> <a href="https://github.com/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></a> <a href="https://github.com/Grynn"><img src="https://avatars.githubusercontent.com/u/212880?v=4&s=48" width="48" height="48" alt="Grynn" title="Grynn"/></a> <a href="https://github.com/hydro13"><img src="https://avatars.githubusercontent.com/u/6640526?v=4&s=48" width="48" height="48" alt="hydro13" title="hydro13"/></a>
|
||||
<a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/kentaro"><img src="https://avatars.githubusercontent.com/u/3458?v=4&s=48" width="48" height="48" alt="kentaro" title="kentaro"/></a> <a href="https://github.com/kunalk16"><img src="https://avatars.githubusercontent.com/u/5303824?v=4&s=48" width="48" height="48" alt="kunalk16" title="kunalk16"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/optimikelabs"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="optimikelabs" title="optimikelabs"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/RamiNoodle733"><img src="https://avatars.githubusercontent.com/u/117773986?v=4&s=48" width="48" height="48" alt="RamiNoodle733" title="RamiNoodle733"/></a> <a href="https://github.com/sauerdaniel"><img src="https://avatars.githubusercontent.com/u/81422812?v=4&s=48" width="48" height="48" alt="sauerdaniel" title="sauerdaniel"/></a> <a href="https://github.com/SleuthCo"><img src="https://avatars.githubusercontent.com/u/259695222?v=4&s=48" width="48" height="48" alt="SleuthCo" title="SleuthCo"/></a>
|
||||
<a href="https://github.com/TaKO8Ki"><img src="https://avatars.githubusercontent.com/u/41065217?v=4&s=48" width="48" height="48" alt="TaKO8Ki" title="TaKO8Ki"/></a> <a href="https://github.com/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></a> <a href="https://github.com/rodbland2021"><img src="https://avatars.githubusercontent.com/u/86267410?v=4&s=48" width="48" height="48" alt="rodbland2021" title="rodbland2021"/></a> <a href="https://github.com/fagemx"><img src="https://avatars.githubusercontent.com/u/117356295?v=4&s=48" width="48" height="48" alt="fagemx" title="fagemx"/></a> <a href="https://github.com/BigUncle"><img src="https://avatars.githubusercontent.com/u/9360607?v=4&s=48" width="48" height="48" alt="BigUncle" title="BigUncle"/></a> <a href="https://github.com/pycckuu"><img src="https://avatars.githubusercontent.com/u/1489583?v=4&s=48" width="48" height="48" alt="Igor Markelov" title="Igor Markelov"/></a> <a href="https://github.com/zhoulongchao77"><img src="https://avatars.githubusercontent.com/u/65058500?v=4&s=48" width="48" height="48" alt="zhoulc777" title="zhoulc777"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/paceyw"><img src="https://avatars.githubusercontent.com/u/44923937?v=4&s=48" width="48" height="48" alt="TIHU" title="TIHU"/></a> <a href="https://github.com/tonydehnke"><img src="https://avatars.githubusercontent.com/u/36720180?v=4&s=48" width="48" height="48" alt="Tony Dehnke" title="Tony Dehnke"/></a>
|
||||
<a href="https://github.com/pablohrcarvalho"><img src="https://avatars.githubusercontent.com/u/66948122?v=4&s=48" width="48" height="48" alt="pablohrcarvalho" title="pablohrcarvalho"/></a> <a href="https://github.com/bonald"><img src="https://avatars.githubusercontent.com/u/12394874?v=4&s=48" width="48" height="48" alt="bonald" title="bonald"/></a> <a href="https://github.com/rhuanssauro"><img src="https://avatars.githubusercontent.com/u/164682191?v=4&s=48" width="48" height="48" alt="rhuanssauro" title="rhuanssauro"/></a> <a href="https://github.com/CommanderCrowCode"><img src="https://avatars.githubusercontent.com/u/72845369?v=4&s=48" width="48" height="48" alt="Tanwa Arpornthip" title="Tanwa Arpornthip"/></a> <a href="https://github.com/webvijayi"><img src="https://avatars.githubusercontent.com/u/49924855?v=4&s=48" width="48" height="48" alt="webvijayi" title="webvijayi"/></a> <a href="https://github.com/tomron87"><img src="https://avatars.githubusercontent.com/u/126325152?v=4&s=48" width="48" height="48" alt="Tom Ron" title="Tom Ron"/></a> <a href="https://github.com/ozbillwang"><img src="https://avatars.githubusercontent.com/u/8954908?v=4&s=48" width="48" height="48" alt="ozbillwang" title="ozbillwang"/></a> <a href="https://github.com/Patrick-Barletta"><img src="https://avatars.githubusercontent.com/u/67929313?v=4&s=48" width="48" height="48" alt="Patrick Barletta" title="Patrick Barletta"/></a> <a href="https://github.com/ianderrington"><img src="https://avatars.githubusercontent.com/u/76016868?v=4&s=48" width="48" height="48" alt="Ian Derrington" title="Ian Derrington"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a>
|
||||
<a href="https://github.com/Ayush10"><img src="https://avatars.githubusercontent.com/u/7945279?v=4&s=48" width="48" height="48" alt="Ayush10" title="Ayush10"/></a> <a href="https://github.com/boris721"><img src="https://avatars.githubusercontent.com/u/257853888?v=4&s=48" width="48" height="48" alt="boris721" title="boris721"/></a> <a href="https://github.com/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a> <a href="https://github.com/doodlewind"><img src="https://avatars.githubusercontent.com/u/7312949?v=4&s=48" width="48" height="48" alt="doodlewind" title="doodlewind"/></a> <a href="https://github.com/ikari-pl"><img src="https://avatars.githubusercontent.com/u/811702?v=4&s=48" width="48" height="48" alt="ikari-pl" title="ikari-pl"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/shayan919293"><img src="https://avatars.githubusercontent.com/u/60409704?v=4&s=48" width="48" height="48" alt="shayan919293" title="shayan919293"/></a> <a href="https://github.com/Harrington-bot"><img src="https://avatars.githubusercontent.com/u/261410808?v=4&s=48" width="48" height="48" alt="Harrington-bot" title="Harrington-bot"/></a> <a href="https://github.com/nonggialiang"><img src="https://avatars.githubusercontent.com/u/14367839?v=4&s=48" width="48" height="48" alt="nonggia.liang" title="nonggia.liang"/></a> <a href="https://github.com/TinyTb"><img src="https://avatars.githubusercontent.com/u/5957298?v=4&s=48" width="48" height="48" alt="Michael Lee" title="Michael Lee"/></a>
|
||||
<a href="https://github.com/OscarMinjarez"><img src="https://avatars.githubusercontent.com/u/86080038?v=4&s=48" width="48" height="48" alt="OscarMinjarez" title="OscarMinjarez"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/Alg0rix"><img src="https://avatars.githubusercontent.com/u/53804949?v=4&s=48" width="48" height="48" alt="Alg0rix" title="Alg0rix"/></a> <a href="https://github.com/L-U-C-K-Y"><img src="https://avatars.githubusercontent.com/u/14868134?v=4&s=48" width="48" height="48" alt="Lucky" title="Lucky"/></a> <a href="https://github.com/Kepler2024"><img src="https://avatars.githubusercontent.com/u/166882517?v=4&s=48" width="48" height="48" alt="Harry Cui Kepler" title="Harry Cui Kepler"/></a> <a href="https://github.com/h0tp-ftw"><img src="https://avatars.githubusercontent.com/u/141889580?v=4&s=48" width="48" height="48" alt="h0tp-ftw" title="h0tp-ftw"/></a> <a href="https://github.com/Youyou972"><img src="https://avatars.githubusercontent.com/u/50808411?v=4&s=48" width="48" height="48" alt="Youyou972" title="Youyou972"/></a> <a href="https://github.com/dominicnunez"><img src="https://avatars.githubusercontent.com/u/43616264?v=4&s=48" width="48" height="48" alt="Dominic" title="Dominic"/></a> <a href="https://github.com/danielwanwx"><img src="https://avatars.githubusercontent.com/u/144515713?v=4&s=48" width="48" height="48" alt="danielwanwx" title="danielwanwx"/></a> <a href="https://github.com/0xJonHoldsCrypto"><img src="https://avatars.githubusercontent.com/u/81202085?v=4&s=48" width="48" height="48" alt="0xJonHoldsCrypto" title="0xJonHoldsCrypto"/></a>
|
||||
<a href="https://github.com/akyourowngames"><img src="https://avatars.githubusercontent.com/u/123736861?v=4&s=48" width="48" height="48" alt="akyourowngames" title="akyourowngames"/></a> <a href="https://github.com/apps/clawdinator"><img src="https://avatars.githubusercontent.com/in/2607181?v=4&s=48" width="48" height="48" alt="clawdinator[bot]" title="clawdinator[bot]"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/thesomewhatyou"><img src="https://avatars.githubusercontent.com/u/162917831?v=4&s=48" width="48" height="48" alt="thesomewhatyou" title="thesomewhatyou"/></a> <a href="https://github.com/dashed"><img src="https://avatars.githubusercontent.com/u/139499?v=4&s=48" width="48" height="48" alt="dashed" title="dashed"/></a> <a href="https://github.com/minupla"><img src="https://avatars.githubusercontent.com/u/42547246?v=4&s=48" width="48" height="48" alt="Dale Babiy" title="Dale Babiy"/></a> <a href="https://github.com/Diaspar4u"><img src="https://avatars.githubusercontent.com/u/3605840?v=4&s=48" width="48" height="48" alt="Diaspar4u" title="Diaspar4u"/></a> <a href="https://github.com/brianleach"><img src="https://avatars.githubusercontent.com/u/1900805?v=4&s=48" width="48" height="48" alt="brianleach" title="brianleach"/></a> <a href="https://github.com/codexGW"><img src="https://avatars.githubusercontent.com/u/9350182?v=4&s=48" width="48" height="48" alt="codexGW" title="codexGW"/></a>
|
||||
<a href="https://github.com/dirbalak"><img src="https://avatars.githubusercontent.com/u/30323349?v=4&s=48" width="48" height="48" alt="dirbalak" title="dirbalak"/></a> <a href="https://github.com/Iranb"><img src="https://avatars.githubusercontent.com/u/49674669?v=4&s=48" width="48" height="48" alt="Iranb" title="Iranb"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="Max" title="Max"/></a> <a href="https://github.com/papago2355"><img src="https://avatars.githubusercontent.com/u/68721273?v=4&s=48" width="48" height="48" alt="TideFinder" title="TideFinder"/></a> <a href="https://github.com/cdorsey"><img src="https://avatars.githubusercontent.com/u/12650570?v=4&s=48" width="48" height="48" alt="Chase Dorsey" title="Chase Dorsey"/></a> <a href="https://github.com/Joly0"><img src="https://avatars.githubusercontent.com/u/13993216?v=4&s=48" width="48" height="48" alt="Joly0" title="Joly0"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/tumf"><img src="https://avatars.githubusercontent.com/u/69994?v=4&s=48" width="48" height="48" alt="tumf" title="tumf"/></a> <a href="https://github.com/slonce70"><img src="https://avatars.githubusercontent.com/u/130596182?v=4&s=48" width="48" height="48" alt="slonce70" title="slonce70"/></a> <a href="https://github.com/alexgleason"><img src="https://avatars.githubusercontent.com/u/3639540?v=4&s=48" width="48" height="48" alt="alexgleason" title="alexgleason"/></a>
|
||||
<a href="https://github.com/theonejvo"><img src="https://avatars.githubusercontent.com/u/125909656?v=4&s=48" width="48" height="48" alt="theonejvo" title="theonejvo"/></a> <a href="https://github.com/adao-max"><img src="https://avatars.githubusercontent.com/u/153898832?v=4&s=48" width="48" height="48" alt="Skyler Miao" title="Skyler Miao"/></a> <a href="https://github.com/jlowin"><img src="https://avatars.githubusercontent.com/u/153965?v=4&s=48" width="48" height="48" alt="Jeremiah Lowin" title="Jeremiah Lowin"/></a> <a href="https://github.com/peetzweg"><img src="https://avatars.githubusercontent.com/u/839848?v=4&s=48" width="48" height="48" alt="peetzweg/" title="peetzweg/"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/ghsmc"><img src="https://avatars.githubusercontent.com/u/68118719?v=4&s=48" width="48" height="48" alt="ghsmc" title="ghsmc"/></a> <a href="https://github.com/ibrahimq21"><img src="https://avatars.githubusercontent.com/u/8392472?v=4&s=48" width="48" height="48" alt="ibrahimq21" title="ibrahimq21"/></a> <a href="https://github.com/irtiq7"><img src="https://avatars.githubusercontent.com/u/3823029?v=4&s=48" width="48" height="48" alt="irtiq7" title="irtiq7"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/kelvinCB"><img src="https://avatars.githubusercontent.com/u/50544379?v=4&s=48" width="48" height="48" alt="kelvinCB" title="kelvinCB"/></a>
|
||||
<a href="https://github.com/mitsuhiko"><img src="https://avatars.githubusercontent.com/u/7396?v=4&s=48" width="48" height="48" alt="mitsuhiko" title="mitsuhiko"/></a> <a href="https://github.com/rybnikov"><img src="https://avatars.githubusercontent.com/u/7761808?v=4&s=48" width="48" height="48" alt="rybnikov" title="rybnikov"/></a> <a href="https://github.com/santiagomed"><img src="https://avatars.githubusercontent.com/u/30184543?v=4&s=48" width="48" height="48" alt="santiagomed" title="santiagomed"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></a> <a href="https://github.com/kaizen403"><img src="https://avatars.githubusercontent.com/u/134706404?v=4&s=48" width="48" height="48" alt="kaizen403" title="kaizen403"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/nk1tz"><img src="https://avatars.githubusercontent.com/u/12980165?v=4&s=48" width="48" height="48" alt="Nate" title="Nate"/></a> <a href="https://github.com/CornBrother0x"><img src="https://avatars.githubusercontent.com/u/101160087?v=4&s=48" width="48" height="48" alt="CornBrother0x" title="CornBrother0x"/></a> <a href="https://github.com/DukeDeSouth"><img src="https://avatars.githubusercontent.com/u/51200688?v=4&s=48" width="48" height="48" alt="DukeDeSouth" title="DukeDeSouth"/></a>
|
||||
<a href="https://github.com/crimeacs"><img src="https://avatars.githubusercontent.com/u/35071559?v=4&s=48" width="48" height="48" alt="crimeacs" title="crimeacs"/></a> <a href="https://github.com/liebertar"><img src="https://avatars.githubusercontent.com/u/99405438?v=4&s=48" width="48" height="48" alt="Cklee" title="Cklee"/></a> <a href="https://github.com/garnetlyx"><img src="https://avatars.githubusercontent.com/u/12513503?v=4&s=48" width="48" height="48" alt="Garnet Liu" title="Garnet Liu"/></a> <a href="https://github.com/Bermudarat"><img src="https://avatars.githubusercontent.com/u/10937319?v=4&s=48" width="48" height="48" alt="neverland" title="neverland"/></a> <a href="https://github.com/ryancontent"><img src="https://avatars.githubusercontent.com/u/39743613?v=4&s=48" width="48" height="48" alt="ryan" title="ryan"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/AdeboyeDN"><img src="https://avatars.githubusercontent.com/u/65312338?v=4&s=48" width="48" height="48" alt="AdeboyeDN" title="AdeboyeDN"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="Neo" title="Neo"/></a> <a href="https://github.com/asklee-klawd"><img src="https://avatars.githubusercontent.com/u/105007315?v=4&s=48" width="48" height="48" alt="asklee-klawd" title="asklee-klawd"/></a> <a href="https://github.com/benediktjohannes"><img src="https://avatars.githubusercontent.com/u/253604130?v=4&s=48" width="48" height="48" alt="benediktjohannes" title="benediktjohannes"/></a>
|
||||
<a href="https://github.com/zhangzhefang-github"><img src="https://avatars.githubusercontent.com/u/34058239?v=4&s=48" width="48" height="48" alt="张哲芳" title="张哲芳"/></a> <a href="https://github.com/constansino"><img src="https://avatars.githubusercontent.com/u/65108260?v=4&s=48" width="48" height="48" alt="constansino" title="constansino"/></a> <a href="https://github.com/yuting0624"><img src="https://avatars.githubusercontent.com/u/32728916?v=4&s=48" width="48" height="48" alt="Yuting Lin" title="Yuting Lin"/></a> <a href="https://github.com/joelnishanth"><img src="https://avatars.githubusercontent.com/u/140015627?v=4&s=48" width="48" height="48" alt="OfflynAI" title="OfflynAI"/></a> <a href="https://github.com/18-RAJAT"><img src="https://avatars.githubusercontent.com/u/78920780?v=4&s=48" width="48" height="48" alt="Rajat Joshi" title="Rajat Joshi"/></a> <a href="https://github.com/pahdo"><img src="https://avatars.githubusercontent.com/u/12799392?v=4&s=48" width="48" height="48" alt="Daniel Zou" title="Daniel Zou"/></a> <a href="https://github.com/manikv12"><img src="https://avatars.githubusercontent.com/u/49544491?v=4&s=48" width="48" height="48" alt="Manik Vahsith" title="Manik Vahsith"/></a> <a href="https://github.com/ProspectOre"><img src="https://avatars.githubusercontent.com/u/54486432?v=4&s=48" width="48" height="48" alt="ProspectOre" title="ProspectOre"/></a> <a href="https://github.com/detecti1"><img src="https://avatars.githubusercontent.com/u/1622461?v=4&s=48" width="48" height="48" alt="Lilo" title="Lilo"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a>
|
||||
<a href="https://github.com/awkoy"><img src="https://avatars.githubusercontent.com/u/13995636?v=4&s=48" width="48" height="48" alt="awkoy" title="awkoy"/></a> <a href="https://github.com/dawondyifraw"><img src="https://avatars.githubusercontent.com/u/9797257?v=4&s=48" width="48" height="48" alt="dawondyifraw" title="dawondyifraw"/></a> <a href="https://github.com/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></a> <a href="https://github.com/hyojin"><img src="https://avatars.githubusercontent.com/u/3413183?v=4&s=48" width="48" height="48" alt="hyojin" title="hyojin"/></a> <a href="https://github.com/Kansodata"><img src="https://avatars.githubusercontent.com/u/225288021?v=4&s=48" width="48" height="48" alt="Kansodata" title="Kansodata"/></a> <a href="https://github.com/natedenh"><img src="https://avatars.githubusercontent.com/u/13399956?v=4&s=48" width="48" height="48" alt="natedenh" title="natedenh"/></a> <a href="https://github.com/pi0"><img src="https://avatars.githubusercontent.com/u/5158436?v=4&s=48" width="48" height="48" alt="pi0" title="pi0"/></a> <a href="https://github.com/dddabtc"><img src="https://avatars.githubusercontent.com/u/104875499?v=4&s=48" width="48" height="48" alt="dddabtc" title="dddabtc"/></a> <a href="https://github.com/AkashKobal"><img src="https://avatars.githubusercontent.com/u/98216083?v=4&s=48" width="48" height="48" alt="AkashKobal" title="AkashKobal"/></a> <a href="https://github.com/wu-tian807"><img src="https://avatars.githubusercontent.com/u/61640083?v=4&s=48" width="48" height="48" alt="wu-tian807" title="wu-tian807"/></a>
|
||||
<a href="https://github.com/kyleok"><img src="https://avatars.githubusercontent.com/u/58307870?v=4&s=48" width="48" height="48" alt="Ganghyun Kim" title="Ganghyun Kim"/></a> <a href="https://github.com/sbking"><img src="https://avatars.githubusercontent.com/u/3913213?v=4&s=48" width="48" height="48" alt="Stephen Brian King" title="Stephen Brian King"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John Rood" title="John Rood"/></a> <a href="https://github.com/divisonofficer"><img src="https://avatars.githubusercontent.com/u/41609506?v=4&s=48" width="48" height="48" alt="JINNYEONG KIM" title="JINNYEONG KIM"/></a> <a href="https://github.com/dinakars777"><img src="https://avatars.githubusercontent.com/u/250428393?v=4&s=48" width="48" height="48" alt="Dinakar Sarbada" title="Dinakar Sarbada"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/Protocol-zero-0"><img src="https://avatars.githubusercontent.com/u/257158451?v=4&s=48" width="48" height="48" alt="Protocol Zero" title="Protocol Zero"/></a> <a href="https://github.com/Limitless2023"><img src="https://avatars.githubusercontent.com/u/127183162?v=4&s=48" width="48" height="48" alt="Limitless" title="Limitless"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="Mykyta Bozhenko" title="Mykyta Bozhenko"/></a>
|
||||
<a href="https://github.com/nicholascyh"><img src="https://avatars.githubusercontent.com/u/188132635?v=4&s=48" width="48" height="48" alt="Nicholas" title="Nicholas"/></a> <a href="https://github.com/shivamraut101"><img src="https://avatars.githubusercontent.com/u/110457469?v=4&s=48" width="48" height="48" alt="Shivam Kumar Raut" title="Shivam Kumar Raut"/></a> <a href="https://github.com/andreesg"><img src="https://avatars.githubusercontent.com/u/810322?v=4&s=48" width="48" height="48" alt="andreesg" title="andreesg"/></a> <a href="https://github.com/fwhite13"><img src="https://avatars.githubusercontent.com/u/173006051?v=4&s=48" width="48" height="48" alt="Fred White" title="Fred White"/></a> <a href="https://github.com/Anandesh-Sharma"><img src="https://avatars.githubusercontent.com/u/30695364?v=4&s=48" width="48" height="48" alt="Anandesh-Sharma" title="Anandesh-Sharma"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/ezhikkk"><img src="https://avatars.githubusercontent.com/u/105670095?v=4&s=48" width="48" height="48" alt="ezhikkk" title="ezhikkk"/></a> <a href="https://github.com/andreabadesso"><img src="https://avatars.githubusercontent.com/u/3586068?v=4&s=48" width="48" height="48" alt="andreabadesso" title="andreabadesso"/></a> <a href="https://github.com/BinaryMuse"><img src="https://avatars.githubusercontent.com/u/189606?v=4&s=48" width="48" height="48" alt="BinaryMuse" title="BinaryMuse"/></a> <a href="https://github.com/cordx56"><img src="https://avatars.githubusercontent.com/u/23298744?v=4&s=48" width="48" height="48" alt="cordx56" title="cordx56"/></a>
|
||||
<a href="https://github.com/DevSecTim"><img src="https://avatars.githubusercontent.com/u/2226767?v=4&s=48" width="48" height="48" alt="DevSecTim" title="DevSecTim"/></a> <a href="https://github.com/edincampara"><img src="https://avatars.githubusercontent.com/u/142477787?v=4&s=48" width="48" height="48" alt="edincampara" title="edincampara"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/gildo"><img src="https://avatars.githubusercontent.com/u/133645?v=4&s=48" width="48" height="48" alt="gildo" title="gildo"/></a> <a href="https://github.com/itsjaydesu"><img src="https://avatars.githubusercontent.com/u/220390?v=4&s=48" width="48" height="48" alt="itsjaydesu" title="itsjaydesu"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/loeclos"><img src="https://avatars.githubusercontent.com/u/116607327?v=4&s=48" width="48" height="48" alt="loeclos" title="loeclos"/></a> <a href="https://github.com/MarvinCui"><img src="https://avatars.githubusercontent.com/u/130876763?v=4&s=48" width="48" height="48" alt="MarvinCui" title="MarvinCui"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/thejhinvirtuoso"><img src="https://avatars.githubusercontent.com/u/258521837?v=4&s=48" width="48" height="48" alt="thejhinvirtuoso" title="thejhinvirtuoso"/></a>
|
||||
<a href="https://github.com/yudshj"><img src="https://avatars.githubusercontent.com/u/16971372?v=4&s=48" width="48" height="48" alt="yudshj" title="yudshj"/></a> <a href="https://github.com/Wangnov"><img src="https://avatars.githubusercontent.com/u/48670012?v=4&s=48" width="48" height="48" alt="Wangnov" title="Wangnov"/></a> <a href="https://github.com/JonathanWorks"><img src="https://avatars.githubusercontent.com/u/124476234?v=4&s=48" width="48" height="48" alt="Jonathan Works" title="Jonathan Works"/></a> <a href="https://github.com/yassine20011"><img src="https://avatars.githubusercontent.com/u/59234686?v=4&s=48" width="48" height="48" alt="Yassine Amjad" title="Yassine Amjad"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/hirefrank"><img src="https://avatars.githubusercontent.com/u/183158?v=4&s=48" width="48" height="48" alt="Frank Harris" title="Frank Harris"/></a> <a href="https://github.com/kennyklee"><img src="https://avatars.githubusercontent.com/u/1432489?v=4&s=48" width="48" height="48" alt="Kenny Lee" title="Kenny Lee"/></a> <a href="https://github.com/ThomsenDrake"><img src="https://avatars.githubusercontent.com/u/120344051?v=4&s=48" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/wangai-studio"><img src="https://avatars.githubusercontent.com/u/256938352?v=4&s=48" width="48" height="48" alt="wangai-studio" title="wangai-studio"/></a> <a href="https://github.com/AytuncYildizli"><img src="https://avatars.githubusercontent.com/u/47717026?v=4&s=48" width="48" height="48" alt="AytuncYildizli" title="AytuncYildizli"/></a>
|
||||
<a href="https://github.com/KnHack"><img src="https://avatars.githubusercontent.com/u/2346724?v=4&s=48" width="48" height="48" alt="Charlie Niño" title="Charlie Niño"/></a> <a href="https://github.com/17jmumford"><img src="https://avatars.githubusercontent.com/u/36290330?v=4&s=48" width="48" height="48" alt="Jeremy Mumford" title="Jeremy Mumford"/></a> <a href="https://github.com/Yeom-JinHo"><img src="https://avatars.githubusercontent.com/u/81306489?v=4&s=48" width="48" height="48" alt="Yeom-JinHo" title="Yeom-JinHo"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="Rob Axelsen" title="Rob Axelsen"/></a> <a href="https://github.com/junjunjunbong"><img src="https://avatars.githubusercontent.com/u/153147718?v=4&s=48" width="48" height="48" alt="junwon" title="junwon"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="Pratham Dubey" title="Pratham Dubey"/></a> <a href="https://github.com/amitbiswal007"><img src="https://avatars.githubusercontent.com/u/108086198?v=4&s=48" width="48" height="48" alt="amitbiswal007" title="amitbiswal007"/></a> <a href="https://github.com/Slats24"><img src="https://avatars.githubusercontent.com/u/42514321?v=4&s=48" width="48" height="48" alt="Slats" title="Slats"/></a> <a href="https://github.com/orenyomtov"><img src="https://avatars.githubusercontent.com/u/168856?v=4&s=48" width="48" height="48" alt="Oren" title="Oren"/></a> <a href="https://github.com/parkertoddbrooks"><img src="https://avatars.githubusercontent.com/u/585456?v=4&s=48" width="48" height="48" alt="Parker Todd Brooks" title="Parker Todd Brooks"/></a>
|
||||
<a href="https://github.com/mattqdev"><img src="https://avatars.githubusercontent.com/u/115874885?v=4&s=48" width="48" height="48" alt="MattQ" title="MattQ"/></a> <a href="https://github.com/Milofax"><img src="https://avatars.githubusercontent.com/u/2537423?v=4&s=48" width="48" height="48" alt="Milofax" title="Milofax"/></a> <a href="https://github.com/stevebot-alive"><img src="https://avatars.githubusercontent.com/u/261149299?v=4&s=48" width="48" height="48" alt="Steve (OpenClaw)" title="Steve (OpenClaw)"/></a> <a href="https://github.com/ZetiMente"><img src="https://avatars.githubusercontent.com/u/76985631?v=4&s=48" width="48" height="48" alt="Matthew" title="Matthew"/></a> <a href="https://github.com/Cassius0924"><img src="https://avatars.githubusercontent.com/u/62874592?v=4&s=48" width="48" height="48" alt="Cassius0924" title="Cassius0924"/></a> <a href="https://github.com/0xbrak"><img src="https://avatars.githubusercontent.com/u/181251288?v=4&s=48" width="48" height="48" alt="0xbrak" title="0xbrak"/></a> <a href="https://github.com/8BlT"><img src="https://avatars.githubusercontent.com/u/162764392?v=4&s=48" width="48" height="48" alt="8BlT" title="8BlT"/></a> <a href="https://github.com/Abdul535"><img src="https://avatars.githubusercontent.com/u/54276938?v=4&s=48" width="48" height="48" alt="Abdul535" title="Abdul535"/></a> <a href="https://github.com/abhaymundhara"><img src="https://avatars.githubusercontent.com/u/62872231?v=4&s=48" width="48" height="48" alt="abhaymundhara" title="abhaymundhara"/></a> <a href="https://github.com/aduk059"><img src="https://avatars.githubusercontent.com/u/257603478?v=4&s=48" width="48" height="48" alt="aduk059" title="aduk059"/></a>
|
||||
<a href="https://github.com/afurm"><img src="https://avatars.githubusercontent.com/u/6375192?v=4&s=48" width="48" height="48" alt="afurm" title="afurm"/></a> <a href="https://github.com/aisling404"><img src="https://avatars.githubusercontent.com/u/211950534?v=4&s=48" width="48" height="48" alt="aisling404" title="aisling404"/></a> <a href="https://github.com/akari-musubi"><img src="https://avatars.githubusercontent.com/u/259925157?v=4&s=48" width="48" height="48" alt="akari-musubi" title="akari-musubi"/></a> <a href="https://github.com/albertlieyingadrian"><img src="https://avatars.githubusercontent.com/u/12984659?v=4&s=48" width="48" height="48" alt="albertlieyingadrian" title="albertlieyingadrian"/></a> <a href="https://github.com/Alex-Alaniz"><img src="https://avatars.githubusercontent.com/u/88956822?v=4&s=48" width="48" height="48" alt="Alex-Alaniz" title="Alex-Alaniz"/></a> <a href="https://github.com/ali-aljufairi"><img src="https://avatars.githubusercontent.com/u/85583841?v=4&s=48" width="48" height="48" alt="ali-aljufairi" title="ali-aljufairi"/></a> <a href="https://github.com/altaywtf"><img src="https://avatars.githubusercontent.com/u/9790196?v=4&s=48" width="48" height="48" alt="altaywtf" title="altaywtf"/></a> <a href="https://github.com/araa47"><img src="https://avatars.githubusercontent.com/u/22760261?v=4&s=48" width="48" height="48" alt="araa47" title="araa47"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/avacadobanana352"><img src="https://avatars.githubusercontent.com/u/263496834?v=4&s=48" width="48" height="48" alt="avacadobanana352" title="avacadobanana352"/></a>
|
||||
<a href="https://github.com/barronlroth"><img src="https://avatars.githubusercontent.com/u/5567884?v=4&s=48" width="48" height="48" alt="barronlroth" title="barronlroth"/></a> <a href="https://github.com/bennewton999"><img src="https://avatars.githubusercontent.com/u/458991?v=4&s=48" width="48" height="48" alt="bennewton999" title="bennewton999"/></a> <a href="https://github.com/bguidolim"><img src="https://avatars.githubusercontent.com/u/987360?v=4&s=48" width="48" height="48" alt="bguidolim" title="bguidolim"/></a> <a href="https://github.com/bigwest60"><img src="https://avatars.githubusercontent.com/u/12373979?v=4&s=48" width="48" height="48" alt="bigwest60" title="bigwest60"/></a> <a href="https://github.com/caelum0x"><img src="https://avatars.githubusercontent.com/u/130079063?v=4&s=48" width="48" height="48" alt="caelum0x" title="caelum0x"/></a> <a href="https://github.com/championswimmer"><img src="https://avatars.githubusercontent.com/u/1327050?v=4&s=48" width="48" height="48" alt="championswimmer" title="championswimmer"/></a> <a href="https://github.com/dutifulbob"><img src="https://avatars.githubusercontent.com/u/261991368?v=4&s=48" width="48" height="48" alt="dutifulbob" title="dutifulbob"/></a> <a href="https://github.com/eternauta1337"><img src="https://avatars.githubusercontent.com/u/550409?v=4&s=48" width="48" height="48" alt="eternauta1337" title="eternauta1337"/></a> <a href="https://github.com/foeken"><img src="https://avatars.githubusercontent.com/u/13864?v=4&s=48" width="48" height="48" alt="foeken" title="foeken"/></a> <a href="https://github.com/gittb"><img src="https://avatars.githubusercontent.com/u/8284364?v=4&s=48" width="48" height="48" alt="gittb" title="gittb"/></a>
|
||||
<a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/junsuwhy"><img src="https://avatars.githubusercontent.com/u/4645498?v=4&s=48" width="48" height="48" alt="junsuwhy" title="junsuwhy"/></a> <a href="https://github.com/knocte"><img src="https://avatars.githubusercontent.com/u/331303?v=4&s=48" width="48" height="48" alt="knocte" title="knocte"/></a> <a href="https://github.com/MackDing"><img src="https://avatars.githubusercontent.com/u/19878893?v=4&s=48" width="48" height="48" alt="MackDing" title="MackDing"/></a> <a href="https://github.com/nobrainer-tech"><img src="https://avatars.githubusercontent.com/u/445466?v=4&s=48" width="48" height="48" alt="nobrainer-tech" title="nobrainer-tech"/></a> <a href="https://github.com/Noctivoro"><img src="https://avatars.githubusercontent.com/u/183974570?v=4&s=48" width="48" height="48" alt="Noctivoro" title="Noctivoro"/></a> <a href="https://github.com/Raikan10"><img src="https://avatars.githubusercontent.com/u/20675476?v=4&s=48" width="48" height="48" alt="Raikan10" title="Raikan10"/></a> <a href="https://github.com/Swader"><img src="https://avatars.githubusercontent.com/u/1430603?v=4&s=48" width="48" height="48" alt="Swader" title="Swader"/></a> <a href="https://github.com/algal"><img src="https://avatars.githubusercontent.com/u/264412?v=4&s=48" width="48" height="48" alt="Alexis Gallagher" title="Alexis Gallagher"/></a> <a href="https://github.com/alexstyl"><img src="https://avatars.githubusercontent.com/u/1665273?v=4&s=48" width="48" height="48" alt="alexstyl" title="alexstyl"/></a> <a href="https://github.com/ethanpalm"><img src="https://avatars.githubusercontent.com/u/56270045?v=4&s=48" width="48" height="48" alt="Ethan Palm" title="Ethan Palm"/></a>
|
||||
<a href="https://github.com/yingchunbai"><img src="https://avatars.githubusercontent.com/u/33477283?v=4&s=48" width="48" height="48" alt="yingchunbai" title="yingchunbai"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/danballance"><img src="https://avatars.githubusercontent.com/u/13839912?v=4&s=48" width="48" height="48" alt="Dan Ballance" title="Dan Ballance"/></a> <a href="https://github.com/GHesericsu"><img src="https://avatars.githubusercontent.com/u/60202455?v=4&s=48" width="48" height="48" alt="Eric Su" title="Eric Su"/></a> <a href="https://github.com/kimitaka"><img src="https://avatars.githubusercontent.com/u/167225?v=4&s=48" width="48" height="48" alt="Kimitaka Watanabe" title="Kimitaka Watanabe"/></a> <a href="https://github.com/itsjling"><img src="https://avatars.githubusercontent.com/u/2521993?v=4&s=48" width="48" height="48" alt="Justin Ling" title="Justin Ling"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/RayBB"><img src="https://avatars.githubusercontent.com/u/921217?v=4&s=48" width="48" height="48" alt="Raymond Berger" title="Raymond Berger"/></a> <a href="https://github.com/atalovesyou"><img src="https://avatars.githubusercontent.com/u/3534502?v=4&s=48" width="48" height="48" alt="atalovesyou" title="atalovesyou"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a>
|
||||
<a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/efe-buken"><img src="https://avatars.githubusercontent.com/u/262546946?v=4&s=48" width="48" height="48" alt="efe-buken" title="efe-buken"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/easternbloc"><img src="https://avatars.githubusercontent.com/u/92585?v=4&s=48" width="48" height="48" alt="easternbloc" title="easternbloc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a>
|
||||
<a href="https://github.com/sktbrd"><img src="https://avatars.githubusercontent.com/u/116202536?v=4&s=48" width="48" height="48" alt="sktbrd" title="sktbrd"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/Mind-Dragon"><img src="https://avatars.githubusercontent.com/u/262945885?v=4&s=48" width="48" height="48" alt="Mind-Dragon" title="Mind-Dragon"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/tmchow"><img src="https://avatars.githubusercontent.com/u/517103?v=4&s=48" width="48" height="48" alt="tmchow" title="tmchow"/></a> <a href="https://github.com/uli-will-code"><img src="https://avatars.githubusercontent.com/u/49715419?v=4&s=48" width="48" height="48" alt="uli-will-code" title="uli-will-code"/></a> <a href="https://github.com/mgratch"><img src="https://avatars.githubusercontent.com/u/2238658?v=4&s=48" width="48" height="48" alt="Marc Gratch" title="Marc Gratch"/></a> <a href="https://github.com/JackyWay"><img src="https://avatars.githubusercontent.com/u/53031570?v=4&s=48" width="48" height="48" alt="JackyWay" title="JackyWay"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/CJWTRUST"><img src="https://avatars.githubusercontent.com/u/235565898?v=4&s=48" width="48" height="48" alt="CJWTRUST" title="CJWTRUST"/></a>
|
||||
<a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/mujiannan"><img src="https://avatars.githubusercontent.com/u/46643837?v=4&s=48" width="48" height="48" alt="mujiannan" title="mujiannan"/></a> <a href="https://github.com/marcodd23"><img src="https://avatars.githubusercontent.com/u/3519682?v=4&s=48" width="48" height="48" alt="Marco Di Dionisio" title="Marco Di Dionisio"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/afern247"><img src="https://avatars.githubusercontent.com/u/34192856?v=4&s=48" width="48" height="48" alt="afern247" title="afern247"/></a> <a href="https://github.com/0oAstro"><img src="https://avatars.githubusercontent.com/u/79555780?v=4&s=48" width="48" height="48" alt="0oAstro" title="0oAstro"/></a> <a href="https://github.com/alexanderatallah"><img src="https://avatars.githubusercontent.com/u/1011391?v=4&s=48" width="48" height="48" alt="alexanderatallah" title="alexanderatallah"/></a>
|
||||
<a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/aaronn"><img src="https://avatars.githubusercontent.com/u/1653630?v=4&s=48" width="48" height="48" alt="aaronn" title="aaronn"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/jiulingyun"><img src="https://avatars.githubusercontent.com/u/126459548?v=4&s=48" width="48" height="48" alt="jiulingyun" title="jiulingyun"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a>
|
||||
<a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a>
|
||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a>
|
||||
<a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a>
|
||||
<a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a>
|
||||
<a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a>
|
||||
<a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a>
|
||||
<a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a>
|
||||
<a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a>
|
||||
<a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a>
|
||||
<a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a>
|
||||
<a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a>
|
||||
<a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a>
|
||||
<a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a>
|
||||
<a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a>
|
||||
<a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a>
|
||||
<a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a>
|
||||
<a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a>
|
||||
<a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a>
|
||||
<a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a>
|
||||
<a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a>
|
||||
<a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
</p>
|
||||
|
||||
287
SECURITY.md
287
SECURITY.md
@@ -1,292 +1,15 @@
|
||||
# Security Policy
|
||||
|
||||
If you believe you've found a security issue in OpenClaw, please report it privately.
|
||||
If you believe you’ve found a security issue in Clawdbot, please report it privately.
|
||||
|
||||
## Reporting
|
||||
|
||||
Report vulnerabilities directly to the repository where the issue lives:
|
||||
|
||||
- **Core CLI and gateway** — [openclaw/openclaw](https://github.com/openclaw/openclaw)
|
||||
- **macOS desktop app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/macos)
|
||||
- **iOS app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/ios)
|
||||
- **Android app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/android)
|
||||
- **ClawHub** — [openclaw/clawhub](https://github.com/openclaw/clawhub)
|
||||
- **Trust and threat model** — [openclaw/trust](https://github.com/openclaw/trust)
|
||||
|
||||
For issues that don't fit a specific repo, or if you're unsure, email **[security@openclaw.ai](mailto:security@openclaw.ai)** and we'll route it.
|
||||
|
||||
For full reporting instructions see our [Trust page](https://trust.openclaw.ai).
|
||||
|
||||
### Required in Reports
|
||||
|
||||
1. **Title**
|
||||
2. **Severity Assessment**
|
||||
3. **Impact**
|
||||
4. **Affected Component**
|
||||
5. **Technical Reproduction**
|
||||
6. **Demonstrated Impact**
|
||||
7. **Environment**
|
||||
8. **Remediation Advice**
|
||||
|
||||
Reports without reproduction steps, demonstrated impact, and remediation advice will be deprioritized. Given the volume of AI-generated scanner findings, we must ensure we're receiving vetted reports from researchers who understand the issues.
|
||||
|
||||
### Report Acceptance Gate (Triage Fast Path)
|
||||
|
||||
For fastest triage, include all of the following:
|
||||
|
||||
- Exact vulnerable path (`file`, function, and line range) on a current revision.
|
||||
- Tested version details (OpenClaw version and/or commit SHA).
|
||||
- Reproducible PoC against latest `main` or latest released version.
|
||||
- If the claim targets a released version, evidence from the shipped tag and published artifact/package for that exact version (not only `main`).
|
||||
- Demonstrated impact tied to OpenClaw's documented trust boundaries.
|
||||
- For exposed-secret reports: proof the credential is OpenClaw-owned (or grants access to OpenClaw-operated infrastructure/services).
|
||||
- Explicit statement that the report does not rely on adversarial operators sharing one gateway host/config.
|
||||
- Scope check explaining why the report is **not** covered by the Out of Scope section below.
|
||||
- For command-risk/parity reports (for example obfuscation detection differences), a concrete boundary-bypass path is required (auth/approval/allowlist/sandbox). Parity-only findings are treated as hardening, not vulnerabilities.
|
||||
|
||||
Reports that miss these requirements may be closed as `invalid` or `no-action`.
|
||||
|
||||
### Common False-Positive Patterns
|
||||
|
||||
These are frequently reported but are typically closed with no code change:
|
||||
|
||||
- Prompt-injection-only chains without a boundary bypass (prompt injection is out of scope).
|
||||
- Operator-intended local features (for example TUI local `!` shell) presented as remote injection.
|
||||
- Reports that treat explicit operator-control surfaces (for example `canvas.eval`, browser evaluate/script execution, or direct `node.invoke` execution primitives) as vulnerabilities without demonstrating an auth/policy/sandbox boundary bypass. These capabilities are intentional when enabled and are trusted-operator features, not standalone security bugs.
|
||||
- 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 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 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.
|
||||
- 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.
|
||||
- Reports that depend on pre-existing symlinked skill/workspace filesystem state (for example symlink chains involving `skills/*/SKILL.md`) without showing an untrusted path that can create/control that state.
|
||||
- Missing HSTS findings on default local/loopback deployments.
|
||||
- Slack webhook signature findings when HTTP mode already uses signing-secret verification.
|
||||
- Discord inbound webhook signature findings for paths not used by this repo's Discord integration.
|
||||
- Claims that Microsoft Teams `fileConsent/invoke` `uploadInfo.uploadUrl` is attacker-controlled without demonstrating one of: auth boundary bypass, a real authenticated Teams/Bot Framework event carrying attacker-chosen URL, or compromise of the Microsoft/Bot trust path.
|
||||
- Scanner-only claims against stale/nonexistent paths, or claims without a working repro.
|
||||
- Reports that restate an already-fixed issue against later released versions without showing the vulnerable path still exists in the shipped tag or published artifact for that later version.
|
||||
|
||||
### Duplicate Report Handling
|
||||
|
||||
- Search existing advisories before filing.
|
||||
- Include likely duplicate GHSA IDs in your report when applicable.
|
||||
- Maintainers may close lower-quality/later duplicates in favor of the earliest high-quality canonical report.
|
||||
|
||||
## Security & Trust
|
||||
|
||||
**Jamieson O'Reilly** ([@theonejvo](https://twitter.com/theonejvo)) is Security & Trust at OpenClaw. Jamieson is the founder of [Dvuln](https://dvuln.com) and brings extensive experience in offensive security, penetration testing, and security program development.
|
||||
|
||||
## Bug Bounties
|
||||
|
||||
OpenClaw is a labor of love. There is no bug bounty program and no budget for paid reports. Please still disclose responsibly so we can fix issues quickly.
|
||||
The best way to help the project right now is by sending PRs.
|
||||
|
||||
## Maintainers: GHSA Updates via CLI
|
||||
|
||||
When patching a GHSA via `gh api`, include `X-GitHub-Api-Version: 2022-11-28` (or newer). Without it, some fields (notably CVSS) may not persist even if the request returns 200.
|
||||
|
||||
## Operator Trust Model (Important)
|
||||
|
||||
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.
|
||||
- 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.
|
||||
- Recommended mode: one user per machine/host (or VPS), one gateway for that user, and one or more agents inside that gateway.
|
||||
- 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 `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.
|
||||
|
||||
## Trusted Plugin Concept (Core)
|
||||
|
||||
Plugins/extensions are part of OpenClaw's trusted computing base for a gateway.
|
||||
|
||||
- Installing or enabling a plugin grants it the same trust level as local code running on that gateway host.
|
||||
- Plugin behavior such as reading env/files or running host commands is expected inside this trust boundary.
|
||||
- Security reports must show a boundary bypass (for example unauthenticated plugin load, allowlist/policy bypass, or sandbox/path-safety bypass), not only malicious behavior from a trusted-installed plugin.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Public Internet Exposure
|
||||
- Using OpenClaw in ways that the docs recommend not to
|
||||
- Deployments where mutually untrusted/adversarial operators share one gateway host and config (for example, reports expecting per-operator isolation for `sessions.list`, `sessions.preview`, `chat.history`, or similar control-plane reads)
|
||||
- Prompt-injection-only attacks (without a policy/auth/sandbox boundary bypass)
|
||||
- Reports that require write access to trusted local state (`~/.openclaw`, workspace files like `MEMORY.md` / `memory/*.md`)
|
||||
- Reports where exploitability depends on attacker-controlled pre-existing symlink/hardlink filesystem state in trusted local paths (for example extraction/install target trees) unless a separate untrusted boundary bypass is shown that creates that state.
|
||||
- Reports whose only claim is sandbox/workspace read expansion through trusted local skill/workspace symlink state (for example `skills/*/SKILL.md` symlink chains) unless a separate untrusted boundary bypass is shown that creates/controls that state.
|
||||
- Reports whose only claim is post-approval executable identity drift on a trusted host via same-path file replacement/rewrite unless a separate untrusted boundary bypass is shown for that host write primitive.
|
||||
- Reports where the only demonstrated impact is an already-authorized sender intentionally invoking a local-action command (for example `/export-session` writing to an absolute host path) without bypassing auth, sandbox, or another documented boundary
|
||||
- Reports whose only claim is use of an explicit trusted-operator control surface (for example `canvas.eval`, browser evaluate/script execution, or direct `node.invoke` execution) without demonstrating an auth, policy, allowlist, approval, or sandbox bypass.
|
||||
- Reports where the only claim is that a trusted-installed/enabled plugin can execute with gateway/host privileges (documented trust model behavior).
|
||||
- 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 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.
|
||||
- Reports whose only claim is that a platform-provided upload destination URL is untrusted (for example Microsoft Teams `fileConsent/invoke` `uploadInfo.uploadUrl`) without proving attacker control in an authenticated production flow.
|
||||
|
||||
## Deployment Assumptions
|
||||
|
||||
OpenClaw security guidance assumes:
|
||||
|
||||
- The host where OpenClaw runs is within a trusted OS/admin boundary.
|
||||
- Anyone who can modify `~/.openclaw` state/config (including `openclaw.json`) is effectively a trusted operator.
|
||||
- A single Gateway shared by mutually untrusted people is **not a recommended setup**. Use separate gateways (or at minimum separate OS users/hosts) per trust boundary.
|
||||
- Authenticated Gateway callers are treated as trusted operators. Session identifiers (for example `sessionKey`) are routing controls, not per-user authorization boundaries.
|
||||
- Multiple gateway instances can run on one machine, but the recommended model is clean per-user isolation (prefer one host/VPS per user).
|
||||
|
||||
## One-User Trust Model (Personal Assistant)
|
||||
|
||||
OpenClaw's security model is "personal assistant" (one trusted operator, potentially many agents), not "shared multi-tenant bus."
|
||||
|
||||
- If multiple people can message the same tool-enabled agent (for example a shared Slack workspace), they can all steer that agent within its granted permissions.
|
||||
- Non-owner sender status only affects owner-only tools/commands. If a non-owner can still access a non-owner-only tool on that same agent (for example `canvas`), that is within the granted tool boundary unless the report demonstrates an auth, policy, allowlist, approval, or sandbox bypass.
|
||||
- Session or memory scoping reduces context bleed, but does **not** create per-user host authorization boundaries.
|
||||
- For mixed-trust or adversarial users, isolate by OS user/host/gateway and use separate credentials per boundary.
|
||||
- A company-shared agent can be a valid setup when users are in the same trust boundary and the agent is strictly business-only.
|
||||
- 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.
|
||||
|
||||
## Agent and Model Assumptions
|
||||
|
||||
- The model/agent is **not** a trusted principal. Assume prompt/content injection can manipulate behavior.
|
||||
- Security boundaries come from host/config trust, auth, tool policy, sandboxing, and exec approvals.
|
||||
- Prompt injection by itself is not a vulnerability report unless it crosses one of those boundaries.
|
||||
- Hook/webhook-driven payloads should be treated as untrusted content; keep unsafe bypass flags disabled unless doing tightly scoped debugging (`hooks.gmail.allowUnsafeExternalContent`, `hooks.mappings[].allowUnsafeExternalContent`).
|
||||
- Weak model tiers are generally easier to prompt-inject. For tool-enabled or hook-driven agents, prefer strong modern model tiers and strict tool policy (for example `tools.profile: "messaging"` or stricter), plus sandboxing where possible.
|
||||
|
||||
## Gateway and Node trust concept
|
||||
|
||||
OpenClaw separates routing from execution, but both remain inside the same operator trust boundary:
|
||||
|
||||
- **Gateway** is the control plane. If a caller passes Gateway auth, they are treated as a trusted operator for that Gateway.
|
||||
- **Node** is an execution extension of the Gateway. Pairing a node grants operator-level remote capability on that node.
|
||||
- **Exec approvals** (allowlist/ask UI) are operator guardrails to reduce accidental command execution, not a multi-tenant authorization boundary.
|
||||
- Exec approvals bind exact command/cwd/env context and, when OpenClaw can identify one concrete local script/file operand, that file snapshot too. This is best-effort integrity hardening, not a complete semantic model of every interpreter/runtime loader path.
|
||||
- Differences in command-risk warning heuristics between exec surfaces (`gateway`, `node`, `sandbox`) do not, by themselves, constitute a security-boundary bypass.
|
||||
- For untrusted-user isolation, split by trust boundary: separate gateways and separate OS users/hosts per boundary.
|
||||
|
||||
## Workspace Memory Trust Boundary
|
||||
|
||||
`MEMORY.md` and `memory/*.md` are plain workspace files and are treated as trusted local operator state.
|
||||
|
||||
- If someone can edit workspace memory files, they already crossed the trusted operator boundary.
|
||||
- Memory search indexing/recall over those files is expected behavior, not a sandbox/security boundary.
|
||||
- Example report pattern considered out of scope: "attacker writes malicious content into `memory/*.md`, then `memory_search` returns it."
|
||||
- If you need isolation between mutually untrusted users, split by OS user or host and run separate gateways.
|
||||
|
||||
## Plugin Trust Boundary
|
||||
|
||||
Plugins/extensions are loaded **in-process** with the Gateway and are treated as trusted code.
|
||||
|
||||
- Plugins can execute with the same OS privileges as the OpenClaw process.
|
||||
- Runtime helpers (for example `runtime.system.runCommandWithTimeout`) are convenience APIs, not a sandbox boundary.
|
||||
- Only install plugins you trust, and prefer `plugins.allow` to pin explicit trusted plugin ids.
|
||||
|
||||
## Temp Folder Boundary (Media/Sandbox)
|
||||
|
||||
OpenClaw uses a dedicated temp root for local media handoff and sandbox-adjacent temp artifacts:
|
||||
|
||||
- Preferred temp root: `/tmp/openclaw` (when available and safe on the host).
|
||||
- Fallback temp root: `os.tmpdir()/openclaw` (or `openclaw-<uid>` on multi-user hosts).
|
||||
|
||||
Security boundary notes:
|
||||
|
||||
- Sandbox media validation allows absolute temp paths only under the OpenClaw-managed temp root.
|
||||
- Arbitrary host tmp paths are not treated as trusted media roots.
|
||||
- Plugin/extension code should use OpenClaw temp helpers (`resolvePreferredOpenClawTmpDir`, `buildRandomTempFilePath`, `withTempDownloadPath`) rather than raw `os.tmpdir()` defaults when handling media files.
|
||||
- Enforcement reference points:
|
||||
- temp root resolver: `src/infra/tmp-openclaw-dir.ts`
|
||||
- SDK temp helpers: `src/plugin-sdk/temp-path.ts`
|
||||
- messaging/channel tmp guardrail: `scripts/check-no-random-messaging-tmp.mjs`
|
||||
- Email: `steipete@gmail.com`
|
||||
- What to include: reproduction steps, impact assessment, and (if possible) a minimal PoC.
|
||||
|
||||
## Operational Guidance
|
||||
|
||||
For threat model + hardening guidance (including `openclaw security audit --deep` and `--fix`), see:
|
||||
For threat model + hardening guidance (including `clawdbot security audit --deep` and `--fix`), see:
|
||||
|
||||
- `https://docs.openclaw.ai/gateway/security`
|
||||
- `https://docs.clawd.bot/gateway/security`
|
||||
|
||||
### Tool filesystem hardening
|
||||
|
||||
- `tools.exec.applyPatch.workspaceOnly: true` (recommended): keeps `apply_patch` writes/deletes within the configured workspace directory.
|
||||
- `tools.fs.workspaceOnly: true` (optional): restricts `read`/`write`/`edit`/`apply_patch` paths and native prompt image auto-load paths to the workspace directory.
|
||||
- Avoid setting `tools.exec.applyPatch.workspaceOnly: false` unless you fully trust who can trigger tool execution.
|
||||
|
||||
### Sub-agent delegation hardening
|
||||
|
||||
- Keep `sessions_spawn` denied unless you explicitly need delegated runs.
|
||||
- Keep `agents.list[].subagents.allowAgents` narrow, and only include agents with sandbox settings you trust.
|
||||
- When delegation must stay sandboxed, call `sessions_spawn` with `sandbox: "require"` (default is `inherit`).
|
||||
- `sandbox: "require"` rejects the spawn unless the target child runtime is sandboxed.
|
||||
- This prevents a less-restricted session from delegating work into an unsandboxed child by mistake.
|
||||
|
||||
### Web Interface Safety
|
||||
|
||||
OpenClaw's web interface (Gateway Control UI + HTTP endpoints) is intended for **local use only**.
|
||||
|
||||
- Recommended: keep the Gateway **loopback-only** (`127.0.0.1` / `::1`).
|
||||
- Config: `gateway.bind="loopback"` (default).
|
||||
- CLI: `openclaw gateway run --bind loopback`.
|
||||
- `gateway.controlUi.dangerouslyDisableDeviceAuth` is intended for localhost-only break-glass use.
|
||||
- OpenClaw keeps deployment flexibility by design and does not hard-forbid non-local setups.
|
||||
- Non-local and other risky configurations are surfaced by `openclaw security audit` as dangerous findings.
|
||||
- This operator-selected tradeoff is by design and not, by itself, a security vulnerability.
|
||||
- Canvas host note: network-visible canvas is **intentional** for trusted node scenarios (LAN/tailnet).
|
||||
- Expected setup: non-loopback bind + Gateway auth (token/password/trusted-proxy) + firewall/tailnet controls.
|
||||
- Expected routes: `/__openclaw__/canvas/`, `/__openclaw__/a2ui/`.
|
||||
- This deployment model alone is not a security vulnerability.
|
||||
- Do **not** expose it to the public internet (no direct bind to `0.0.0.0`, no public reverse proxy). It is not hardened for public exposure.
|
||||
- If you need remote access, prefer an SSH tunnel or Tailscale serve/funnel (so the Gateway still binds to loopback), plus strong Gateway auth.
|
||||
- The Gateway HTTP surface includes the canvas host (`/__openclaw__/canvas/`, `/__openclaw__/a2ui/`). Treat canvas content as sensitive/untrusted and avoid exposing it beyond loopback unless you understand the risk.
|
||||
|
||||
## Runtime Requirements
|
||||
|
||||
### Node.js Version
|
||||
|
||||
OpenClaw requires **Node.js 22.12.0 or later** (LTS). This version includes important security patches:
|
||||
|
||||
- CVE-2025-59466: async_hooks DoS vulnerability
|
||||
- CVE-2026-21636: Permission model bypass vulnerability
|
||||
|
||||
Verify your Node.js version:
|
||||
|
||||
```bash
|
||||
node --version # Should be v22.12.0 or later
|
||||
```
|
||||
|
||||
### Docker Security
|
||||
|
||||
When running OpenClaw in Docker:
|
||||
|
||||
1. The official image runs as a non-root user (`node`) for reduced attack surface
|
||||
2. Use `--read-only` flag when possible for additional filesystem protection
|
||||
3. Limit container capabilities with `--cap-drop=ALL`
|
||||
|
||||
Example secure Docker run:
|
||||
|
||||
```bash
|
||||
docker run --read-only --cap-drop=ALL \
|
||||
-v openclaw-data:/app/data \
|
||||
openclaw/openclaw:latest
|
||||
```
|
||||
|
||||
## Security Scanning
|
||||
|
||||
This project uses `detect-secrets` for automated secret detection in CI/CD.
|
||||
See `.detect-secrets.cfg` for configuration and `.secrets.baseline` for the baseline.
|
||||
|
||||
Run locally:
|
||||
|
||||
```bash
|
||||
pip install detect-secrets==1.5.0
|
||||
detect-secrets scan --baseline .secrets.baseline
|
||||
```
|
||||
|
||||
@@ -1,33 +1,15 @@
|
||||
{
|
||||
"originHash" : "24a723309d7a0039d3df3051106f77ac1ed7068a02508e3a6804e41d757e6c72",
|
||||
"originHash" : "5d29ee82825e0764775562242cfa1ff4dc79584797dd638f76c9876545454748",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "commander",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/Commander.git",
|
||||
"state" : {
|
||||
"revision" : "9e349575c8e3c6745e81fe19e5bb5efa01b078ce",
|
||||
"version" : "0.2.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "elevenlabskit",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/ElevenLabsKit",
|
||||
"state" : {
|
||||
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
|
||||
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
|
||||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-concurrency-extras",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
|
||||
"state" : {
|
||||
"revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
|
||||
"version" : "1.3.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-syntax",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -45,24 +27,6 @@
|
||||
"revision" : "399f76dcd91e4c688ca2301fa24a8cc6d9927211",
|
||||
"version" : "0.99.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftui-math",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/gonzalezreal/swiftui-math",
|
||||
"state" : {
|
||||
"revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71",
|
||||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "textual",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/gonzalezreal/textual",
|
||||
"state" : {
|
||||
"revision" : "5b06b811c0f5313b6b84bbef98c635a630638c38",
|
||||
"version" : "0.3.1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
|
||||
@@ -13,7 +13,7 @@ let package = Package(
|
||||
.executable(name: "swabble", targets: ["SwabbleCLI"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/steipete/Commander.git", exact: "0.2.1"),
|
||||
.package(path: "../Peekaboo/Commander"),
|
||||
.package(url: "https://github.com/apple/swift-testing", from: "0.99.0"),
|
||||
],
|
||||
targets: [
|
||||
|
||||
@@ -101,8 +101,8 @@ Environment variables:
|
||||
- Authorization requested at first start; requires macOS 26 + new Speech.framework APIs.
|
||||
|
||||
## Development
|
||||
- Format: `./scripts/format.sh` (uses local `.swiftformat`)
|
||||
- Lint: `./scripts/lint.sh` (uses local `.swiftlint.yml`)
|
||||
- Format: `./scripts/format.sh` (uses ../peekaboo/.swiftformat if present)
|
||||
- Lint: `./scripts/lint.sh` (uses ../peekaboo/.swiftlint.yml if present)
|
||||
- Tests: `swift test` (uses swift-testing package)
|
||||
|
||||
## Roadmap
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
CONFIG="${ROOT}/.swiftformat"
|
||||
PEEKABOO_ROOT="${ROOT}/../peekaboo"
|
||||
if [ -f "${PEEKABOO_ROOT}/.swiftformat" ]; then
|
||||
CONFIG="${PEEKABOO_ROOT}/.swiftformat"
|
||||
else
|
||||
CONFIG="${ROOT}/.swiftformat"
|
||||
fi
|
||||
swiftformat --config "$CONFIG" "$ROOT/Sources"
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
CONFIG="${ROOT}/.swiftlint.yml"
|
||||
PEEKABOO_ROOT="${ROOT}/../peekaboo"
|
||||
if [ -f "${PEEKABOO_ROOT}/.swiftlint.yml" ]; then
|
||||
CONFIG="${PEEKABOO_ROOT}/.swiftlint.yml"
|
||||
else
|
||||
CONFIG="$ROOT/.swiftlint.yml"
|
||||
fi
|
||||
if ! command -v swiftlint >/dev/null; then
|
||||
echo "swiftlint not installed" >&2
|
||||
exit 1
|
||||
|
||||
110
VISION.md
110
VISION.md
@@ -1,110 +0,0 @@
|
||||
## OpenClaw Vision
|
||||
|
||||
OpenClaw is the AI that actually does things.
|
||||
It runs on your devices, in your channels, with your rules.
|
||||
|
||||
This document explains the current state and direction of the project.
|
||||
We are still early, so iteration is fast.
|
||||
Project overview and developer docs: [`README.md`](README.md)
|
||||
Contribution guide: [`CONTRIBUTING.md`](CONTRIBUTING.md)
|
||||
|
||||
OpenClaw started as a personal playground to learn AI and build something genuinely useful:
|
||||
an assistant that can run real tasks on a real computer.
|
||||
It evolved through several names and shells: Warelay -> Clawdbot -> Moltbot -> OpenClaw.
|
||||
|
||||
The goal: a personal assistant that is easy to use, supports a wide range of platforms, and respects privacy and security.
|
||||
|
||||
The current focus is:
|
||||
|
||||
Priority:
|
||||
|
||||
- Security and safe defaults
|
||||
- Bug fixes and stability
|
||||
- Setup reliability and first-run UX
|
||||
|
||||
Next priorities:
|
||||
|
||||
- Supporting all major model providers
|
||||
- Improving support for major messaging channels (and adding a few high-demand ones)
|
||||
- Performance and test infrastructure
|
||||
- Better computer-use and agent harness capabilities
|
||||
- Ergonomics across CLI and web frontend
|
||||
- Companion apps on macOS, iOS, Android, Windows, and Linux
|
||||
|
||||
Contribution rules:
|
||||
|
||||
- One PR = one issue/topic. Do not bundle multiple unrelated fixes/features.
|
||||
- PRs over ~5,000 changed lines are reviewed only in exceptional circumstances.
|
||||
- Do not open large batches of tiny PRs at once; each PR has review cost.
|
||||
- For very small related fixes, grouping into one focused PR is encouraged.
|
||||
|
||||
## Security
|
||||
|
||||
Security in OpenClaw is a deliberate tradeoff: strong defaults without killing capability.
|
||||
The goal is to stay powerful for real work while making risky paths explicit and operator-controlled.
|
||||
|
||||
Canonical security policy and reporting:
|
||||
|
||||
- [`SECURITY.md`](SECURITY.md)
|
||||
|
||||
We prioritize secure defaults, but also expose clear knobs for trusted high-power workflows.
|
||||
|
||||
## Plugins & Memory
|
||||
|
||||
OpenClaw has an extensive plugin API.
|
||||
Core stays lean; optional capability should usually ship as plugins.
|
||||
|
||||
Preferred plugin path is npm package distribution plus local extension loading for development.
|
||||
If you build a plugin, host and maintain it in your own repository.
|
||||
The bar for adding optional plugins to core is intentionally high.
|
||||
Plugin docs: [`docs/tools/plugin.md`](docs/tools/plugin.md)
|
||||
Community plugin listing + PR bar: https://docs.openclaw.ai/plugins/community
|
||||
|
||||
Memory is a special plugin slot where only one memory plugin can be active at a time.
|
||||
Today we ship multiple memory options; over time we plan to converge on one recommended default path.
|
||||
|
||||
### Skills
|
||||
|
||||
We still ship some bundled skills for baseline UX.
|
||||
New skills should be published to ClawHub first (`clawhub.ai`), not added to core by default.
|
||||
Core skill additions should be rare and require a strong product or security reason.
|
||||
|
||||
### MCP Support
|
||||
|
||||
OpenClaw supports MCP through `mcporter`: https://github.com/steipete/mcporter
|
||||
|
||||
This keeps MCP integration flexible and decoupled from core runtime:
|
||||
|
||||
- add or change MCP servers without restarting the gateway
|
||||
- keep core tool/context surface lean
|
||||
- reduce MCP churn impact on core stability and security
|
||||
|
||||
For now, we prefer this bridge model over building first-class MCP runtime into core.
|
||||
If there is an MCP server or feature `mcporter` does not support yet, please open an issue there.
|
||||
|
||||
### Setup
|
||||
|
||||
OpenClaw is currently terminal-first by design.
|
||||
This keeps setup explicit: users see docs, auth, permissions, and security posture up front.
|
||||
|
||||
Long term, we want easier onboarding flows as hardening matures.
|
||||
We do not want convenience wrappers that hide critical security decisions from users.
|
||||
|
||||
### Why TypeScript?
|
||||
|
||||
OpenClaw is primarily an orchestration system: prompts, tools, protocols, and integrations.
|
||||
TypeScript was chosen to keep OpenClaw hackable by default.
|
||||
It is widely known, fast to iterate in, and easy to read, modify, and extend.
|
||||
|
||||
## What We Will Not Merge (For Now)
|
||||
|
||||
- New core skills when they can live on ClawHub
|
||||
- Full-doc translation sets for all docs (deferred; we plan AI-generated translations later)
|
||||
- Commercial service integrations that do not clearly fit the model-provider category
|
||||
- Wrapper channels around already supported channels without a clear capability or security gap
|
||||
- First-class MCP runtime in core when `mcporter` already provides the integration path
|
||||
- Agent-hierarchy frameworks (manager-of-managers / nested planner trees) as a default architecture
|
||||
- Heavy orchestration layers that duplicate existing agent and tool infrastructure
|
||||
|
||||
This list is a roadmap guardrail, not a law of physics.
|
||||
Strong user demand and strong technical rationale can change it.
|
||||
761
appcast.xml
761
appcast.xml
@@ -1,534 +1,275 @@
|
||||
<?xml version="1.0" standalone="yes"?>
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>OpenClaw</title>
|
||||
<title>Clawdbot</title>
|
||||
<item>
|
||||
<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>2026031290</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.3.12</sparkle:shortVersionString>
|
||||
<title>2026.1.16-2</title>
|
||||
<pubDate>Sat, 17 Jan 2026 12:46:22 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>6273</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.16-2</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.3.12</h2>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.16-2</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<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>
|
||||
<li>CLI: stamp build commit into dist metadata so banners show the commit in npm installs.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<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>
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<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=="/>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.16-2/Clawdbot-2026.1.16-2.zip" length="21399591" type="application/octet-stream" sparkle:edSignature="zelT+KzN32cXsihbFniPF5Heq0hkwFfL3Agrh/AaoKUkr7kJAFarkGSOZRTWZ9y+DvOluzn2wHHjVigRjMzrBA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<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>2026030801</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.3.8-beta.1</sparkle:shortVersionString>
|
||||
<title>2026.1.15</title>
|
||||
<pubDate>Fri, 16 Jan 2026 10:31:53 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>5998</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.15</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.3.8-beta.1</h2>
|
||||
<h3>Changes</h3>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.15</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<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>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.8-beta.1/OpenClaw-2026.3.8-beta.1.zip" length="23407015" type="application/octet-stream" sparkle:edSignature="KCqhSmu4b0tHf55RqcQOHorsc55CgBI5BUmK/NTizxNq04INn/7QvsamHYQou9DbB2IW6B2nawBC4nn4au5yDA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.3.7</title>
|
||||
<pubDate>Sun, 08 Mar 2026 04:42:35 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026030790</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.3.7</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.3.7</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Agents/context engine plugin interface: add <code>ContextEngine</code> plugin slot with full lifecycle hooks (<code>bootstrap</code>, <code>ingest</code>, <code>assemble</code>, <code>compact</code>, <code>afterTurn</code>, <code>prepareSubagentSpawn</code>, <code>onSubagentEnded</code>), slot-based registry with config-driven resolution, <code>LegacyContextEngine</code> wrapper preserving existing compaction behavior, scoped subagent runtime for plugin runtimes via <code>AsyncLocalStorage</code>, and <code>sessions.get</code> gateway method. Enables plugins like <code>lossless-claw</code> to provide alternative context management strategies without modifying core compaction logic. Zero behavior change when no context engine plugin is configured. (#22201) thanks @jalehman.</li>
|
||||
<li>ACP/persistent channel bindings: add durable Discord channel and Telegram topic binding storage, routing resolution, and CLI/docs support so ACP thread targets survive restarts and can be managed consistently. (#34873) Thanks @dutifulbob.</li>
|
||||
<li>Telegram/ACP topic bindings: accept Telegram Mac Unicode dash option prefixes in <code>/acp spawn</code>, support Telegram topic thread binding (<code>--thread here|auto</code>), route bound-topic follow-ups to ACP sessions, add actionable Telegram approval buttons with prefixed approval-id resolution, and pin successful bind confirmations in-topic. (#36683) Thanks @huntharo.</li>
|
||||
<li>Telegram/topic agent routing: support per-topic <code>agentId</code> overrides in forum groups and DM topics so topics can route to dedicated agents with isolated sessions. (#33647; based on #31513) Thanks @kesor and @Sid-Qin.</li>
|
||||
<li>Web UI/i18n: add Spanish (<code>es</code>) locale support in the Control UI, including locale detection, lazy loading, and language picker labels across supported locales. (#35038) Thanks @DaoPromociones.</li>
|
||||
<li>Onboarding/web search: add provider selection step and full provider list in configure wizard, with SecretRef ref-mode support during onboarding. (#34009) Thanks @kesku and @thewilloftheshadow.</li>
|
||||
<li>Tools/Web search: switch Perplexity provider to Search API with structured results plus new language/region/time filters. (#33822) Thanks @kesku.</li>
|
||||
<li>Gateway: add SecretRef support for gateway.auth.token with auth-mode guardrails. (#35094) Thanks @joshavant.</li>
|
||||
<li>Docker/Podman extension dependency baking: add <code>OPENCLAW_EXTENSIONS</code> so container builds can preinstall selected bundled extension npm dependencies into the image for faster and more reproducible startup in container deployments. (#32223) Thanks @sallyom.</li>
|
||||
<li>Plugins/before_prompt_build system-context fields: add <code>prependSystemContext</code> and <code>appendSystemContext</code> so static plugin guidance can be placed in system prompt space for provider caching and lower repeated prompt token cost. (#35177) thanks @maweibin.</li>
|
||||
<li>Plugins/hook policy: add <code>plugins.entries.<id>.hooks.allowPromptInjection</code>, validate unknown typed hook names at runtime, and preserve legacy <code>before_agent_start</code> model/provider overrides while stripping prompt-mutating fields when prompt injection is disabled. (#36567) thanks @gumadeiras.</li>
|
||||
<li>Hooks/Compaction lifecycle: emit <code>session:compact:before</code> and <code>session:compact:after</code> internal events plus plugin compaction callbacks with session/count metadata, so automations can react to compaction runs consistently. (#16788) thanks @vincentkoc.</li>
|
||||
<li>Agents/compaction post-context configurability: add <code>agents.defaults.compaction.postCompactionSections</code> so deployments can choose which <code>AGENTS.md</code> sections are re-injected after compaction, while preserving legacy fallback behavior when the documented default pair is configured in any order. (#34556) thanks @efe-arv.</li>
|
||||
<li>TTS/OpenAI-compatible endpoints: add <code>messages.tts.openai.baseUrl</code> config support with config-over-env precedence, endpoint-aware directive validation, and OpenAI TTS request routing to the resolved base URL. (#34321) thanks @RealKai42.</li>
|
||||
<li>Slack/DM typing feedback: add <code>channels.slack.typingReaction</code> so Socket Mode DMs can show reaction-based processing status even when Slack native assistant typing is unavailable. (#19816) Thanks @dalefrieswthat.</li>
|
||||
<li>Discord/allowBots mention gating: add <code>allowBots: "mentions"</code> to only accept bot-authored messages that mention the bot. Thanks @thewilloftheshadow.</li>
|
||||
<li>Agents/tool-result truncation: preserve important tail diagnostics by using head+tail truncation for oversized tool results while keeping configurable truncation options. (#20076) thanks @jlwestsr.</li>
|
||||
<li>Cron/job snapshot persistence: skip backup during normalization persistence in <code>ensureLoaded</code> so <code>jobs.json.bak</code> keeps the pre-edit snapshot for recovery, while preserving backup creation on explicit user-driven writes. (#35234) Thanks @0xsline.</li>
|
||||
<li>CLI: make read-only SecretRef status flows degrade safely (#37023) thanks @joshavant.</li>
|
||||
<li>Tools/Diffs guidance: restore a short system-prompt hint for enabled diffs while keeping the detailed instructions in the companion skill, so diffs usage guidance stays out of user-prompt space. (#36904) thanks @gumadeiras.</li>
|
||||
<li>Tools/Diffs guidance loading: move diffs usage guidance from unconditional prompt-hook injection to the plugin companion skill path, reducing unrelated-turn prompt noise while keeping diffs tool behavior unchanged. (#32630) thanks @sircrumpet.</li>
|
||||
<li>Docs/Web search: remove outdated Brave free-tier wording and replace prescriptive AI ToS guidance with neutral compliance language in Brave setup docs. (#26860) Thanks @HenryLoenwind.</li>
|
||||
<li>Config/Compaction safeguard tuning: expose <code>agents.defaults.compaction.recentTurnsPreserve</code> and quality-guard retry knobs through the validated config surface and embedded-runner wiring, with regression coverage for real config loading and schema metadata. (#25557) thanks @rodrigouroz.</li>
|
||||
<li>iOS/App Store Connect release prep: align iOS bundle identifiers under <code>ai.openclaw.client</code>, refresh Watch app icons, add Fastlane metadata/screenshot automation, and support Keychain-backed ASC auth for uploads. (#38936) Thanks @ngutman.</li>
|
||||
<li>Mattermost/model picker: add Telegram-style interactive provider/model browsing for <code>/oc_model</code> and <code>/oc_models</code>, fix picker callback updates, and emit a normal confirmation reply when a model is selected. (#38767) thanks @mukhtharcm.</li>
|
||||
<li>Docker/multi-stage build: restructure Dockerfile as a multi-stage build to produce a minimal runtime image without build tools, source code, or Bun; add <code>OPENCLAW_VARIANT=slim</code> build arg for a bookworm-slim variant. (#38479) Thanks @sallyom.</li>
|
||||
<li>Google/Gemini 3.1 Flash-Lite: add first-class <code>google/gemini-3.1-flash-lite-preview</code> support across model-id normalization, default aliases, media-understanding image lookups, Google Gemini CLI forward-compat fallback, and docs.</li>
|
||||
<li>Plugins: add provider auth registry + <code>clawdbot models auth login</code> for plugin-driven OAuth/API key flows.</li>
|
||||
<li>Browser: improve remote CDP/Browserless support (auth passthrough, <code>wss</code> upgrade, timeouts, clearer errors).</li>
|
||||
<li>Heartbeat: per-agent configuration + 24h duplicate suppression. (#980) — thanks @voidserf.</li>
|
||||
<li>Security: audit warns on weak model tiers; app nodes store auth tokens encrypted (Keychain/SecurePrefs).</li>
|
||||
</ul>
|
||||
<h3>Breaking</h3>
|
||||
<ul>
|
||||
<li><strong>BREAKING:</strong> Gateway auth now requires explicit <code>gateway.auth.mode</code> when both <code>gateway.auth.token</code> and <code>gateway.auth.password</code> are configured (including SecretRefs). Set <code>gateway.auth.mode</code> to <code>token</code> or <code>password</code> before upgrade to avoid startup/pairing/TUI failures. (#35094) Thanks @joshavant.</li>
|
||||
<li><strong>BREAKING:</strong> iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)</li>
|
||||
<li><strong>BREAKING:</strong> Microsoft Teams is now a plugin; install <code>@clawdbot/msteams</code> via <code>clawdbot plugins install @clawdbot/msteams</code>.</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>CLI: set process titles to <code>clawdbot-<command></code> for clearer process listings.</li>
|
||||
<li>CLI/macOS: sync remote SSH target/identity to config and let <code>gateway status</code> auto-infer SSH targets (ssh-config aware).</li>
|
||||
<li>Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.</li>
|
||||
<li>Sessions/Security: add <code>session.dmScope</code> for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.</li>
|
||||
<li>Plugins: add provider auth registry + <code>clawdbot models auth login</code> for plugin-driven OAuth/API key flows.</li>
|
||||
<li>Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker.</li>
|
||||
<li>TUI: show provider/model labels for the active session and default model.</li>
|
||||
<li>Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.</li>
|
||||
<li>UI: show gateway auth guidance + doc link on unauthorized Control UI connections.</li>
|
||||
<li>Security: warn on weak model tiers (Haiku, below GPT-5, below Claude 4.5) in <code>clawdbot security audit</code>.</li>
|
||||
<li>Apps: store node auth tokens encrypted (Keychain/SecurePrefs).</li>
|
||||
<li>Daemon: share profile/state-dir resolution across service helpers and honor <code>CLAWDBOT_STATE_DIR</code> for Windows task scripts.</li>
|
||||
<li>Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.</li>
|
||||
<li>Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).</li>
|
||||
<li>Tools: normalize Slack/Discord message timestamps with <code>timestampMs</code>/<code>timestampUtc</code> while keeping raw provider fields.</li>
|
||||
<li>macOS: add <code>system.which</code> for prompt-free remote skill discovery (with gateway fallback to <code>system.run</code>).</li>
|
||||
<li>Docs: add Date & Time guide and update prompt/timezone configuration docs.</li>
|
||||
<li>Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.</li>
|
||||
<li>Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.</li>
|
||||
<li>Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in <code>/status</code> and <code>clawdbot models status</code>, and update docs.</li>
|
||||
<li>CLI: add <code>--json</code> output for <code>clawdbot daemon</code> lifecycle/install commands.</li>
|
||||
<li>Memory: make <code>node-llama-cpp</code> an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.</li>
|
||||
<li>Browser: add <code>snapshot refs=aria</code> (Playwright aria-ref ids) for self-resolving refs across <code>snapshot</code> → <code>act</code>.</li>
|
||||
<li>Browser: <code>profile="chrome"</code> now defaults to host control and returns clearer “attach a tab” errors.</li>
|
||||
<li>Browser: prefer stable Chrome for auto-detect, with Brave/Edge fallbacks and updated docs. (#983) — thanks @cpojer.</li>
|
||||
<li>Browser: increase remote CDP reachability timeouts + add <code>remoteCdpTimeoutMs</code>/<code>remoteCdpHandshakeTimeoutMs</code>.</li>
|
||||
<li>Browser: preserve auth/query tokens for remote CDP endpoints and pass Basic auth for CDP HTTP/WS. (#895) — thanks @mukhtharcm.</li>
|
||||
<li>Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.</li>
|
||||
<li>Telegram: allow custom commands in the bot menu (merged with native; conflicts ignored). (#860) — thanks @nachoiacovino.</li>
|
||||
<li>Discord: allow allowlisted guilds without channel lists to receive messages when <code>groupPolicy="allowlist"</code>. — thanks @thewilloftheshadow.</li>
|
||||
<li>Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Models/MiniMax: stop advertising removed <code>MiniMax-M2.5-Lightning</code> in built-in provider catalogs, onboarding metadata, and docs; keep the supported fast-tier model as <code>MiniMax-M2.5-highspeed</code>.</li>
|
||||
<li>Security/Config: fail closed when <code>loadConfig()</code> hits validation or read errors so invalid configs cannot silently fall back to permissive runtime defaults. (#9040) Thanks @joetomasone.</li>
|
||||
<li>Memory/Hybrid search: preserve negative FTS5 BM25 relevance ordering in <code>bm25RankToScore()</code> so stronger keyword matches rank above weaker ones instead of collapsing or reversing scores. (#33757) Thanks @lsdcc01.</li>
|
||||
<li>LINE/<code>requireMention</code> group gating: align inbound and reply-stage LINE group policy resolution across raw, <code>group:</code>, and <code>room:</code> keys (including account-scoped group config), preserve plugin-backed reply-stage fallback behavior, and add regression coverage for prefixed-only group/room config plus reply-stage policy resolution. (#35847) Thanks @kirisame-wang.</li>
|
||||
<li>Onboarding/local setup: default unset local <code>tools.profile</code> to <code>coding</code> instead of <code>messaging</code>, restoring file/runtime tools for fresh local installs while preserving explicit user-set profiles. (from #38241, overlap with #34958) Thanks @cgdusek.</li>
|
||||
<li>Gateway/Telegram stale-socket restart guard: only apply stale-socket restarts to channels that publish event-liveness timestamps, preventing Telegram providers from being misclassified as stale solely due to long uptime and avoiding restart/pairing storms after upgrade. (openclaw#38464)</li>
|
||||
<li>Onboarding/headless Linux daemon probe hardening: treat <code>systemctl --user is-enabled</code> probe failures as non-fatal during daemon install flow so onboarding no longer crashes on SSH/headless VPS environments before showing install guidance. (#37297) Thanks @acarbajal-web.</li>
|
||||
<li>Memory/QMD mcporter Windows spawn hardening: when <code>mcporter.cmd</code> launch fails with <code>spawn EINVAL</code>, retry via bare <code>mcporter</code> shell resolution so QMD recall can continue instead of falling back to builtin memory search. (#27402) Thanks @i0ivi0i.</li>
|
||||
<li>Tools/web_search Brave language-code validation: align <code>search_lang</code> handling with Brave-supported codes (including <code>zh-hans</code>, <code>zh-hant</code>, <code>en-gb</code>, and <code>pt-br</code>), map common alias inputs (<code>zh</code>, <code>ja</code>) to valid Brave values, and reject unsupported codes before upstream requests to prevent 422 failures. (#37260) Thanks @heyanming.</li>
|
||||
<li>Models/openai-completions streaming compatibility: force <code>compat.supportsUsageInStreaming=false</code> for non-native OpenAI-compatible endpoints during model normalization, preventing usage-only stream chunks from triggering <code>choices[0]</code> parser crashes in provider streams. (#8714) Thanks @nonanon1.</li>
|
||||
<li>Tools/xAI native web-search collision guard: drop OpenClaw <code>web_search</code> from tool registration when routing to xAI/Grok model providers (including OpenRouter <code>x-ai/*</code>) to avoid duplicate tool-name request failures against provider-native <code>web_search</code>. (#14749) Thanks @realsamrat.</li>
|
||||
<li>TUI/token copy-safety rendering: treat long credential-like mixed alphanumeric tokens (including quoted forms) as copy-sensitive in render sanitization so formatter hard-wrap guards no longer inject visible spaces into auth-style values before display. (#26710) Thanks @jasonthane.</li>
|
||||
<li>WhatsApp/self-chat response prefix fallback: stop forcing <code>"[openclaw]"</code> as the implicit outbound response prefix when no identity name or response prefix is configured, so blank/default prefix settings no longer inject branding text unexpectedly in self-chat flows. (#27962) Thanks @ecanmor.</li>
|
||||
<li>Memory/QMD search result decoding: accept <code>qmd search</code> hits that only include <code>file</code> URIs (for example <code>qmd://collection/path.md</code>) without <code>docid</code>, resolve them through managed collection roots, and keep multi-collection results keyed by file fallback so valid QMD hits no longer collapse to empty <code>memory_search</code> output. (#28181) Thanks @0x76696265.</li>
|
||||
<li>Memory/QMD collection-name conflict recovery: when <code>qmd collection add</code> fails because another collection already occupies the same <code>path + pattern</code>, detect the conflicting collection from <code>collection list</code>, remove it, and retry add so agent-scoped managed collections are created deterministically instead of being silently skipped; also add warning-only fallback when qmd metadata is unavailable to avoid destructive guesses. (#25496) Thanks @Ramsbaby.</li>
|
||||
<li>Slack/app_mention race dedupe: when <code>app_mention</code> dispatch wins while same-<code>ts</code> <code>message</code> prepare is still in-flight, suppress the later message dispatch so near-simultaneous Slack deliveries do not produce duplicate replies; keep single-retry behavior and add regression coverage for both dropped and successful message-prepare outcomes. (#37033) Thanks @Takhoffman.</li>
|
||||
<li>Gateway/chat streaming tool-boundary text retention: merge assistant delta segments into per-run chat buffers so pre-tool text is preserved in live chat deltas/finals when providers emit post-tool assistant segments as non-prefix snapshots. (#36957) Thanks @Datyedyeguy.</li>
|
||||
<li>TUI/model indicator freshness: prevent stale session snapshots from overwriting freshly patched model selection (and reset per-session freshness when switching session keys) so <code>/model</code> updates reflect immediately instead of lagging by one or more commands. (#21255) Thanks @kowza.</li>
|
||||
<li>TUI/final-error rendering fallback: when a chat <code>final</code> event has no renderable assistant content but includes envelope <code>errorMessage</code>, render the formatted error text instead of collapsing to <code>"(no output)"</code>, preserving actionable failure context in-session. (#14687) Thanks @Mquarmoc.</li>
|
||||
<li>TUI/session-key alias event matching: treat chat events whose session keys are canonical aliases (for example <code>agent:<id>:main</code> vs <code>main</code>) as the same session while preserving cross-agent isolation, so assistant replies no longer disappear or surface in another terminal window due to strict key-form mismatch. (#33937) Thanks @yjh1412.</li>
|
||||
<li>OpenAI Codex OAuth/login parity: keep <code>openclaw models auth login --provider openai-codex</code> on the built-in path even without provider plugins, preserve Pi-generated authorize URLs without local scope rewriting, and stop validating successful Codex sign-ins against the public OpenAI Responses API after callback. (#37558; follow-up to #36660 and #24720) Thanks @driesvints, @Skippy-Gunboat, and @obviyus.</li>
|
||||
<li>Agents/config schema lookup: add <code>gateway</code> tool action <code>config.schema.lookup</code> so agents can inspect one config path at a time before edits without loading the full schema into prompt context. (#37266) Thanks @gumadeiras.</li>
|
||||
<li>Onboarding/API key input hardening: strip non-Latin1 Unicode artifacts from normalized secret input (while preserving Latin-1 content and internal spaces) so malformed copied API keys cannot trigger HTTP header <code>ByteString</code> construction crashes; adds regression coverage for shared normalization and MiniMax auth header usage. (#24496) Thanks @fa6maalassaf.</li>
|
||||
<li>Kimi Coding/Anthropic tools compatibility: normalize <code>anthropic-messages</code> tool payloads to OpenAI-style <code>tools[].function</code> + compatible <code>tool_choice</code> when targeting Kimi Coding endpoints, restoring tool-call workflows that regressed after v2026.3.2. (#37038) Thanks @mochimochimochi-hub.</li>
|
||||
<li>Heartbeat/workspace-path guardrails: append explicit workspace <code>HEARTBEAT.md</code> path guidance (and <code>docs/heartbeat.md</code> avoidance) to heartbeat prompts so heartbeat runs target workspace checklists reliably across packaged install layouts. (#37037) Thanks @stofancy.</li>
|
||||
<li>Subagents/kill-complete announce race: when a late <code>subagent-complete</code> lifecycle event arrives after an earlier kill marker, clear stale kill suppression/cleanup flags and re-run announce cleanup so finished runs no longer get silently swallowed. (#37024) Thanks @cmfinlan.</li>
|
||||
<li>Agents/tool-result cleanup timeout hardening: on embedded runner teardown idle timeouts, clear pending tool-call state without persisting synthetic <code>missing tool result</code> entries, preventing timeout cleanups from poisoning follow-up turns; adds regression coverage for timeout clear-vs-flush behavior. (#37081) Thanks @Coyote-Den.</li>
|
||||
<li>Agents/openai-completions stream timeout hardening: ensure runtime undici global dispatchers use extended streaming body/header timeouts (including env-proxy dispatcher mode) before embedded runs, reducing forced mid-stream <code>terminated</code> failures on long generations; adds regression coverage for dispatcher selection and idempotent reconfiguration. (#9708) Thanks @scottchguard.</li>
|
||||
<li>Agents/fallback cooldown probe execution: thread explicit rate-limit cooldown probe intent from model fallback into embedded runner auth-profile selection so same-provider fallback attempts can actually run when all profiles are cooldowned for <code>rate_limit</code> (instead of failing pre-run as <code>No available auth profile</code>), while preserving default cooldown skip behavior and adding regression tests at both fallback and runner layers. (#13623) Thanks @asfura.</li>
|
||||
<li>Cron/OpenAI Codex OAuth refresh hardening: when <code>openai-codex</code> token refresh fails specifically on account-id extraction, reuse the cached access token instead of failing the run immediately, with regression coverage to keep non-Codex and unrelated refresh failures unchanged. (#36604) Thanks @laulopezreal.</li>
|
||||
<li>TUI/session isolation for <code>/new</code>: make <code>/new</code> allocate a unique <code>tui-<uuid></code> session key instead of resetting the shared agent session, so multiple TUI clients on the same agent stop receiving each other’s replies; also sanitize <code>/new</code> and <code>/reset</code> failure text before rendering in-terminal. Landed from contributor PR #39238 by @widingmarcus-cyber. Thanks @widingmarcus-cyber.</li>
|
||||
<li>Synology Chat/rate-limit env parsing: honor <code>SYNOLOGY_RATE_LIMIT=0</code> as an explicit value while still falling back to the default limit for malformed env values instead of partially parsing them. Landed from contributor PR #39197 by @scoootscooob. Thanks @scoootscooob.</li>
|
||||
<li>Voice-call/OpenAI Realtime STT config defaults: honor explicit <code>vadThreshold: 0</code> and <code>silenceDurationMs: 0</code> instead of silently replacing them with defaults. Landed from contributor PR #39196 by @scoootscooob. Thanks @scoootscooob.</li>
|
||||
<li>Voice-call/OpenAI TTS speed config: honor explicit <code>speed: 0</code> instead of silently replacing it with the default speed. Landed from contributor PR #39318 by @ql-wade. Thanks @ql-wade.</li>
|
||||
<li>launchd/runtime PID parsing: reject <code>pid <= 0</code> from <code>launchctl print</code> so the daemon state parser no longer treats kernel/non-running sentinel values as real process IDs. Landed from contributor PR #39281 by @mvanhorn. Thanks @mvanhorn.</li>
|
||||
<li>Cron/file permission hardening: enforce owner-only (<code>0600</code>) cron store/backup/run-log files and harden cron store + run-log directories to <code>0700</code>, including pre-existing directories from older installs. (#36078) Thanks @aerelune.</li>
|
||||
<li>Gateway/remote WS break-glass hostname support: honor <code>OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1</code> for <code>ws://</code> hostname URLs (not only private IP literals) across onboarding validation and runtime gateway connection checks, while still rejecting public IP literals and non-unicast IPv6 endpoints. (#36930) Thanks @manju-rn.</li>
|
||||
<li>Routing/binding lookup scalability: pre-index route bindings by channel/account and avoid full binding-list rescans on channel-account cache rollover, preventing multi-second <code>resolveAgentRoute</code> stalls in large binding configurations. (#36915) Thanks @songchenghao.</li>
|
||||
<li>Browser/session cleanup: track browser tabs opened by session-scoped browser tool runs and close tracked tabs during <code>sessions.reset</code>/<code>sessions.delete</code> runtime cleanup, preventing orphaned tabs and unbounded browser memory growth after session teardown. (#36666) Thanks @Harnoor6693.</li>
|
||||
<li>Plugin/hook install rollback hardening: stage installs under the canonical install base, validate and run dependency installs before publish, and restore updates by rename instead of deleting the target path, reducing partial-replace and symlink-rebind risk during install failures.</li>
|
||||
<li>Slack/local file upload allowlist parity: propagate <code>mediaLocalRoots</code> through the Slack send action pipeline so workspace-rooted attachments pass <code>assertLocalMediaAllowed</code> checks while non-allowlisted paths remain blocked. (synthesis: #36656; overlap considered from #36516, #36496, #36493, #36484, #32648, #30888) Thanks @2233admin.</li>
|
||||
<li>Agents/compaction safeguard pre-check: skip embedded compaction before entering the Pi SDK when a session has no real conversation messages, avoiding unnecessary LLM API calls on idle sessions. (#36451) thanks @Sid-Qin.</li>
|
||||
<li>Config/schema cache key stability: build merged schema cache keys with incremental hashing to avoid large single-string serialization and prevent <code>RangeError: Invalid string length</code> on high-cardinality plugin/channel metadata. (#36603) Thanks @powermaster888.</li>
|
||||
<li>iMessage/cron completion announces: strip leaked inline reply tags (for example <code>[[reply_to:6100]]</code>) from user-visible completion text so announcement deliveries do not expose threading metadata. (#24600) Thanks @vincentkoc.</li>
|
||||
<li>Control UI/iMessage duplicate reply routing: keep internal webchat turns on dispatcher delivery (instead of origin-channel reroute) so Control UI chats do not duplicate replies into iMessage, while preserving webchat-provider relayed routing for external surfaces. Fixes #33483. Thanks @alicexmolt.</li>
|
||||
<li>Sessions/daily reset transcript archival: archive prior transcript files during stale-session scheduled/daily resets by capturing the previous session entry before rollover, preventing orphaned transcript files on disk. (#35493) Thanks @byungsker.</li>
|
||||
<li>Feishu/group slash command detection: normalize group mention wrappers before command-authorization probing so mention-prefixed commands (for example <code>@Bot/model</code> and <code>@Bot /reset</code>) are recognized as gateway commands instead of being forwarded to the agent. (#35994) Thanks @liuxiaopai-ai.</li>
|
||||
<li>Control UI/auth token separation: keep the shared gateway token in browser auth validation while reserving cached device tokens for signed device payloads, preventing false <code>device token mismatch</code> disconnects after restart/rotation. Landed from contributor PR #37382 by @FradSer. Thanks @FradSer.</li>
|
||||
<li>Gateway/browser auth reconnect hardening: stop counting missing token/password submissions as auth rate-limit failures, and stop auto-reconnecting Control UI clients on non-recoverable auth errors so misconfigured browser tabs no longer lock out healthy sessions. Landed from contributor PR #38725 by @ademczuk. Thanks @ademczuk.</li>
|
||||
<li>Gateway/service token drift repair: stop persisting shared auth tokens into installed gateway service units, flag stale embedded service tokens for reinstall, and treat tokenless service env as canonical so token rotation/reboot flows stay aligned with config/env resolution. Landed from contributor PR #28428 by @l0cka. Thanks @l0cka.</li>
|
||||
<li>Control UI/agents-page selection: keep the edited agent selected after saving agent config changes and reloading the agents list, so <code>/agents</code> no longer snaps back to the default agent. Landed from contributor PR #39301 by @MumuTW. Thanks @MumuTW.</li>
|
||||
<li>Gateway/auth follow-up hardening: preserve systemd <code>EnvironmentFile=</code> precedence/source provenance in daemon audits and doctor repairs, block shared-password override flows from piggybacking cached device tokens, and fail closed when config-first gateway SecretRefs cannot resolve. Follow-up to #39241.</li>
|
||||
<li>Agents/context pruning: guard assistant thinking/text char estimation against malformed blocks (missing <code>thinking</code>/<code>text</code> strings or null entries) so pruning no longer crashes with malformed provider content. (openclaw#35146) thanks @Sid-Qin.</li>
|
||||
<li>Agents/transcript policy: set <code>preserveSignatures</code> to Anthropic-only handling in <code>resolveTranscriptPolicy</code> so Anthropic thinking signatures are preserved while non-Anthropic providers remain unchanged. (#32813) thanks @Sid-Qin.</li>
|
||||
<li>Agents/schema cleaning: detect Venice + Grok model IDs as xAI-proxied targets so unsupported JSON Schema keywords are stripped before requests, preventing Venice/Grok <code>Invalid arguments</code> failures. (openclaw#35355) thanks @Sid-Qin.</li>
|
||||
<li>Skills/native command deduplication: centralize skill command dedupe by canonical <code>skillName</code> in <code>listSkillCommandsForAgents</code> so duplicate suffixed variants (for example <code>_2</code>) are no longer surfaced across interfaces outside Discord. (#27521) thanks @shivama205.</li>
|
||||
<li>Agents/xAI tool-call argument decoding: decode HTML-entity encoded xAI/Grok tool-call argument values (<code>&</code>, <code>"</code>, <code><</code>, <code>></code>, numeric entities) before tool execution so commands with shell operators and quotes no longer fail with parse errors. (#35276) Thanks @Sid-Qin.</li>
|
||||
<li>Linux/WSL2 daemon install hardening: add regression coverage for WSL environment detection, WSL-specific systemd guidance, and <code>systemctl --user is-enabled</code> failure paths so WSL2/headless onboarding keeps treating bus-unavailable probes as non-fatal while preserving real permission errors. Related: #36495. Thanks @vincentkoc.</li>
|
||||
<li>Linux/systemd status and degraded-session handling: treat degraded-but-reachable <code>systemctl --user status</code> results as available, preserve early errors for truly unavailable user-bus cases, and report externally managed running services as running instead of <code>not installed</code>. Thanks @vincentkoc.</li>
|
||||
<li>Agents/thinking-tag promotion hardening: guard <code>promoteThinkingTagsToBlocks</code> against malformed assistant content entries (<code>null</code>/<code>undefined</code>) before <code>block.type</code> reads so malformed provider payloads no longer crash session processing while preserving pass-through behavior. (#35143) thanks @Sid-Qin.</li>
|
||||
<li>Gateway/Control UI version reporting: align runtime and browser client version metadata to avoid <code>dev</code> placeholders, wait for bootstrap version before first UI websocket connect, and only forward bootstrap <code>serverVersion</code> to same-origin gateway targets to prevent cross-target version leakage. (from #35230, #30928, #33928) Thanks @Sid-Qin, @joelnishanth, and @MoerAI.</li>
|
||||
<li>Control UI/markdown parser crash fallback: catch <code>marked.parse()</code> failures and fall back to escaped plain-text <code><pre></code> rendering so malformed recursive markdown no longer crashes Control UI session rendering on load. (#36445) Thanks @BinHPdev.</li>
|
||||
<li>Control UI/markdown fallback regression coverage: add explicit regression assertions for parser-error fallback behavior so malformed markdown no longer risks reintroducing hard-crash rendering paths in future markdown/parser upgrades. (#36445) Thanks @BinHPdev.</li>
|
||||
<li>Web UI/config form: treat <code>additionalProperties: true</code> object schemas as editable map entries instead of unsupported fields so Accounts-style maps stay editable in form mode. (#35380, supersedes #32072) Thanks @stakeswky and @liuxiaopai-ai.</li>
|
||||
<li>Feishu/streaming card delivery synthesis: unify snapshot and delta streaming merge semantics, apply overlap-aware final merge, suppress duplicate final text delivery (including text+media final packets), prefer topic-thread <code>message.reply</code> routing when a reply target exists, and tune card print cadence to avoid duplicate incremental rendering. (from #33245, #32896, #33840) Thanks @rexl2018, @kcinzgg, and @aerelune.</li>
|
||||
<li>Feishu/group mention detection: carry startup-probed bot display names through monitor dispatch so <code>requireMention</code> checks compare against current bot identity instead of stale config names, fixing missed <code>@bot</code> handling in groups while preserving multi-bot false-positive guards. (#36317, #34271) Thanks @liuxiaopai-ai.</li>
|
||||
<li>Security/dependency audit: patch transitive Hono vulnerabilities by pinning <code>hono</code> to <code>4.12.5</code> and <code>@hono/node-server</code> to <code>1.19.10</code> in production resolution paths. Thanks @shakkernerd.</li>
|
||||
<li>Security/dependency audit: bump <code>tar</code> to <code>7.5.10</code> (from <code>7.5.9</code>) to address the high-severity hardlink path traversal advisory (<code>GHSA-qffp-2rhf-9h96</code>). Thanks @shakkernerd.</li>
|
||||
<li>Cron/announce delivery robustness: bypass pending-descendant announce guards for cron completion sends, ensure named-agent announce routes have outbound session entries, and fall back to direct delivery only when an announce send was actually attempted and failed. (from #35185, #32443, #34987) Thanks @Sid-Qin, @scoootscooob, and @bmendonca3.</li>
|
||||
<li>Cron/announce best-effort fallback: run direct outbound fallback after attempted announce failures even when delivery is configured as best-effort, so Telegram cron sends are not left as attempted-but-undelivered after <code>cron announce delivery failed</code> warnings.</li>
|
||||
<li>Auto-reply/system events: restore runtime system events to the message timeline (<code>System:</code> lines), preserve think-hint parsing with prepended events, and carry events into deferred followup/collect/steer-backlog prompts to keep cache behavior stable without dropping queued metadata. (#34794) Thanks @anisoptera.</li>
|
||||
<li>Security/audit account handling: avoid prototype-chain account IDs in audit validation by using own-property checks for <code>accounts</code>. (#34982) Thanks @HOYALIM.</li>
|
||||
<li>Cron/restart catch-up semantics: replay interrupted recurring jobs and missed immediate cron slots on startup without replaying interrupted one-shot jobs, with guarded missed-slot probing to avoid malformed-schedule startup aborts and duplicate-trigger drift after restart. (from #34466, #34896, #34625, #33206) Thanks @dunamismax, @dsantoreis, @Octane0411, and @Sid-Qin.</li>
|
||||
<li>Venice/provider onboarding hardening: align per-model Venice completion-token limits with discovery metadata, clamp untrusted discovery values to safe bounds, sync the static Venice fallback catalog with current live model metadata, and disable tool wiring for Venice models that do not support function calling so default Venice setups no longer fail with <code>max_completion_tokens</code> or unsupported-tools 400s. Fixes #38168. Thanks @Sid-Qin, @powermaster888 and @vincentkoc.</li>
|
||||
<li>Agents/session usage tracking: preserve accumulated usage metadata on embedded Pi runner error exits so failed turns still update session <code>totalTokens</code> from real usage instead of stale prior values. (#34275) thanks @RealKai42.</li>
|
||||
<li>Slack/reaction thread context routing: carry Slack native DM channel IDs through inbound context and threading tool resolution so reaction targets resolve consistently for DM <code>To=user:*</code> sessions (including <code>toolContext.currentChannelId</code> fallback behavior). (from #34831; overlaps #34440, #34502, #34483, #32754) Thanks @dunamismax.</li>
|
||||
<li>Subagents/announce completion scoping: scope nested direct-child completion aggregation to the current requester run window, harden frozen completion capture for deterministic descendant synthesis, and route completion announce delivery through parent-agent announce turns with provenance-aware internal events. (#35080) Thanks @tyler6204.</li>
|
||||
<li>Nodes/system.run approval hardening: use explicit argv-mutation signaling when regenerating prepared <code>rawCommand</code>, and cover the <code>system.run.prepare -> system.run</code> handoff so direct PATH-based <code>nodes.run</code> commands no longer fail with <code>rawCommand does not match command</code>. (#33137) thanks @Sid-Qin.</li>
|
||||
<li>Models/custom provider headers: propagate <code>models.providers.<name>.headers</code> across inline, fallback, and registry-found model resolution so header-authenticated proxies consistently receive configured request headers. (#27490) thanks @Sid-Qin.</li>
|
||||
<li>Ollama/remote provider auth fallback: synthesize a local runtime auth key for explicitly configured <code>models.providers.ollama</code> entries that omit <code>apiKey</code>, so remote Ollama endpoints run without requiring manual dummy-key setup while preserving env/profile/config key precedence and missing-config failures. (#11283) Thanks @cpreecs.</li>
|
||||
<li>Ollama/custom provider headers: forward resolved model headers into native Ollama stream requests so header-authenticated Ollama proxies receive configured request headers. (#24337) thanks @echoVic.</li>
|
||||
<li>Ollama/compaction and summarization: register custom <code>api: "ollama"</code> handling for compaction, branch-style internal summarization, and TTS text summarization on current <code>main</code>, so native Ollama models no longer fail with <code>No API provider registered for api: ollama</code> outside the main run loop. Thanks @JaviLib.</li>
|
||||
<li>Daemon/systemd install robustness: treat <code>systemctl --user is-enabled</code> exit-code-4 <code>not-found</code> responses as not-enabled by combining stderr/stdout detail parsing, so Ubuntu fresh installs no longer fail with <code>systemctl is-enabled unavailable</code>. (#33634) Thanks @Yuandiaodiaodiao.</li>
|
||||
<li>Slack/system-event session routing: resolve reaction/member/pin/interaction system-event session keys through channel/account bindings (with sender-aware DM routing) so inbound Slack events target the correct agent session in multi-account setups instead of defaulting to <code>agent:main</code>. (#34045) Thanks @paulomcg, @daht-mad and @vincentkoc.</li>
|
||||
<li>Slack/native streaming markdown conversion: stop pre-normalizing text passed to Slack native <code>markdown_text</code> in streaming start/append/stop paths to prevent Markdown style corruption from double conversion. (#34931)</li>
|
||||
<li>Gateway/HTTP tools invoke media compatibility: preserve raw media payload access for direct <code>/tools/invoke</code> clients by allowing media <code>nodes</code> invoke commands only in HTTP tool context, while keeping agent-context media invoke blocking to prevent base64 prompt bloat. (#34365) Thanks @obviyus.</li>
|
||||
<li>Security/archive ZIP hardening: extract ZIP entries via same-directory temp files plus atomic rename, then re-open and reject post-rename hardlink alias races outside the destination root.</li>
|
||||
<li>Agents/Nodes media outputs: add dedicated <code>photos_latest</code> action handling, block media-returning <code>nodes invoke</code> commands, keep metadata-only <code>camera.list</code> invoke allowed, and normalize empty <code>photos_latest</code> results to a consistent response shape to prevent base64 context bloat. (#34332) Thanks @obviyus.</li>
|
||||
<li>TUI/session-key canonicalization: normalize <code>openclaw tui --session</code> values to lowercase so uppercase session names no longer drop real-time streaming updates due to gateway/TUI key mismatches. (#33866, #34013) thanks @lynnzc.</li>
|
||||
<li>iMessage/echo loop hardening: strip leaked assistant-internal scaffolding from outbound iMessage replies, drop reflected assistant-content messages before they re-enter inbound processing, extend echo-cache text retention for delayed reflections, and suppress repeated loop traffic before it amplifies into queue overflow. (#33295) Thanks @joelnishanth.</li>
|
||||
<li>Skills/workspace boundary hardening: reject workspace and extra-dir skill roots or <code>SKILL.md</code> files whose realpath escapes the configured source root, and skip syncing those escaped skills into sandbox workspaces.</li>
|
||||
<li>Outbound/send config threading: pass resolved SecretRef config through outbound adapters and helper send paths so send flows do not reload unresolved runtime config. (#33987) Thanks @joshavant.</li>
|
||||
<li>gateway: harden shared auth resolution across systemd, discord, and node host (#39241) Thanks @joshavant.</li>
|
||||
<li>Secrets/models.json persistence hardening: keep SecretRef-managed api keys + headers from persisting in generated models.json, expand audit/apply coverage, and harden marker handling/serialization. (#38955) Thanks @joshavant.</li>
|
||||
<li>Sessions/subagent attachments: remove <code>attachments[].content.maxLength</code> from <code>sessions_spawn</code> schema to avoid llama.cpp GBNF repetition overflow, and preflight UTF-8 byte size before buffer allocation while keeping runtime file-size enforcement unchanged. (#33648) Thanks @anisoptera.</li>
|
||||
<li>Runtime/tool-state stability: recover from dangling Anthropic <code>tool_use</code> after compaction, serialize long-running Discord handler runs without blocking new inbound events, and prevent stale busy snapshots from suppressing stuck-channel recovery. (from #33630, #33583) Thanks @kevinWangSheng and @theotarr.</li>
|
||||
<li>ACP/Discord startup hardening: clean up stuck ACP worker children on gateway restart, unbind stale ACP thread bindings during Discord startup reconciliation, and add per-thread listener watchdog timeouts so wedged turns cannot block later messages. (#33699) Thanks @dutifulbob.</li>
|
||||
<li>Extensions/media local-root propagation: consistently forward <code>mediaLocalRoots</code> through extension <code>sendMedia</code> adapters (Google Chat, Slack, iMessage, Signal, WhatsApp), preserving non-local media behavior while restoring local attachment resolution from configured roots. Synthesis of #33581, #33545, #33540, #33536, #33528. Thanks @bmendonca3.</li>
|
||||
<li>Gateway/plugin HTTP auth hardening: require gateway auth when any overlapping matched route needs it, block mixed-auth fallthrough at dispatch, and reject mixed-auth exact/prefix route overlaps during plugin registration.</li>
|
||||
<li>Feishu/video media send contract: keep mp4-like outbound payloads on <code>msg_type: "media"</code> (including reply and reply-in-thread paths) so videos render as media instead of degrading to file-link behavior, while preserving existing non-video file subtype handling. (from #33720, #33808, #33678) Thanks @polooooo, @dingjianrui, and @kevinWangSheng.</li>
|
||||
<li>Gateway/security default response headers: add <code>Permissions-Policy: camera=(), microphone=(), geolocation=()</code> to baseline gateway HTTP security headers for all responses. (#30186) thanks @habakan.</li>
|
||||
<li>Plugins/startup loading: lazily initialize plugin runtime, split startup-critical plugin SDK imports into <code>openclaw/plugin-sdk/core</code> and <code>openclaw/plugin-sdk/telegram</code>, and preserve <code>api.runtime</code> reflection semantics for plugin compatibility. (#28620) thanks @hmemcpy.</li>
|
||||
<li>Plugins/startup performance: reduce bursty plugin discovery/manifest overhead with short in-process caches, skip importing bundled memory plugins that are disabled by slot selection, and speed legacy root <code>openclaw/plugin-sdk</code> compatibility via runtime root-alias routing while preserving backward compatibility. Thanks @gumadeiras.</li>
|
||||
<li>Build/lazy runtime boundaries: replace ineffective dynamic import sites with dedicated lazy runtime boundaries across Slack slash handling, Telegram audit, CLI send deps, memory fallback, and outbound delivery paths while preserving behavior. (#33690) thanks @gumadeiras.</li>
|
||||
<li>Gateway/password CLI hardening: add <code>openclaw gateway run --password-file</code>, warn when inline <code>--password</code> is used because it can leak via process listings, and document env/file-backed password input as the preferred startup path. Fixes #27948. Thanks @vibewrk and @vincentkoc.</li>
|
||||
<li>Config/heartbeat legacy-path handling: auto-migrate top-level <code>heartbeat</code> into <code>agents.defaults.heartbeat</code> (with merge semantics that preserve explicit defaults), and keep startup failures on non-migratable legacy entries in the detailed invalid-config path instead of generic migration-failed errors. (#32706) thanks @xiwan.</li>
|
||||
<li>Plugins/SDK subpath parity: expand plugin SDK subpaths across bundled channels/extensions (Discord, Slack, Signal, iMessage, WhatsApp, LINE, and bundled companion plugins), with build/export/type/runtime wiring so scoped imports resolve consistently in source and dist while preserving compatibility. (#33737) thanks @gumadeiras.</li>
|
||||
<li>Google/Gemini Flash model selection: switch built-in <code>gemini-flash</code> defaults and docs/examples from the nonexistent <code>google/gemini-3.1-flash-preview</code> ID to the working <code>google/gemini-3-flash-preview</code>, while normalizing legacy OpenClaw config that still uses the old Flash 3.1 alias.</li>
|
||||
<li>Plugins/bundled scoped-import migration: migrate bundled plugins from monolithic <code>openclaw/plugin-sdk</code> imports to scoped subpaths (or <code>openclaw/plugin-sdk/core</code>) across registration and startup-sensitive runtime files, add CI/release guardrails to prevent regressions, and keep root <code>openclaw/plugin-sdk</code> support for external/community plugins. Thanks @gumadeiras.</li>
|
||||
<li>Routing/session duplicate suppression synthesis: align shared session delivery-context inheritance, channel-paired route-field merges, and reply-surface target matching so dmScope=main turns avoid cross-surface duplicate replies while thread-aware forwarding keeps intended routing semantics. (from #33629, #26889, #17337, #33250) Thanks @Yuandiaodiaodiao, @kevinwildenradt, @Glucksberg, and @bmendonca3.</li>
|
||||
<li>Routing/legacy session route inheritance: preserve external route metadata inheritance for legacy channel session keys (<code>agent:<agent>:<channel>:<peer></code> and <code>...:thread:<id></code>) so <code>chat.send</code> does not incorrectly fall back to webchat when valid delivery context exists. Follow-up to #33786.</li>
|
||||
<li>Routing/legacy route guard tightening: require legacy session-key channel hints to match the saved delivery channel before inheriting external routing metadata, preventing custom namespaced keys like <code>agent:<agent>:work:<ticket></code> from inheriting stale non-webchat routes.</li>
|
||||
<li>Gateway/internal client routing continuity: prevent webchat/TUI/UI turns from inheriting stale external reply routes by requiring explicit <code>deliver: true</code> for external delivery, keeping main-session external inheritance scoped to non-Webchat/UI clients, and honoring configured <code>session.mainKey</code> when identifying main-session continuity. (from #35321, #34635, #35356) Thanks @alexyyyander and @Octane0411.</li>
|
||||
<li>Security/auth labels: remove token and API-key snippets from user-facing auth status labels so <code>/status</code> and <code>/models</code> do not expose credential fragments. (#33262) thanks @cu1ch3n.</li>
|
||||
<li>Models/MiniMax portal vision routing: add <code>MiniMax-VL-01</code> to the <code>minimax-portal</code> provider, route portal image understanding through the MiniMax VLM endpoint, and align media auto-selection plus Telegram sticker description with the shared portal image provider path. (#33953) Thanks @tars90percent.</li>
|
||||
<li>Auth/credential semantics: align profile eligibility + probe diagnostics with SecretRef/expiry rules and harden browser download atomic writes. (#33733) thanks @joshavant.</li>
|
||||
<li>Security/audit denyCommands guidance: suggest likely exact node command IDs for unknown <code>gateway.nodes.denyCommands</code> entries so ineffective denylist entries are easier to correct. (#29713) thanks @liquidhorizon88-bot.</li>
|
||||
<li>Agents/overload failover handling: classify overloaded provider failures separately from rate limits/status timeouts, add short overload backoff before retry/failover, record overloaded prompt/assistant failures as transient auth-profile cooldowns (with probeable same-provider fallback) instead of treating them like persistent auth/billing failures, and keep one-shot cron retry classification aligned so overloaded fallback summaries still count as transient retries.</li>
|
||||
<li>Docs/security hardening guidance: document Docker <code>DOCKER-USER</code> + UFW policy and add cross-linking from Docker install docs for VPS/public-host setups. (#27613) thanks @dorukardahan.</li>
|
||||
<li>Docs/security threat-model links: replace relative <code>.md</code> links with Mintlify-compatible root-relative routes in security docs to prevent broken internal navigation. (#27698) thanks @clawdoo.</li>
|
||||
<li>Plugins/Update integrity drift: avoid false integrity drift prompts when updating npm-installed plugins from unpinned specs, while keeping drift checks for exact pinned versions. (#37179) Thanks @vincentkoc.</li>
|
||||
<li>iOS/Voice timing safety: guard system speech start/finish callbacks to the active utterance to avoid misattributed start events during rapid stop/restart cycles. (#33304) thanks @mbelinky; original implementation direction by @ngutman.</li>
|
||||
<li>Gateway/chat.send command scopes: require <code>operator.admin</code> for persistent <code>/config set|unset</code> writes routed through gateway chat clients while keeping <code>/config show</code> available to normal write-scoped operator clients, preserving messaging-channel config command behavior without widening RPC write scope into admin config mutation. Thanks @tdjackey for reporting.</li>
|
||||
<li>iOS/Talk incremental speech pacing: allow long punctuation-free assistant chunks to start speaking at safe whitespace boundaries so voice responses begin sooner instead of waiting for terminal punctuation. (#33305) thanks @mbelinky; original implementation by @ngutman.</li>
|
||||
<li>iOS/Watch reply reliability: make watch session activation waiters robust under concurrent requests so status/send calls no longer hang intermittently, and align delegate callbacks with Swift 6 actor safety. (#33306) thanks @mbelinky; original implementation by @Rocuts.</li>
|
||||
<li>Docs/tool-loop detection config keys: align <code>docs/tools/loop-detection.md</code> examples and field names with the current <code>tools.loopDetection</code> schema to prevent copy-paste validation failures from outdated keys. (#33182) Thanks @Mylszd.</li>
|
||||
<li>Gateway/session agent discovery: include disk-scanned agent IDs in <code>listConfiguredAgentIds</code> even when <code>agents.list</code> is configured, so disk-only/ACP agent sessions remain visible in gateway session aggregation and listings. (#32831) thanks @Sid-Qin.</li>
|
||||
<li>Discord/inbound debouncer: skip bot-own MESSAGE_CREATE events before they reach the debounce queue to avoid self-triggered slowdowns in busy servers. Thanks @thewilloftheshadow.</li>
|
||||
<li>Discord/Agent-scoped media roots: pass <code>mediaLocalRoots</code> through Discord monitor reply delivery (message + component interaction paths) so local media attachments honor per-agent workspace roots instead of falling back to default global roots. Thanks @thewilloftheshadow.</li>
|
||||
<li>Discord/slash command handling: intercept text-based slash commands in channels, register plugin commands as native, and send fallback acknowledgments for empty slash runs so interactions do not hang. Thanks @thewilloftheshadow.</li>
|
||||
<li>Discord/thread session lifecycle: reset thread-scoped sessions when a thread is archived so reopening a thread starts fresh without deleting transcript history. Thanks @thewilloftheshadow.</li>
|
||||
<li>Discord/presence defaults: send an online presence update on ready when no custom presence is configured so bots no longer appear offline by default. Thanks @thewilloftheshadow.</li>
|
||||
<li>Discord/typing cleanup: stop typing indicators after silent/NO_REPLY runs by marking the run complete before dispatch idle cleanup. Thanks @thewilloftheshadow.</li>
|
||||
<li>ACP/sandbox spawn parity: block <code>/acp spawn</code> from sandboxed requester sessions with the same host-runtime guard already enforced for <code>sessions_spawn({ runtime: "acp" })</code>, preserving non-sandbox ACP flows while closing the command-path policy gap. Thanks @patte.</li>
|
||||
<li>Discord/config SecretRef typing: align Discord account token config typing with SecretInput so SecretRef tokens typecheck. (#32490) Thanks @scoootscooob.</li>
|
||||
<li>Discord/voice messages: request upload slots with JSON fetch calls so voice message uploads no longer fail with content-type errors. Thanks @thewilloftheshadow.</li>
|
||||
<li>Discord/voice decoder fallback: drop the native Opus dependency and use opusscript for voice decoding to avoid native-opus installs. Thanks @thewilloftheshadow.</li>
|
||||
<li>Discord/auto presence health signal: add runtime availability-driven presence updates plus connected-state reporting to improve health monitoring and operator visibility. (#33277) Thanks @thewilloftheshadow.</li>
|
||||
<li>HEIC image inputs: accept HEIC/HEIF <code>input_image</code> sources in Gateway HTTP APIs, normalize them to JPEG before provider delivery, and document the expanded default MIME allowlist. Thanks @vincentkoc.</li>
|
||||
<li>Gateway/HEIC input follow-up: keep non-HEIC <code>input_image</code> MIME handling unchanged, make HEIC tests hermetic, and enforce chat-completions <code>maxTotalImageBytes</code> against post-normalization image payload size. Thanks @vincentkoc.</li>
|
||||
<li>Telegram/draft-stream boundary stability: materialize DM draft previews at assistant-message/tool boundaries, serialize lane-boundary callbacks before final delivery, and scope preview cleanup to the active preview so multi-step Telegram streams no longer lose, overwrite, or leave stale preview bubbles. (#33842) Thanks @ngutman.</li>
|
||||
<li>Telegram/DM draft finalization reliability: require verified final-text draft emission before treating preview finalization as delivered, and fall back to normal payload send when final draft delivery is not confirmed (preventing missing final responses and preserving media/button delivery). (#32118) Thanks @OpenCils.</li>
|
||||
<li>Telegram/DM draft final delivery: materialize text-only <code>sendMessageDraft</code> previews into one permanent final message and skip duplicate final payload sends, while preserving fallback behavior when materialization fails. (#34318) Thanks @Brotherinlaw-13.</li>
|
||||
<li>Telegram/DM draft duplicate display: clear stale DM draft previews after materializing the real final message, including threadless fallback when DM topic lookup fails, so partial streaming no longer briefly shows duplicate replies. (#36746) Thanks @joelnishanth.</li>
|
||||
<li>Telegram/draft preview boundary + silent-token reliability: stabilize answer-lane message boundaries across late-partial/message-start races, preserve/reset finalized preview state at the correct boundaries, and suppress <code>NO_REPLY</code> lead-fragment leaks without broad heartbeat-prefix false positives. (#33169) Thanks @obviyus.</li>
|
||||
<li>Telegram/native commands <code>commands.allowFrom</code> precedence: make native Telegram commands honor <code>commands.allowFrom</code> as the command-specific authorization source, including group chats, instead of falling back to channel sender allowlists. (#28216) Thanks @toolsbybuddy and @vincentkoc.</li>
|
||||
<li>Telegram/<code>groupAllowFrom</code> sender-ID validation: restore sender-only runtime validation so negative chat/group IDs remain invalid entries instead of appearing accepted while still being unable to authorize group access. (#37134) Thanks @qiuyuemartin-max and @vincentkoc.</li>
|
||||
<li>Telegram/native group command auth: authorize native commands in groups and forum topics against <code>groupAllowFrom</code> and per-group/topic sender overrides, while keeping auth rejection replies in the originating topic thread. (#39267) Thanks @edwluo.</li>
|
||||
<li>Telegram/named-account DMs: restore non-default-account DM routing when a named Telegram account falls back to the default agent by keeping groups fail-closed but deriving a per-account session key for DMs, including identity-link canonicalization and regression coverage for account isolation. (from #32426; fixes #32351) Thanks @chengzhichao-xydt.</li>
|
||||
<li>Discord/audit wildcard warnings: ignore "\*" wildcard keys when counting unresolved guild channels so doctor/status no longer warns on allow-all configs. (#33125) Thanks @thewilloftheshadow.</li>
|
||||
<li>Discord/channel resolution: default bare numeric recipients to channels, harden allowlist numeric ID handling with safe fallbacks, and avoid inbound WS heartbeat stalls. (#33142) Thanks @thewilloftheshadow.</li>
|
||||
<li>Discord/chunk delivery reliability: preserve chunk ordering when using a REST client and retry chunk sends on 429/5xx using account retry settings. (#33226) Thanks @thewilloftheshadow.</li>
|
||||
<li>Discord/mention handling: add id-based mention formatting + cached rewrites, resolve inbound mentions to display names, and add optional ignoreOtherMentions gating (excluding @everyone/@here). (#33224) Thanks @thewilloftheshadow.</li>
|
||||
<li>Discord/media SSRF allowlist: allow Discord CDN hostnames (including wildcard domains) in inbound media SSRF policy to prevent proxy/VPN fake-ip blocks. (#33275) Thanks @thewilloftheshadow.</li>
|
||||
<li>Telegram/device pairing notifications: auto-arm one-shot notify on <code>/pair qr</code>, auto-ping on new pairing requests, and add manual fallback via <code>/pair approve latest</code> if the ping does not arrive. (#33299) thanks @mbelinky.</li>
|
||||
<li>Exec heartbeat routing: scope exec-triggered heartbeat wakes to agent session keys so unrelated agents are no longer awakened by exec events, while preserving legacy unscoped behavior for non-canonical session keys. (#32724) thanks @altaywtf</li>
|
||||
<li>macOS/Tailscale remote gateway discovery: add a Tailscale Serve fallback peer probe path (<code>wss://<peer>.ts.net</code>) when Bonjour and wide-area DNS-SD discovery return no gateways, and refresh both discovery paths from macOS onboarding. (#32860) Thanks @ngutman.</li>
|
||||
<li>iOS/Gateway keychain hardening: move gateway metadata and TLS fingerprints to device keychain storage with safer migration behavior and rollback-safe writes to reduce credential loss risk during upgrades. (#33029) thanks @mbelinky.</li>
|
||||
<li>iOS/Concurrency stability: replace risky shared-state access in camera and gateway connection paths with lock-protected access patterns to reduce crash risk under load. (#33241) thanks @mbelinky.</li>
|
||||
<li>iOS/Security guardrails: limit production API-key sourcing to app config and make deep-link confirmation prompts safer by coalescing queued requests instead of silently dropping them. (#33031) thanks @mbelinky.</li>
|
||||
<li>iOS/TTS playback fallback: keep voice playback resilient by switching from PCM to MP3 when provider format support is unavailable, while avoiding sticky fallback on generic local playback errors. (#33032) thanks @mbelinky.</li>
|
||||
<li>Plugin outbound/text-only adapter compatibility: allow direct-delivery channel plugins that only implement <code>sendText</code> (without <code>sendMedia</code>) to remain outbound-capable, gracefully fall back to text delivery for media payloads when <code>sendMedia</code> is absent, and fail explicitly for media-only payloads with no text fallback. (#32788) thanks @liuxiaopai-ai.</li>
|
||||
<li>Telegram/multi-account default routing clarity: warn only for ambiguous (2+) account setups without an explicit default, add <code>openclaw doctor</code> warnings for missing/invalid multi-account defaults across channels, and document explicit-default guidance for channel routing and Telegram config. (#32544) thanks @Sid-Qin.</li>
|
||||
<li>Telegram/plugin outbound hook parity: run <code>message_sending</code> + <code>message_sent</code> in Telegram reply delivery, include reply-path hook metadata (<code>mediaUrls</code>, <code>threadId</code>), and report <code>message_sent.success=false</code> when hooks blank text and no outbound message is delivered. (#32649) Thanks @KimGLee.</li>
|
||||
<li>CLI/Coding-agent reliability: switch default <code>claude-cli</code> non-interactive args to <code>--permission-mode bypassPermissions</code>, auto-normalize legacy <code>--dangerously-skip-permissions</code> backend overrides to the modern permission-mode form, align coding-agent + live-test docs with the non-PTY Claude path, and emit session system-event heartbeat notices when CLI watchdog no-output timeouts terminate runs. (#28610, #31149, #34055). Thanks @niceysam, @cryptomaltese and @vincentkoc.</li>
|
||||
<li>Gateway/OpenAI chat completions: parse active-turn <code>image_url</code> content parts (including parameterized data URIs and guarded URL sources), forward them as multimodal <code>images</code>, accept image-only user turns, enforce per-request image-part/byte budgets, default URL-based image fetches to disabled unless explicitly enabled by config, and redact image base64 data in cache-trace/provider payload diagnostics. (#17685) Thanks @vincentkoc</li>
|
||||
<li>ACP/ACPX session bootstrap: retry with <code>sessions new</code> when <code>sessions ensure</code> returns no session identifiers so ACP spawns avoid <code>NO_SESSION</code>/<code>ACP_TURN_FAILED</code> failures on affected agents. (#28786, #31338, #34055). Thanks @Sid-Qin and @vincentkoc.</li>
|
||||
<li>ACP/sessions_spawn parent stream visibility: add <code>streamTo: "parent"</code> for <code>runtime: "acp"</code> to forward initial child-run progress/no-output/completion updates back into the requester session as system events (instead of direct child delivery), and emit a tail-able session-scoped relay log (<code><sessionId>.acp-stream.jsonl</code>, returned as <code>streamLogPath</code> when available), improving orchestrator visibility for blocked or long-running harness turns. (#34310, #29909; reopened from #34055). Thanks @vincentkoc.</li>
|
||||
<li>Agents/bootstrap truncation warning handling: unify bootstrap budget/truncation analysis across embedded + CLI runtime, <code>/context</code>, and <code>openclaw doctor</code>; add <code>agents.defaults.bootstrapPromptTruncationWarning</code> (<code>off|once|always</code>, default <code>once</code>) and persist warning-signature metadata so truncation warnings are consistent and deduped across turns. (#32769) Thanks @gumadeiras.</li>
|
||||
<li>Agents/Skills runtime loading: propagate run config into embedded attempt and compaction skill-entry loading so explicitly enabled bundled companion skills are discovered consistently when skill snapshots do not already provide resolved entries. Thanks @gumadeiras.</li>
|
||||
<li>Agents/Session startup date grounding: substitute <code>YYYY-MM-DD</code> placeholders in startup/post-compaction AGENTS context and append runtime current-time lines for <code>/new</code> and <code>/reset</code> prompts so daily-memory references resolve correctly. (#32381) Thanks @chengzhichao-xydt.</li>
|
||||
<li>Agents/Compaction template heading alignment: update AGENTS template section names to <code>Session Startup</code>/<code>Red Lines</code> and keep legacy <code>Every Session</code>/<code>Safety</code> fallback extraction so post-compaction context remains intact across template versions. (#25098) thanks @echoVic.</li>
|
||||
<li>Agents/Compaction continuity: expand staged-summary merge instructions to preserve active task status, batch progress, latest user request, and follow-up commitments so compaction handoffs retain in-flight work context. (#8903) thanks @joetomasone.</li>
|
||||
<li>Agents/Compaction safeguard structure hardening: require exact fallback summary headings, sanitize untrusted compaction instruction text before prompt embedding, and keep structured sections when preserving all turns. (#25555) thanks @rodrigouroz.</li>
|
||||
<li>Gateway/status self version reporting: make Gateway self version in <code>openclaw status</code> prefer runtime <code>VERSION</code> (while preserving explicit <code>OPENCLAW_VERSION</code> override), preventing stale post-upgrade app version output. (#32655) thanks @liuxiaopai-ai.</li>
|
||||
<li>Memory/QMD index isolation: set <code>QMD_CONFIG_DIR</code> alongside <code>XDG_CONFIG_HOME</code> so QMD config state stays per-agent despite upstream XDG handling bugs, preventing cross-agent collection indexing and excess disk/CPU usage. (#27028) thanks @HenryLoenwind.</li>
|
||||
<li>Memory/QMD collection safety: stop destructive collection rebinds when QMD <code>collection list</code> only reports names without path metadata, preventing <code>memory search</code> from dropping existing collections if re-add fails. (#36870) Thanks @Adnannnnnnna.</li>
|
||||
<li>Memory/QMD duplicate-document recovery: detect <code>UNIQUE constraint failed: documents.collection, documents.path</code> update failures, rebuild managed collections once, and retry update so periodic QMD syncs recover instead of failing every run; includes regression coverage to avoid over-matching unrelated unique constraints. (#27649) Thanks @MiscMich.</li>
|
||||
<li>Memory/local embedding initialization hardening: add regression coverage for transient initialization retry and mixed <code>embedQuery</code> + <code>embedBatch</code> concurrent startup to lock single-flight initialization behavior. (#15639) thanks @SubtleSpark.</li>
|
||||
<li>CLI/Coding-agent reliability: switch default <code>claude-cli</code> non-interactive args to <code>--permission-mode bypassPermissions</code>, auto-normalize legacy <code>--dangerously-skip-permissions</code> backend overrides to the modern permission-mode form, align coding-agent + live-test docs with the non-PTY Claude path, and emit session system-event heartbeat notices when CLI watchdog no-output timeouts terminate runs. Related to #28261. Landed from contributor PRs #28610 and #31149. Thanks @niceysam, @cryptomaltese and @vincentkoc.</li>
|
||||
<li>ACP/ACPX session bootstrap: retry with <code>sessions new</code> when <code>sessions ensure</code> returns no session identifiers so ACP spawns avoid <code>NO_SESSION</code>/<code>ACP_TURN_FAILED</code> failures on affected agents. Related to #28786. Landed from contributor PR #31338. Thanks @Sid-Qin and @vincentkoc.</li>
|
||||
<li>LINE/auth boundary hardening synthesis: enforce strict LINE webhook authn/z boundary semantics across pairing-store account scoping, DM/group allowlist separation, fail-closed webhook auth/runtime behavior, and replay/duplication controls (including in-flight replay reservation and post-success dedupe marking). (from #26701, #26683, #25978, #17593, #16619, #31990, #26047, #30584, #18777) Thanks @bmendonca3, @davidahmann, @harshang03, @haosenwang1018, @liuxiaopai-ai, @coygeek, and @Takhoffman.</li>
|
||||
<li>LINE/media download synthesis: fix file-media download handling and M4A audio classification across overlapping LINE regressions. (from #26386, #27761, #27787, #29509, #29755, #29776, #29785, #32240) Thanks @kevinWangSheng, @loiie45e, @carrotRakko, @Sid-Qin, @codeafridi, and @bmendonca3.</li>
|
||||
<li>LINE/context and routing synthesis: fix group/room peer routing and command-authorization context propagation, and keep processing later events in mixed-success webhook batches. (from #21955, #24475, #27035, #28286) Thanks @lailoo, @mcaxtr, @jervyclaw, @Glucksberg, and @Takhoffman.</li>
|
||||
<li>LINE/status/config/webhook synthesis: fix status false positives from snapshot/config state and accept LINE webhook HEAD probes for compatibility. (from #10487, #25726, #27537, #27908, #31387) Thanks @BlueBirdBack, @stakeswky, @loiie45e, @puritysb, and @mcaxtr.</li>
|
||||
<li>LINE cleanup/test follow-ups: fold cleanup/test learnings into the synthesis review path while keeping runtime changes focused on regression fixes. (from #17630, #17289) Thanks @Clawborn and @davidahmann.</li>
|
||||
<li>Mattermost/interactive buttons: add interactive button send/callback support with directory-based channel/user target resolution, and harden callbacks via account-scoped HMAC verification plus sender-scoped DM routing. (#19957) thanks @tonydehnke.</li>
|
||||
<li>Feishu/groupPolicy legacy alias compatibility: treat legacy <code>groupPolicy: "allowall"</code> as <code>open</code> in both schema parsing and runtime policy checks so intended open-group configs no longer silently drop group messages when <code>groupAllowFrom</code> is empty. (from #36358) Thanks @Sid-Qin.</li>
|
||||
<li>Mattermost/plugin SDK import policy: replace remaining monolithic <code>openclaw/plugin-sdk</code> imports in Mattermost mention-gating paths/tests with scoped subpaths (<code>openclaw/plugin-sdk/compat</code> and <code>openclaw/plugin-sdk/mattermost</code>) so <code>pnpm check</code> passes <code>lint:plugins:no-monolithic-plugin-sdk-entry-imports</code> on baseline. (#36480) Thanks @Takhoffman.</li>
|
||||
<li>Telegram/polls: add Telegram poll action support to channel action discovery and tool/CLI poll flows, with multi-account discoverability gated to accounts that can actually execute polls (<code>sendMessage</code> + <code>poll</code>). (#36547) thanks @gumadeiras.</li>
|
||||
<li>Agents/failover cooldown classification: stop treating generic <code>cooling down</code> text as provider <code>rate_limit</code> so healthy models no longer show false global cooldown/rate-limit warnings while explicit <code>model_cooldown</code> markers still trigger failover. (#32972) thanks @stakeswky.</li>
|
||||
<li>Agents/failover service-unavailable handling: stop treating bare proxy/CDN <code>service unavailable</code> errors as provider overload while keeping them retryable via the timeout/failover path, so transient outages no longer show false rate-limit warnings or block fallback. (#36646) thanks @jnMetaCode.</li>
|
||||
<li>Plugins/HTTP route migration diagnostics: rewrite legacy <code>api.registerHttpHandler(...)</code> loader failures into actionable migration guidance so doctor/plugin diagnostics point operators to <code>api.registerHttpRoute(...)</code> or <code>registerPluginHttpRoute(...)</code>. (#36794) Thanks @vincentkoc</li>
|
||||
<li>Doctor/Heartbeat upgrade diagnostics: warn when heartbeat delivery is configured with an implicit <code>directPolicy</code> so upgrades pin direct/DM behavior explicitly instead of relying on the current default. (#36789) Thanks @vincentkoc.</li>
|
||||
<li>Agents/current-time UTC anchor: append a machine-readable UTC suffix alongside local <code>Current time:</code> lines in shared cron-style prompt contexts so agents can compare UTC-stamped workspace timestamps without doing timezone math. (#32423) thanks @jriff.</li>
|
||||
<li>Ollama/local model handling: preserve explicit lower <code>contextWindow</code> / <code>maxTokens</code> overrides during merge refresh, and keep native Ollama streamed replies from surfacing fallback <code>thinking</code> / <code>reasoning</code> text once real content starts streaming. (#39292) Thanks @vincentkoc.</li>
|
||||
<li>TUI/webchat command-owner scope alignment: treat internal-channel gateway sessions with <code>operator.admin</code> as owner-authorized in command auth, restoring cron/gateway/connector tool access for affected TUI/webchat sessions while keeping external channels on identity-based owner checks. (from #35666, #35673, #35704) Thanks @Naylenv, @Octane0411, and @Sid-Qin.</li>
|
||||
<li>Discord/inbound timeout isolation: separate inbound worker timeout tracking from listener timeout budgets so queued Discord replies are no longer dropped when listener watchdog windows expire mid-run. (#36602) Thanks @dutifulbob.</li>
|
||||
<li>Memory/doctor SecretRef handling: treat SecretRef-backed memory-search API keys as configured, and fail embedding setup with explicit unresolved-secret errors instead of crashing. (#36835) Thanks @joshavant.</li>
|
||||
<li>Memory/flush default prompt: ban timestamped variant filenames during default memory flush runs so durable notes stay in the canonical daily <code>memory/YYYY-MM-DD.md</code> file. (#34951) thanks @zerone0x.</li>
|
||||
<li>Agents/reply delivery timing: flush embedded Pi block replies before waiting on compaction retries so already-generated assistant replies reach channels before compaction wait completes. (#35489) thanks @Sid-Qin.</li>
|
||||
<li>Agents/gateway config guidance: stop exposing <code>config.schema</code> through the agent <code>gateway</code> tool, remove prompt/docs guidance that told agents to call it, and keep agents on <code>config.get</code> plus <code>config.patch</code>/<code>config.apply</code> for config changes. (#7382) thanks @kakuteki.</li>
|
||||
<li>Provider/KiloCode: Keep duplicate models after malformed discovery rows, and strip legacy <code>reasoning_effort</code> when proxy reasoning injection is skipped. (#32352) Thanks @pandemicsyn and @vincentkoc.</li>
|
||||
<li>Agents/failover: classify periodic provider limit exhaustion text (for example <code>Weekly/Monthly Limit Exhausted</code>) as <code>rate_limit</code> while keeping explicit <code>402 Payment Required</code> variants in billing, so failover continues without misclassifying billing-wrapped quota errors. (#33813) thanks @zhouhe-xydt.</li>
|
||||
<li>Mattermost/interactive button callbacks: allow external callback base URLs and stop requiring loopback-origin requests so button clicks work when Mattermost reaches the gateway over Tailscale, LAN, or a reverse proxy. (#37543) thanks @mukhtharcm.</li>
|
||||
<li>Gateway/chat.send route inheritance: keep explicit external delivery for channel-scoped sessions while preventing shared-main and other channel-agnostic webchat sessions from inheriting stale external routes, so Control UI replies stay on webchat without breaking selected channel-target sessions. (#34669) Thanks @vincentkoc.</li>
|
||||
<li>Telegram/Discord media upload caps: make outbound uploads honor channel <code>mediaMaxMb</code> config, raise Telegram's default media cap to 100MB, and remove MIME fallback limits that kept some Telegram uploads at 16MB. Thanks @vincentkoc.</li>
|
||||
<li>Skills/nano-banana-pro resolution override: respect explicit <code>--resolution</code> values during image editing and only auto-detect output size from input images when the flag is omitted. (#36880) Thanks @shuofengzhang and @vincentkoc.</li>
|
||||
<li>Skills/openai-image-gen CLI validation: validate <code>--background</code> and <code>--style</code> inputs early, normalize supported values, and warn when those flags are ignored for incompatible models. (#36762) Thanks @shuofengzhang and @vincentkoc.</li>
|
||||
<li>Skills/openai-image-gen output formats: validate <code>--output-format</code> values early, normalize aliases like <code>jpg -> jpeg</code>, and warn when the flag is ignored for incompatible models. (#36648) Thanks @shuofengzhang and @vincentkoc.</li>
|
||||
<li>ACP/skill env isolation: strip skill-injected API keys from ACP harness child-process environments so tools like Codex CLI keep their own auth flow instead of inheriting billed provider keys from active skills. (#36316) Thanks @taw0002 and @vincentkoc.</li>
|
||||
<li>WhatsApp media upload caps: make outbound media sends and auto-replies honor <code>channels.whatsapp.mediaMaxMb</code> with per-account overrides so inbound and outbound limits use the same channel config. Thanks @vincentkoc.</li>
|
||||
<li>Windows/Plugin install: when OpenClaw runs on Windows via Bun and <code>npm-cli.js</code> is not colocated with the runtime binary, fall back to <code>npm.cmd</code>/<code>npx.cmd</code> through the existing <code>cmd.exe</code> wrapper so <code>openclaw plugins install</code> no longer fails with <code>spawn EINVAL</code>. (#38056) Thanks @0xlin2023.</li>
|
||||
<li>Telegram/send retry classification: retry grammY <code>Network request ... failed after N attempts</code> envelopes in send flows without reclassifying plain <code>Network request ... failed!</code> wrappers as transient, restoring the intended retry path while keeping broad send-context message matching tight. (#38056) Thanks @0xlin2023.</li>
|
||||
<li>Gateway/probes: keep <code>/health</code>, <code>/healthz</code>, <code>/ready</code>, and <code>/readyz</code> reachable when the Control UI is mounted at <code>/</code>, preserve plugin-owned route precedence on those paths, and make <code>/ready</code> and <code>/readyz</code> report channel-backed readiness with startup grace plus <code>503</code> on disconnected managed channels, while <code>/health</code> and <code>/healthz</code> stay shallow liveness probes. (#18446) Thanks @vibecodooor, @mahsumaktas, and @vincentkoc.</li>
|
||||
<li>Feishu/media downloads: drop invalid timeout fields from SDK method calls now that client-level <code>httpTimeoutMs</code> applies to requests. (#38267) Thanks @ant1eicher and @thewilloftheshadow.</li>
|
||||
<li>PI embedded runner/Feishu docs: propagate sender identity into embedded attempts so Feishu doc auto-grant restores requester access for embedded-runner executions. (#32915) thanks @cszhouwei.</li>
|
||||
<li>Agents/usage normalization: normalize missing or partial assistant usage snapshots before compaction accounting so <code>openclaw agent --json</code> no longer crashes when provider payloads omit <code>totalTokens</code> or related usage fields. (#34977) thanks @sp-hk2ldn.</li>
|
||||
<li>Venice/default model refresh: switch the built-in Venice default to <code>kimi-k2-5</code>, update onboarding aliasing, and refresh Venice provider docs/recommendations to match the current private and anonymized catalog. (from #12964) Fixes #20156. Thanks @sabrinaaquino and @vincentkoc.</li>
|
||||
<li>Agents/skill API write pacing: add a global prompt guardrail that treats skill-driven external API writes as rate-limited by default, so runners prefer batched writes, avoid tight request loops, and respect <code>429</code>/<code>Retry-After</code>. Thanks @vincentkoc.</li>
|
||||
<li>Google Chat/multi-account webhook auth fallback: when <code>channels.googlechat.accounts.default</code> carries shared webhook audience/path settings (for example after config normalization), inherit those defaults for named accounts while preserving top-level and per-account overrides, so inbound webhook verification no longer fails silently for named accounts missing duplicated audience fields. Fixes #38369.</li>
|
||||
<li>Models/tool probing: raise the tool-capability probe budget from 32 to 256 tokens so reasoning models that spend tokens on thinking before returning a required tool call are less likely to be misclassified as not supporting tools. (#7521) Thanks @jakobdylanc.</li>
|
||||
<li>Gateway/transient network classification: treat wrapped <code>...: fetch failed</code> transport messages as transient while avoiding broad matches like <code>Web fetch failed (404): ...</code>, preventing Discord reconnect wrappers from crashing the gateway without suppressing non-network tool failures. (#38530) Thanks @xinhuagu.</li>
|
||||
<li>ACP/console silent reply suppression: filter ACP <code>NO_REPLY</code> lead fragments and silent-only finals before <code>openclaw agent</code> logging/delivery so console-backed ACP sessions no longer leak <code>NO</code>/<code>NO_REPLY</code> placeholders. (#38436) Thanks @ql-wade.</li>
|
||||
<li>Feishu/reply delivery reliability: disable block streaming in Feishu reply options so plain-text auto-render replies are no longer silently dropped before final delivery. (#38258) Thanks @xinhuagu.</li>
|
||||
<li>Agents/reply MEDIA delivery: normalize local assistant <code>MEDIA:</code> paths before block/final delivery, keep media dedupe aligned with message-tool sends, and contain malformed media normalization failures so generated files send reliably instead of falling back to empty responses. (#38572) Thanks @obviyus.</li>
|
||||
<li>Sessions/bootstrap cache rollover invalidation: clear cached workspace bootstrap snapshots whenever an existing <code>sessionKey</code> rolls to a new <code>sessionId</code> across auto-reply, command, and isolated cron session resolvers, so <code>AGENTS.md</code>/<code>MEMORY.md</code>/<code>USER.md</code> updates are reloaded after daily, idle, or forced session resets instead of staying stale until gateway restart. (#38494) Thanks @LivingInDrm.</li>
|
||||
<li>Gateway/Telegram polling health monitor: skip stale-socket restarts for Telegram long-polling channels and thread channel identity through shared health evaluation so polling connections are not restarted on the WebSocket stale-socket heuristic. (#38395) Thanks @ql-wade and @Takhoffman.</li>
|
||||
<li>Daemon/systemd fresh-install probe: check for OpenClaw's managed user unit before running <code>systemctl --user is-enabled</code>, so first-time Linux installs no longer fail on generic missing-unit probe errors. (#38819) Thanks @adaHubble.</li>
|
||||
<li>Gateway/container lifecycle: allow <code>openclaw gateway stop</code> to SIGTERM unmanaged gateway listeners and <code>openclaw gateway restart</code> to SIGUSR1 a single unmanaged listener when no service manager is installed, so container and supervisor-based deployments are no longer blocked by <code>service disabled</code> no-op responses. Fixes #36137. Thanks @vincentkoc.</li>
|
||||
<li>Gateway/Windows restart supervision: relaunch task-managed gateways through Scheduled Task with quoted helper-script command paths, distinguish restart-capable supervisors per platform, and stop orphaned Windows gateway children during self-restart. (#38825) Thanks @obviyus.</li>
|
||||
<li>Telegram/native topic command routing: resolve forum-topic native commands through the same conversation route as inbound messages so topic <code>agentId</code> overrides and bound topic sessions target the active session instead of the default topic-parent session. (#38871) Thanks @obviyus.</li>
|
||||
<li>Markdown/assistant image hardening: flatten remote markdown images to plain text across the Control UI, exported HTML, and shared Swift chat while keeping inline <code>data:image/...</code> markdown renderable, so model output no longer triggers automatic remote image fetches. (#38895) Thanks @obviyus.</li>
|
||||
<li>Config/compaction safeguard settings: regression-test <code>agents.defaults.compaction.recentTurnsPreserve</code> through <code>loadConfig()</code> and cover the new help metadata entry so the exposed preserve knob stays wired through schema validation and config UX. (#25557) thanks @rodrigouroz.</li>
|
||||
<li>iOS/Quick Setup presentation: skip automatic Quick Setup when a gateway is already configured (active connect config, last-known connection, preferred gateway, or manual host), so reconnecting installs no longer get prompted to connect again. (#38964) Thanks @ngutman.</li>
|
||||
<li>CLI/Docs memory help accuracy: clarify <code>openclaw memory status --deep</code> behavior and align memory command examples/docs with the current search options. (#31803) Thanks @JasonOA888 and @Avi974.</li>
|
||||
<li>Auto-reply/allowlist store account scoping: keep <code>/allowlist ... --store</code> writes scoped to the selected account and clear legacy unscoped entries when removing default-account store access, preventing cross-account default allowlist bleed-through from legacy pairing-store reads. Thanks @tdjackey for reporting and @vincentkoc for the fix.</li>
|
||||
<li>Security/Nostr: harden profile mutation/import loopback guards by failing closed on non-loopback forwarded client headers (<code>x-forwarded-for</code> / <code>x-real-ip</code>) and rejecting <code>sec-fetch-site: cross-site</code>; adds regression coverage for proxy-forwarded and browser cross-site mutation attempts.</li>
|
||||
<li>CLI/bootstrap Node version hint maintenance: replace hardcoded nvm <code>22</code> instructions in <code>openclaw.mjs</code> with <code>MIN_NODE_MAJOR</code> interpolation so future minimum-Node bumps keep startup guidance in sync automatically. (#39056) Thanks @onstash.</li>
|
||||
<li>Discord/native slash command auth: honor <code>commands.allowFrom.discord</code> (and <code>commands.allowFrom["*"]</code>) in guild slash-command pre-dispatch authorization so allowlisted senders are no longer incorrectly rejected as unauthorized. (#38794) Thanks @jskoiz and @thewilloftheshadow.</li>
|
||||
<li>Outbound/message target normalization: ignore empty legacy <code>to</code>/<code>channelId</code> fields when explicit <code>target</code> is provided so valid target-based sends no longer fail legacy-param validation; includes regression coverage. (#38944) Thanks @Narcooo.</li>
|
||||
<li>Models/auth token prompts: guard cancelled manual token prompts so <code>Symbol(clack:cancel)</code> values cannot be persisted into auth profiles; adds regression coverage for cancelled <code>models auth paste-token</code>. (#38951) Thanks @MumuTW.</li>
|
||||
<li>Gateway/loopback announce URLs: treat <code>http://</code> and <code>https://</code> aliases with the same loopback/private-network policy as websocket URLs so loopback cron announce delivery no longer fails secure URL validation. (#39064) Thanks @Narcooo.</li>
|
||||
<li>Models/default provider fallback: when the hardcoded default provider is removed from <code>models.providers</code>, resolve defaults from configured providers instead of reporting stale removed-provider defaults in status output. (#38947) Thanks @davidemanuelDEV.</li>
|
||||
<li>Agents/cache-trace stability: guard stable stringify against circular references in trace payloads so near-limit payloads no longer crash with <code>Maximum call stack size exceeded</code>; adds regression coverage. (#38935) Thanks @MumuTW.</li>
|
||||
<li>Extensions/diffs CI stability: add <code>headers</code> to the <code>localReq</code> test helper in <code>extensions/diffs/index.test.ts</code> so forwarding-hint checks no longer crash with <code>req.headers</code> undefined. (supersedes #39063) Thanks @Shennng.</li>
|
||||
<li>Agents/compaction thresholding: apply <code>agents.defaults.contextTokens</code> cap to the model passed into embedded run and <code>/compact</code> session creation so auto-compaction thresholds use the effective context window, not native model max context. (#39099) Thanks @MumuTW.</li>
|
||||
<li>Models/merge mode provider precedence: when <code>models.mode: "merge"</code> is active and config explicitly sets a provider <code>baseUrl</code>, keep config as source of truth instead of preserving stale runtime <code>models.json</code> <code>baseUrl</code> values; includes normalized provider-key coverage. (#39103) Thanks @BigUncle.</li>
|
||||
<li>UI/Control chat tool streaming: render tool events live in webchat without requiring refresh by enabling <code>tool-events</code> capability, fixing stream/event correlation, and resetting/reloading stream state around tool results and terminal events. (#39104) Thanks @jakepresent.</li>
|
||||
<li>Models/provider apiKey persistence hardening: when a provider <code>apiKey</code> value equals a known provider env var value, persist the canonical env var name into <code>models.json</code> instead of resolved plaintext secrets. (#38889) Thanks @gambletan.</li>
|
||||
<li>Discord/model picker persistence check: add a short post-dispatch settle delay before reading back session model state so picker confirmations stop reporting false mismatch warnings after successful model switches. (#39105) Thanks @akropp.</li>
|
||||
<li>Agents/OpenAI WS compat store flag: omit <code>store</code> from <code>response.create</code> payloads when model compat sets <code>supportsStore: false</code>, preventing strict OpenAI-compatible providers from rejecting websocket requests with unknown-field errors. (#39113) Thanks @scoootscooob.</li>
|
||||
<li>Config/validation log sanitization: sanitize config-validation issue paths/messages before logging so control characters and ANSI escape sequences cannot inject misleading terminal output from crafted config content. (#39116) Thanks @powermaster888.</li>
|
||||
<li>Agents/compaction counter accuracy: count successful overflow-triggered auto-compactions (<code>willRetry=true</code>) in the compaction counter while still excluding aborted/no-result events, so <code>/status</code> reflects actual safeguard compaction activity. (#39123) Thanks @MumuTW.</li>
|
||||
<li>Gateway/chat delta ordering: flush buffered assistant deltas before emitting tool <code>start</code> events so pre-tool text is delivered to Control UI before tool cards, avoiding transient text/tool ordering artifacts in streaming. (#39128) Thanks @0xtangping.</li>
|
||||
<li>Voice-call plugin schema parity: add missing manifest <code>configSchema</code> fields (<code>webhookSecurity</code>, <code>streaming.preStartTimeoutMs|maxPendingConnections|maxPendingConnectionsPerIp|maxConnections</code>, <code>staleCallReaperSeconds</code>) so gateway AJV validation accepts already-supported runtime config instead of failing with <code>additionalProperties</code> errors. (#38892) Thanks @giumex.</li>
|
||||
<li>Agents/OpenAI WS reconnect retry accounting: avoid double retry scheduling when reconnect failures emit both <code>error</code> and <code>close</code>, so retry budgets track actual reconnect attempts instead of exhausting early. (#39133) Thanks @scoootscooob.</li>
|
||||
<li>Daemon/Windows schtasks runtime detection: use locale-invariant <code>Last Run Result</code> running codes (<code>0x41301</code>/<code>267009</code>) as the primary running signal so <code>openclaw node status</code> no longer misreports active tasks as stopped on non-English Windows locales. (#39076) Thanks @ademczuk.</li>
|
||||
<li>Usage/token count formatting: round near-million token counts to millions (<code>1.0m</code>) instead of <code>1000k</code>, with explicit boundary coverage for <code>999_499</code> and <code>999_500</code>. (#39129) Thanks @CurryMessi.</li>
|
||||
<li>Gateway/session bootstrap cache invalidation ordering: clear bootstrap snapshots only after active embedded-run shutdown wait completes, preventing dying runs from repopulating stale cache between <code>/new</code>/<code>sessions.reset</code> turns. (#38873) Thanks @MumuTW.</li>
|
||||
<li>Browser/dispatcher error clarity: preserve dispatcher-side failure context in browser fetch errors while still appending operator guidance and explicit no-retry model hints, preventing misleading <code>"Can't reach service"</code> wrapping and avoiding LLM retry loops. (#39090) Thanks @NewdlDewdl.</li>
|
||||
<li>Telegram/polling offset safety: confirm persisted offsets before polling startup while validating stored <code>lastUpdateId</code> values as non-negative safe integers (with overflow guards) so malformed offset state cannot cause update skipping/dropping. (#39111) Thanks @MumuTW.</li>
|
||||
<li>Telegram/status SecretRef read-only resolution: resolve env-backed bot-token SecretRefs in config-only/status inspection while respecting provider source/defaults and env allowlists, so status no longer crashes or reports false-ready tokens for disallowed providers. (#39130) Thanks @neocody.</li>
|
||||
<li>Agents/OpenAI WS max-token zero forwarding: treat <code>maxTokens: 0</code> as an explicit value in websocket <code>response.create</code> payloads (instead of dropping it as falsy), with regression coverage for zero-token forwarding. (#39148) Thanks @scoootscooob.</li>
|
||||
<li>Podman/.env gateway bind precedence: evaluate <code>OPENCLAW_GATEWAY_BIND</code> after sourcing <code>.env</code> in <code>run-openclaw-podman.sh</code> so env-file overrides are honored. (#38785) Thanks @majinyu666.</li>
|
||||
<li>Models/default alias refresh: bump <code>gpt</code> to <code>openai/gpt-5.4</code> and Gemini defaults to <code>gemini-3.1</code> preview aliases (including normalization/default wiring) to track current model IDs. (#38638) Thanks @ademczuk.</li>
|
||||
<li>Config/env substitution degraded mode: convert missing <code>${VAR}</code> resolution in config reads from hard-fail to warning-backed degraded behavior, while preventing unresolved placeholders from being accepted as gateway credentials. (#39050) Thanks @akz142857.</li>
|
||||
<li>Discord inbound listener non-blocking dispatch: make <code>MESSAGE_CREATE</code> listener handoff asynchronous (no per-listener queue blocking), so long runs no longer stall unrelated incoming events. (#39154) Thanks @yaseenkadlemakki.</li>
|
||||
<li>Daemon/Windows PATH freeze fix: stop persisting install-time <code>PATH</code> snapshots into Scheduled Task scripts so runtime tool lookup follows current host PATH updates; also refresh local TUI history on silent local finals. (#39139) Thanks @Narcooo.</li>
|
||||
<li>Gateway/systemd service restart hardening: clear stale gateway listeners by explicit run-port before service bind, add restart stale-pid port-override support, tune systemd start/stop/exit handling, and disable detached child mode only in service-managed runtime so cgroup stop semantics clean up descendants reliably. (#38463) Thanks @spirittechie.</li>
|
||||
<li>Discord/plugin native command aliases: let plugins declare provider-specific slash names so native Discord registration can avoid built-in command collisions; the bundled Talk voice plugin now uses <code>/talkvoice</code> natively on Discord while keeping text <code>/voice</code>.</li>
|
||||
<li>Daemon/Windows schtasks status normalization: derive runtime state from locale-neutral numeric <code>Last Run Result</code> codes only (without language string matching) and surface unknown when numeric result data is unavailable, preventing locale-specific misclassification drift. (#39153) Thanks @scoootscooob.</li>
|
||||
<li>Telegram/polling conflict recovery: reset the polling <code>webhookCleared</code> latch on <code>getUpdates</code> 409 conflicts so webhook cleanup re-runs on restart cycles and polling avoids infinite conflict loops. (#39205) Thanks @amittell.</li>
|
||||
<li>Heartbeat/requests-in-flight scheduling: stop advancing <code>nextDueMs</code> and avoid immediate <code>scheduleNext()</code> timer overrides on requests-in-flight skips, so wake-layer retry cooldowns are honored and heartbeat cadence no longer drifts under sustained contention. (#39182) Thanks @MumuTW.</li>
|
||||
<li>Memory/SQLite contention resilience: re-apply <code>PRAGMA busy_timeout</code> on every sync-store and QMD connection open so process restarts/reopens no longer revert to immediate <code>SQLITE_BUSY</code> failures under lock contention. (#39183) Thanks @MumuTW.</li>
|
||||
<li>Gateway/webchat route safety: block webchat/control-ui clients from inheriting stored external delivery routes on channel-scoped sessions (while preserving route inheritance for UI/TUI clients), preventing cross-channel leakage from scoped chats. (#39175) Thanks @widingmarcus-cyber.</li>
|
||||
<li>Telegram error-surface resilience: return a user-visible fallback reply when dispatch/debounce processing fails instead of going silent, while preserving draft-stream cleanup and best-effort thread-scoped fallback delivery. (#39209) Thanks @riftzen-bit.</li>
|
||||
<li>Gateway/password auth startup diagnostics: detect unresolved provider-reference objects in <code>gateway.auth.password</code> and fail with a specific bootstrap-secrets error message instead of generic misconfiguration output. (#39230) Thanks @ademczuk.</li>
|
||||
<li>Agents/OpenAI-responses compatibility: strip unsupported <code>store</code> payload fields when <code>supportsStore=false</code> (including OpenAI-compatible non-OpenAI providers) while preserving server-compaction payload behavior. (#39219) Thanks @ademczuk.</li>
|
||||
<li>Agents/model fallback visibility: warn when configured model IDs cannot be resolved and fallback is applied, with log-safe sanitization of model text to prevent control-sequence injection in warning output. (#39215) Thanks @ademczuk.</li>
|
||||
<li>Outbound delivery replay safety: use two-phase delivery ACK markers (<code>.json</code> -> <code>.delivered</code> -> unlink) and startup marker cleanup so crash windows between send and cleanup do not replay already-delivered messages. (#38668) Thanks @Gundam98.</li>
|
||||
<li>Nodes/system.run approval binding: carry prepared approval plans through gateway forwarding and bind interpreter-style script operands across approval to execution, so post-approval script rewrites are denied while unchanged approved script runs keep working. Thanks @tdjackey for reporting.</li>
|
||||
<li>Nodes/system.run PowerShell wrapper parsing: treat <code>pwsh</code>/<code>powershell</code> <code>-EncodedCommand</code> forms as shell-wrapper payloads so allowlist mode still requires approval instead of falling back to plain argv analysis. Thanks @tdjackey for reporting.</li>
|
||||
<li>Control UI/auth error reporting: map generic browser <code>Fetch failed</code> websocket close errors back to actionable gateway auth messages (<code>gateway token mismatch</code>, <code>authentication failed</code>, <code>retry later</code>) so dashboard disconnects stop hiding credential problems. Landed from contributor PR #28608 by @KimGLee. Thanks @KimGLee.</li>
|
||||
<li>Media/mime unknown-kind handling: return <code>undefined</code> (not <code>"unknown"</code>) for missing/unrecognized MIME kinds and use document-size fallback caps for unknown remote media, preventing phantom <code><media:unknown></code> Signal events from being treated as real messages. (#39199) Thanks @nicolasgrasset.</li>
|
||||
<li>Nodes/system.run allow-always persistence: honor shell comment semantics during allowlist analysis so <code>#</code>-tailed payloads that never execute are not persisted as trusted follow-up commands. Thanks @tdjackey for reporting.</li>
|
||||
<li>Signal/inbound attachment fan-in: forward all successfully fetched inbound attachments through <code>MediaPaths</code>/<code>MediaUrls</code>/<code>MediaTypes</code> (instead of only the first), and improve multi-attachment placeholder summaries in mention-gated pending history. (#39212) Thanks @joeykrug.</li>
|
||||
<li>Nodes/system.run dispatch-wrapper boundary: keep shell-wrapper approval classification active at the depth boundary so <code>env</code> wrapper stacks cannot reach <code>/bin/sh -c</code> execution without the expected approval gate. Thanks @tdjackey for reporting.</li>
|
||||
<li>Docker/token persistence on reconfigure: reuse the existing <code>.env</code> gateway token during <code>docker-setup.sh</code> reruns and align compose token env defaults, so Docker installs stop silently rotating tokens and breaking existing dashboard sessions. Landed from contributor PR #33097 by @chengzhichao-xydt. Thanks @chengzhichao-xydt.</li>
|
||||
<li>Agents/strict OpenAI turn ordering: apply assistant-first transcript bootstrap sanitization to strict OpenAI-compatible providers (for example vLLM/Gemma via <code>openai-completions</code>) without adding Google-specific session markers, preventing assistant-first history rejections. (#39252) Thanks @scoootscooob.</li>
|
||||
<li>Discord/exec approvals gateway auth: pass resolved shared gateway credentials into the Discord exec-approvals gateway client so token-auth installs stop failing approvals with <code>gateway token mismatch</code>. Related to #38179. Thanks @0riginal-claw for the adjacent PR #35147 investigation.</li>
|
||||
<li>Subagents/workspace inheritance: propagate parent workspace directory to spawned subagent runs so child sessions reliably inherit workspace-scoped instructions (<code>AGENTS.md</code>, <code>SOUL.md</code>, etc.) without exposing workspace override through tool-call arguments. (#39247) Thanks @jasonQin6.</li>
|
||||
<li>Exec approvals/gateway-node policy: honor explicit <code>ask=off</code> from <code>exec-approvals.json</code> even when runtime defaults are stricter, so trusted full/off setups stop re-prompting on gateway and node exec paths. Landed from contributor PR #26789 by @pandego. Thanks @pandego.</li>
|
||||
<li>Exec approvals/config fallback: inherit <code>ask</code> from <code>exec-approvals.json</code> when <code>tools.exec.ask</code> is unset, so local full/off defaults no longer fall back to <code>on-miss</code> for exec tool and <code>nodes run</code>. Landed from contributor PR #29187 by @Bartok9. Thanks @Bartok9.</li>
|
||||
<li>Exec approvals/allow-always shell scripts: persist and match script paths for wrapper invocations like <code>bash scripts/foo.sh</code> while still blocking <code>-c</code>/<code>-s</code> wrapper bypasses. Landed from contributor PR #35137 by @yuweuii. Thanks @yuweuii.</li>
|
||||
<li>Queue/followup dedupe across drain restarts: dedupe queued redelivery <code>message_id</code> values after queue recreation so busy-session followups no longer duplicate on replayed inbound events. Landed from contributor PR #33168 by @rylena. Thanks @rylena.</li>
|
||||
<li>Telegram/preview-final edit idempotence: treat <code>message is not modified</code> errors during preview finalization as delivered so partial-stream final replies do not fall back to duplicate sends. Landed from contributor PR #34983 by @HOYALIM. Thanks @HOYALIM.</li>
|
||||
<li>Telegram/DM streaming transport parity: use message preview transport for all DM streaming lanes so final delivery can edit the active preview instead of sending duplicate finals. Landed from contributor PR #38906 by @gambletan. Thanks @gambletan.</li>
|
||||
<li>Telegram/DM draft streaming restoration: restore native <code>sendMessageDraft</code> preview transport for DM answer streaming while keeping reasoning on message transport, with regression coverage to keep draft finalization from sending duplicate finals. (#39398) Thanks @obviyus.</li>
|
||||
<li>Telegram/send retry safety: retry non-idempotent send paths only for pre-connect failures and make custom retry predicates strict, preventing ambiguous reconnect retries from sending duplicate messages. Landed from contributor PR #34238 by @hal-crackbot. Thanks @hal-crackbot.</li>
|
||||
<li>ACP/run spawn delivery bootstrap: stop reusing requester inline delivery targets for one-shot <code>mode: "run"</code> ACP spawns, so fresh run-mode workers bootstrap in isolation instead of inheriting thread-bound session delivery behavior. (#39014) Thanks @lidamao633.</li>
|
||||
<li>Discord/DM session-key normalization: rewrite legacy <code>discord:dm:*</code> and phantom direct-message <code>discord:channel:<user></code> session keys to <code>discord:direct:*</code> when the sender matches, so multi-agent Discord DMs stop falling into empty channel-shaped sessions and resume replying correctly.</li>
|
||||
<li>Discord/native slash session fallback: treat empty configured bound-session keys as missing so <code>/status</code> and other native commands fall back to the routed slash session and routed channel session instead of blanking Discord session keys in normal channel bindings.</li>
|
||||
<li>Agents/tool-call dispatch normalization: normalize provider-prefixed tool names before dispatch across <code>toolCall</code>, <code>toolUse</code>, and <code>functionCall</code> blocks, while preserving multi-segment tool suffixes when stripping provider wrappers so malformed-but-recoverable tool names no longer fail with <code>Tool not found</code>. (#39328) Thanks @vincentkoc.</li>
|
||||
<li>Agents/parallel tool-call compatibility: honor <code>parallel_tool_calls</code> / <code>parallelToolCalls</code> extra params only for <code>openai-completions</code> and <code>openai-responses</code> payloads, preserve higher-precedence alias overrides across config and runtime layers, and ignore invalid non-boolean values so single-tool-call providers like NVIDIA-hosted Kimi stop failing on forced parallel tool-call payloads. (#37048) Thanks @vincentkoc.</li>
|
||||
<li>Config/invalid-load fail-closed: stop converting <code>INVALID_CONFIG</code> into an empty runtime config, keep valid settings available only through explicit best-effort diagnostic reads, and route read-only CLI diagnostics through that path so unknown keys no longer silently drop security-sensitive config. (#28140) Thanks @bobsahur-robot and @vincentkoc.</li>
|
||||
<li>Agents/codex-cli sandbox defaults: switch the built-in Codex backend from <code>read-only</code> to <code>workspace-write</code> so spawned coding runs can edit files out of the box. Landed from contributor PR #39336 by @0xtangping. Thanks @0xtangping.</li>
|
||||
<li>Gateway/health-monitor restart reason labeling: report <code>disconnected</code> instead of <code>stuck</code> for clean channel disconnect restarts, so operator logs distinguish socket drops from genuinely stuck channels. (#36436) Thanks @Sid-Qin.</li>
|
||||
<li>Control UI/agents-page overrides: auto-create minimal per-agent config entries when editing inherited agents, so model/tool/skill changes enable Save and inherited model fallbacks can be cleared by writing a primary-only override. Landed from contributor PR #39326 by @dunamismax. Thanks @dunamismax.</li>
|
||||
<li>Gateway/Telegram webhook-mode recovery: add <code>webhookCertPath</code> to re-upload self-signed certificates during webhook registration and skip stale-socket detection for webhook-mode channels, so Telegram webhook setups survive health-monitor restarts. Landed from contributor PR #39313 by @fellanH. Thanks @fellanH.</li>
|
||||
<li>Discord/config schema parity: add <code>channels.discord.agentComponents</code> to the strict Zod config schema so valid <code>agentComponents.enabled</code> settings (root and account-scoped) no longer fail with unrecognized-key validation errors. Landed from contributor PR #39378 by @gambletan. Thanks @gambletan and @thewilloftheshadow.</li>
|
||||
<li>ACPX/MCP session bootstrap: inject configured MCP servers into ACP <code>session/new</code> and <code>session/load</code> for acpx-backed sessions, restoring Canva and other external MCP tools. Landed from contributor PR #39337. Thanks @goodspeed-apps.</li>
|
||||
<li>Control UI/Telegram sender labels: preserve inbound sender labels in sanitized chat history so dashboard user-message groups split correctly and show real group-member names instead of <code>You</code>. (#39414) Thanks @obviyus.</li>
|
||||
<li>Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.</li>
|
||||
<li>Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr.</li>
|
||||
<li>Fix: persist <code>gateway.mode=local</code> after selecting Local run mode in <code>clawdbot configure</code>, even if no other sections are chosen.</li>
|
||||
<li>Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.</li>
|
||||
<li>Agents: avoid false positives when logging unsupported Google tool schema keywords.</li>
|
||||
<li>Agents: skip Gemini history downgrades for google-antigravity to preserve tool calls. (#894) — thanks @mukhtharcm.</li>
|
||||
<li>Status: restore usage summary line for current provider when no OAuth profiles exist.</li>
|
||||
<li>Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.</li>
|
||||
<li>Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.</li>
|
||||
<li>Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.</li>
|
||||
<li>Fix: support MiniMax coding plan usage responses with <code>model_remains</code>/<code>current_interval_*</code> payloads.</li>
|
||||
<li>Fix: suppress WhatsApp pairing replies for historical catch-up DMs on initial link. (#904)</li>
|
||||
<li>Browser: extension mode recovers when only one tab is attached (stale targetId fallback).</li>
|
||||
<li>Browser: fix <code>tab not found</code> for extension relay snapshots/actions when Playwright blocks <code>newCDPSession</code> (use the single available Page).</li>
|
||||
<li>Browser: upgrade <code>ws</code> → <code>wss</code> when remote CDP uses <code>https</code> (fixes Browserless handshake).</li>
|
||||
<li>Telegram: skip <code>message_thread_id=1</code> for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.</li>
|
||||
<li>Fix: sanitize user-facing error text + strip <code><final></code> tags across reply pipelines. (#975) — thanks @ThomsenDrake.</li>
|
||||
<li>Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.</li>
|
||||
<li>Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash.</li>
|
||||
<li>Fix: reset sessions after role-ordering conflicts to recover from consecutive user turns. (#998)</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.3.7/OpenClaw-2026.3.7.zip" length="23263833" type="application/octet-stream" sparkle:edSignature="SO0zedZMzrvSDltLkuaSVQTWFPPPe1iu/enS4TGGb5EGckhqRCmNJWMKNID5lKwFC8vefTbfG9JTlSrZedP4Bg=="/>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.15/Clawdbot-2026.1.15.zip" length="12127276" type="application/octet-stream" sparkle:edSignature="o79vwTbtW/d91NQFRVfUDhsv6D4zIw7IkhY0N1iLImMu94BURgLcecA6z7Smy3bMobPwOyzN8yfm6mA/Rt8FCA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.14-1</title>
|
||||
<pubDate>Thu, 15 Jan 2026 11:14:40 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>5825</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.14-1</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.14-1</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li>Web search: <code>web_search</code>/<code>web_fetch</code> tools (Brave API) + first-time setup in onboarding/configure.</li>
|
||||
<li>Browser control: Chrome extension relay takeover mode + remote browser control via <code>clawdbot browser serve</code>.</li>
|
||||
<li>Plugins: channel plugins (gateway HTTP hooks) + Zalo plugin + onboarding install flow. (#854) — thanks @longmaba.</li>
|
||||
<li>Security: expanded <code>clawdbot security audit</code> (+ <code>--fix</code>), detect-secrets CI scan, and a <code>SECURITY.md</code> reporting policy.</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<h4>Web Tools</h4>
|
||||
<ul>
|
||||
<li>Tools: add <code>web_search</code>/<code>web_fetch</code> (Brave API), including helpful setup hints when the key is missing.</li>
|
||||
<li>Tools: enable <code>web_fetch</code> by default (unless explicitly disabled in config).</li>
|
||||
<li>CLI/Docs: add <code>clawdbot configure --section web</code> for storing Brave API keys and update onboarding tips.</li>
|
||||
</ul>
|
||||
<h4>Browser / Control UI</h4>
|
||||
<ul>
|
||||
<li>Browser: add Chrome extension relay takeover mode (toolbar button) + <code>clawdbot browser serve</code> remote control + <code>browser.controlToken</code>.</li>
|
||||
<li>Browser: ship a built-in <code>chrome</code> profile for extension relay and start the relay automatically when running locally.</li>
|
||||
<li>Browser: default <code>browser.defaultProfile</code> to <code>chrome</code> (existing Chrome takeover mode).</li>
|
||||
<li>Browser: add <code>clawdbot browser extension install/path</code> and copy extension path to clipboard.</li>
|
||||
<li>Browser: add <code>snapshot refs=aria</code> (Playwright aria-ref ids) for self-resolving refs across <code>snapshot</code> → <code>act</code>.</li>
|
||||
<li>Browser: <code>profile="chrome"</code> now defaults to host control and returns clearer “attach a tab” errors.</li>
|
||||
<li>Browser: extension mode recovers when only one tab is attached (stale targetId fallback).</li>
|
||||
<li>Control UI: show raw any-map entries in config views; move Docs link into the left nav.</li>
|
||||
</ul>
|
||||
<h4>Plugins</h4>
|
||||
<ul>
|
||||
<li>Plugins: add plugin HTTP hooks + loader updates to support channel plugins. (#854) — thanks @longmaba.</li>
|
||||
<li>Plugins: add onboarding plugin install flow. (#854) — thanks @longmaba.</li>
|
||||
<li>Channels: add Matrix plugin (external) with docs + onboarding hooks.</li>
|
||||
<li>Voice Call: add Plivo provider (no SDK dependency). (#846) — thanks @vrknetha.</li>
|
||||
</ul>
|
||||
<h4>Security</h4>
|
||||
<ul>
|
||||
<li>Security: expand <code>clawdbot security audit</code> checks and publish a <code>SECURITY.md</code> reporting policy.</li>
|
||||
<li>Security: extend <code>clawdbot security audit --fix</code> to tighten more sensitive state paths.</li>
|
||||
<li>Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.</li>
|
||||
</ul>
|
||||
<h4>Onboarding / Daemon</h4>
|
||||
<ul>
|
||||
<li>Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require <code>--accept-risk</code> for <code>--non-interactive</code>.</li>
|
||||
<li>Daemon: support profile-aware service names for multi-gateway setups. (#671) — thanks @bjesuiter.</li>
|
||||
</ul>
|
||||
<h4>Auth / Usage / Config</h4>
|
||||
<ul>
|
||||
<li>Usage: add MiniMax coding plan usage tracking.</li>
|
||||
<li>Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR.</li>
|
||||
<li>Agents: add optional auth-profile copy prompt on <code>agents add</code> and improve auth error messaging.</li>
|
||||
<li>Auth: add dynamic template variables to <code>messages.responsePrefix</code>. (#928) — thanks @sebslight.</li>
|
||||
<li>Config: add <code>channels.<provider>.configWrites</code> gating for channel-initiated config writes; migrate Slack channel IDs.</li>
|
||||
</ul>
|
||||
<h4>Channels</h4>
|
||||
<ul>
|
||||
<li>Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko.</li>
|
||||
<li>WhatsApp: add <code>channels.whatsapp.sendReadReceipts</code> to disable auto read receipts. (#882) — thanks @chrisrodz.</li>
|
||||
</ul>
|
||||
<h4>Docs</h4>
|
||||
<ul>
|
||||
<li>Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics.</li>
|
||||
<li>Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors.</li>
|
||||
<li>Docs: expand gateway security hardening guidance and incident response checklist.</li>
|
||||
<li>Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf.</li>
|
||||
<li>Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915)</li>
|
||||
<li>Docs: add per-command CLI doc pages and link them from <code>clawdbot <command> --help</code>.</li>
|
||||
<li>Docs: add multi-gateway guide (sidebar + nav).</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<h4>Gateway / Daemon / Sessions</h4>
|
||||
<ul>
|
||||
<li>Gateway: forward termination signals to respawned CLI child processes to avoid orphaned systemd runs. (#933) — thanks @roshanasingh4.</li>
|
||||
<li>Gateway/UI: ship session defaults in the hello snapshot so the Control UI canonicalizes main session keys (no bare <code>main</code> alias).</li>
|
||||
<li>Agents: skip thinking/final tag stripping inside Markdown code spans. (#939) — thanks @ngutman.</li>
|
||||
<li>Browser: add tests for snapshot labels/efficient query params and labeled image responses.</li>
|
||||
<li>Browser: persist role snapshot refs per CDP target so <code>snapshot</code> → <code>act</code> clicks work even if Playwright returns a different Page instance.</li>
|
||||
<li>macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.</li>
|
||||
<li>macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.</li>
|
||||
<li>Packaging: run <code>pnpm build</code> on <code>prepack</code> so npm publishes include fresh <code>dist/</code> output.</li>
|
||||
<li>Telegram: register dock native commands with underscores to avoid <code>BOT_COMMAND_INVALID</code> (#929, fixes #901) — thanks @grp06.</li>
|
||||
<li>Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.</li>
|
||||
<li>Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.</li>
|
||||
<li>Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.</li>
|
||||
<li>Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.</li>
|
||||
<li>Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.</li>
|
||||
<li>Agents: scrub tuple <code>items</code> schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.</li>
|
||||
<li>Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.</li>
|
||||
<li>Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare <code>main</code> sessions.</li>
|
||||
<li>Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.</li>
|
||||
<li>Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.</li>
|
||||
<li>Daemon: clear persisted launchd disabled state before bootstrap (fixes <code>daemon install</code> after uninstall). (#849) — thanks @ndraiman.</li>
|
||||
<li>Sessions: return deep clones (<code>structuredClone</code>) so cached session entries can't be mutated. (#934) — thanks @ronak-guliani.</li>
|
||||
<li>Heartbeat: keep <code>updatedAt</code> monotonic when restoring heartbeat sessions. (#934) — thanks @ronak-guliani.</li>
|
||||
<li>Agent: clear run context after CLI runs (<code>clearAgentRunContext</code>) to avoid runaway contexts. (#934) — thanks @ronak-guliani.</li>
|
||||
<li>Gateway/Dev: ensure <code>pnpm gateway:dev</code> always uses the dev profile config + state (<code>~/.clawdbot-dev</code>).</li>
|
||||
</ul>
|
||||
<h4>CLI / Onboarding</h4>
|
||||
<ul>
|
||||
<li>Onboarding: show web search setup at the end (not the beginning).</li>
|
||||
<li>Onboarding: show daemon install/restart progress (avoid “blinking cursor”) and fix daemon install output formatting.</li>
|
||||
<li>Health: colorize “not configured” provider lines for easier scanning.</li>
|
||||
</ul>
|
||||
<h4>Control UI / TUI</h4>
|
||||
<ul>
|
||||
<li>Control UI: load cron run history on job selection and clarify empty-state messaging. (#866)</li>
|
||||
<li>UI: use application-defined WebSocket close code and fix dashboard auth query items. (#918) — thanks @rahthakor.</li>
|
||||
<li>UI: always apply <code>?token=</code> from URL (fixes unauthorized after re-onboard).</li>
|
||||
<li>Browser: add tests for snapshot labels/efficient query params and labeled image responses.</li>
|
||||
<li>TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank.</li>
|
||||
<li>TUI: add a bright spinner + elapsed time in the status line for send/stream/run states.</li>
|
||||
<li>TUI: show LLM error messages (rate limits, auth, etc.) instead of <code>(no output)</code>.</li>
|
||||
</ul>
|
||||
<h4>Agents / Auth / Tools / Sandbox</h4>
|
||||
<ul>
|
||||
<li>Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.</li>
|
||||
<li>Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.</li>
|
||||
<li>Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.</li>
|
||||
<li>Agents: scrub tuple <code>items</code> schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.</li>
|
||||
<li>Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.</li>
|
||||
<li>Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.</li>
|
||||
<li>Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.</li>
|
||||
<li>Logging: tolerate <code>EIO</code> from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.</li>
|
||||
<li>Sandbox: restore <code>docker.binds</code> config validation and preserve configured PATH for <code>docker exec</code>. (#873) — thanks @akonyer.</li>
|
||||
<li>Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.</li>
|
||||
</ul>
|
||||
<h4>macOS / Apps</h4>
|
||||
<ul>
|
||||
<li>macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.</li>
|
||||
<li>macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.</li>
|
||||
<li>macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.</li>
|
||||
<li>macOS: reuse launchd gateway auth and skip wizard when gateway config already exists. (#917)</li>
|
||||
<li>Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare <code>main</code> sessions.</li>
|
||||
<li>macOS: fix cron preview/testing payload to use <code>channel</code> key. (#867) — thanks @wes-davis.</li>
|
||||
<li>macOS: update cron testing channel arg. (#896) — thanks @ngutman.</li>
|
||||
</ul>
|
||||
<h4>Channels / Messaging</h4>
|
||||
<ul>
|
||||
<li>Slack: isolate thread history and avoid inheriting channel transcripts for new threads by default. (#758)</li>
|
||||
<li>Slack: respect <code>channels.slack.requireMention</code> default when resolving channel mention gating. (#850) — thanks @evalexpr.</li>
|
||||
<li>Slack: drop Socket Mode events with mismatched <code>api_app_id</code>/<code>team_id</code>. (#889) — thanks @roshanasingh4.</li>
|
||||
<li>Commands: add native command argument menus across Discord/Slack/Telegram. (#936) — thanks @thewilloftheshadow.</li>
|
||||
<li>Discord: isolate autoThread thread context. (#856) — thanks @davidguttman.</li>
|
||||
<li>Telegram: honor <code>channels.telegram.timeoutSeconds</code> for grammY API requests. (#863) — thanks @Snaver.</li>
|
||||
<li>Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).</li>
|
||||
<li>Telegram: let control commands bypass per-chat sequentialization; always allow abort triggers.</li>
|
||||
<li>Telegram: split long captions into media + follow-up text messages. (#907) — thanks @jalehman.</li>
|
||||
<li>Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko.</li>
|
||||
<li>Telegram: register dock native commands with underscores to avoid <code>BOT_COMMAND_INVALID</code> (#929, fixes #901) — thanks @grp06.</li>
|
||||
<li>Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose.</li>
|
||||
<li>iMessage: prefer handle routing for direct-message replies; include imsg RPC error details. (#935)</li>
|
||||
<li>WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester.</li>
|
||||
<li>WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee.</li>
|
||||
<li>WhatsApp: harden owner command auth.</li>
|
||||
<li>Auto-reply: treat trailing <code>NO_REPLY</code> tokens as silent replies.</li>
|
||||
</ul>
|
||||
<h4>Config / Doctor / Packaging</h4>
|
||||
<ul>
|
||||
<li>Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves).</li>
|
||||
<li>Config/Doctor: remove legacy Clawdis env fallbacks and config/service migrations (Clawdbot-only).</li>
|
||||
<li>Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.</li>
|
||||
<li>Packaging: run <code>pnpm build</code> on <code>prepack</code> so npm publishes include fresh <code>dist/</code> output.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.14-1/Clawdbot-2026.1.14-1.zip" length="19887144" type="application/octet-stream" sparkle:edSignature="1irKxBLt2eRtns34m/8JsjL/ZzhZQNjahwrxtArTvzaCnidS/MEnpD4nV2SHnhuo8g+fJZQpV9NoCAoEOAinCw=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -1,26 +1,13 @@
|
||||
## OpenClaw Android App
|
||||
## Clawdbot Node (Android) (internal)
|
||||
|
||||
Status: **extremely alpha**. The app is actively being rebuilt from the ground up.
|
||||
Modern Android node app: connects to the **Gateway-owned bridge** (`_clawdbot-bridge._tcp`) over TCP and exposes **Canvas + Chat + Camera**.
|
||||
|
||||
### Rebuild Checklist
|
||||
|
||||
- [x] New 4-step onboarding flow
|
||||
- [x] Connect tab with `Setup Code` + `Manual` modes
|
||||
- [x] Encrypted persistence for gateway setup/auth state
|
||||
- [x] Chat UI restyled
|
||||
- [x] Settings UI restyled and de-duplicated (gateway controls moved to Connect)
|
||||
- [x] QR code scanning in onboarding
|
||||
- [x] Performance improvements
|
||||
- [x] Streaming support in chat UI
|
||||
- [x] Request camera/location and other permissions in onboarding/settings flow
|
||||
- [x] Push notifications for gateway/chat status updates
|
||||
- [x] Security hardening (biometric lock, token handling, safer defaults)
|
||||
- [x] Voice tab full functionality
|
||||
- [x] Screen tab full functionality
|
||||
- [ ] Full end-to-end QA and release hardening
|
||||
Notes:
|
||||
- The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action).
|
||||
- Chat always uses the shared session key **`main`** (same session across iOS/macOS/WebChat/Android).
|
||||
- Supports modern Android only (`minSdk 31`, Kotlin + Jetpack Compose).
|
||||
|
||||
## Open in Android Studio
|
||||
|
||||
- Open the folder `apps/android`.
|
||||
|
||||
## Build / Run
|
||||
@@ -32,135 +19,26 @@ cd apps/android
|
||||
./gradlew :app:testDebugUnitTest
|
||||
```
|
||||
|
||||
## Kotlin Lint + Format
|
||||
|
||||
```bash
|
||||
pnpm android:lint
|
||||
pnpm android:format
|
||||
```
|
||||
|
||||
Android framework/resource lint (separate pass):
|
||||
|
||||
```bash
|
||||
pnpm android:lint:android
|
||||
```
|
||||
|
||||
Direct Gradle tasks:
|
||||
|
||||
```bash
|
||||
cd apps/android
|
||||
./gradlew :app:ktlintCheck :benchmark:ktlintCheck
|
||||
./gradlew :app:ktlintFormat :benchmark:ktlintFormat
|
||||
./gradlew :app:lintDebug
|
||||
```
|
||||
|
||||
`gradlew` auto-detects the Android SDK at `~/Library/Android/sdk` (macOS default) if `ANDROID_SDK_ROOT` / `ANDROID_HOME` are unset.
|
||||
|
||||
## Macrobenchmark (Startup + Frame Timing)
|
||||
|
||||
```bash
|
||||
cd apps/android
|
||||
./gradlew :benchmark:connectedDebugAndroidTest
|
||||
```
|
||||
|
||||
Reports are written under:
|
||||
|
||||
- `apps/android/benchmark/build/reports/androidTests/connected/`
|
||||
|
||||
## Perf CLI (low-noise)
|
||||
|
||||
Deterministic startup measurement + hotspot extraction with compact CLI output:
|
||||
|
||||
```bash
|
||||
cd apps/android
|
||||
./scripts/perf-startup-benchmark.sh
|
||||
./scripts/perf-startup-hotspots.sh
|
||||
```
|
||||
|
||||
Benchmark script behavior:
|
||||
|
||||
- Runs only `StartupMacrobenchmark#coldStartup` (10 iterations).
|
||||
- Prints median/min/max/COV in one line.
|
||||
- Writes timestamped snapshot JSON to `apps/android/benchmark/results/`.
|
||||
- Auto-compares with previous local snapshot (or pass explicit baseline: `--baseline <old-benchmarkData.json>`).
|
||||
|
||||
Hotspot script behavior:
|
||||
|
||||
- Ensures debug app installed, captures startup `simpleperf` data for `.MainActivity`.
|
||||
- Prints top DSOs, top symbols, and key app-path clues (Compose/MainActivity/WebView).
|
||||
- Writes raw `perf.data` path for deeper follow-up if needed.
|
||||
|
||||
## Run on a Real Android Phone (USB)
|
||||
|
||||
1) On phone, enable **Developer options** + **USB debugging**.
|
||||
2) Connect by USB and accept the debugging trust prompt on phone.
|
||||
3) Verify ADB can see the device:
|
||||
|
||||
```bash
|
||||
adb devices -l
|
||||
```
|
||||
|
||||
4) Install + launch debug build:
|
||||
|
||||
```bash
|
||||
pnpm android:install
|
||||
pnpm android:run
|
||||
```
|
||||
|
||||
If `adb devices -l` shows `unauthorized`, re-plug and accept the trust prompt again.
|
||||
|
||||
### USB-only gateway testing (no LAN dependency)
|
||||
|
||||
Use `adb reverse` so Android `localhost:18789` tunnels to your laptop `localhost:18789`.
|
||||
|
||||
Terminal A (gateway):
|
||||
|
||||
```bash
|
||||
pnpm openclaw gateway --port 18789 --verbose
|
||||
```
|
||||
|
||||
Terminal B (USB tunnel):
|
||||
|
||||
```bash
|
||||
adb reverse tcp:18789 tcp:18789
|
||||
```
|
||||
|
||||
Then in app **Connect → Manual**:
|
||||
|
||||
- Host: `127.0.0.1`
|
||||
- Port: `18789`
|
||||
- TLS: off
|
||||
|
||||
## Hot Reload / Fast Iteration
|
||||
|
||||
This app is native Kotlin + Jetpack Compose.
|
||||
|
||||
- For Compose UI edits: use Android Studio **Live Edit** on a debug build (works on physical devices; project `minSdk=31` already meets API requirement).
|
||||
- For many non-structural code/resource changes: use Android Studio **Apply Changes**.
|
||||
- For structural/native/manifest/Gradle changes: do full reinstall (`pnpm android:run`).
|
||||
- Canvas web content already supports live reload when loaded from Gateway `__openclaw__/canvas/` (see `docs/platforms/android.md`).
|
||||
|
||||
## Connect / Pair
|
||||
|
||||
1) Start the gateway (on your main machine):
|
||||
|
||||
1) Start the gateway (on your “master” machine):
|
||||
```bash
|
||||
pnpm openclaw gateway --port 18789 --verbose
|
||||
pnpm clawdbot gateway --port 18789 --verbose
|
||||
```
|
||||
|
||||
2) In the Android app:
|
||||
|
||||
- Open the **Connect** tab.
|
||||
- Use **Setup Code** or **Manual** mode to connect.
|
||||
- Open **Settings**
|
||||
- Either select a discovered bridge under **Discovered Bridges**, or use **Advanced → Manual Bridge** (host + port).
|
||||
|
||||
3) Approve pairing (on the gateway machine):
|
||||
|
||||
```bash
|
||||
openclaw devices list
|
||||
openclaw devices approve <requestId>
|
||||
clawdbot nodes pending
|
||||
clawdbot nodes approve <requestId>
|
||||
```
|
||||
|
||||
More details: `docs/platforms/android.md`.
|
||||
More details: `docs/android/connect.md`.
|
||||
|
||||
## Permissions
|
||||
|
||||
@@ -171,58 +49,3 @@ More details: `docs/platforms/android.md`.
|
||||
- Camera:
|
||||
- `CAMERA` for `camera.snap` and `camera.clip`
|
||||
- `RECORD_AUDIO` for `camera.clip` when `includeAudio=true`
|
||||
|
||||
## Integration Capability Test (Preconditioned)
|
||||
|
||||
This suite assumes setup is already done manually. It does **not** install/run/pair automatically.
|
||||
|
||||
Pre-req checklist:
|
||||
|
||||
1) Gateway is running and reachable from the Android app.
|
||||
2) Android app is connected to that gateway and `openclaw nodes status` shows it as paired + connected.
|
||||
3) App stays unlocked and in foreground for the whole run.
|
||||
4) Open the app **Screen** tab and keep it active during the run (canvas/A2UI commands require the canvas WebView attached there).
|
||||
5) Grant runtime permissions for capabilities you expect to pass (camera/mic/location/notification listener/location, etc.).
|
||||
6) No interactive system dialogs should be pending before test start.
|
||||
7) Canvas host is enabled and reachable from the device (do not run gateway with `OPENCLAW_SKIP_CANVAS_HOST=1`; startup logs should include `canvas host mounted at .../__openclaw__/`).
|
||||
8) Local operator test client pairing is approved. If first run fails with `pairing required`, approve latest pending device pairing request, then rerun:
|
||||
9) For A2UI checks, keep the app on **Screen** tab; the node now auto-refreshes canvas capability once on first A2UI reachability failure (TTL-safe retry).
|
||||
|
||||
```bash
|
||||
openclaw devices list
|
||||
openclaw devices approve --latest
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm android:test:integration
|
||||
```
|
||||
|
||||
Optional overrides:
|
||||
|
||||
- `OPENCLAW_ANDROID_GATEWAY_URL=ws://...` (default: from your local OpenClaw config)
|
||||
- `OPENCLAW_ANDROID_GATEWAY_TOKEN=...`
|
||||
- `OPENCLAW_ANDROID_GATEWAY_PASSWORD=...`
|
||||
- `OPENCLAW_ANDROID_NODE_ID=...` or `OPENCLAW_ANDROID_NODE_NAME=...`
|
||||
|
||||
What it does:
|
||||
|
||||
- Reads `node.describe` command list from the selected Android node.
|
||||
- Invokes advertised non-interactive commands.
|
||||
- Skips `screen.record` in this suite (Android requires interactive per-invocation screen-capture consent).
|
||||
- Asserts command contracts (success or expected deterministic error for safe-invalid calls like `sms.send` and `notifications.actions`).
|
||||
|
||||
Common failure quick-fixes:
|
||||
|
||||
- `pairing required` before tests start:
|
||||
- approve pending device pairing (`openclaw devices approve --latest`) and rerun.
|
||||
- `A2UI host not reachable` / `A2UI_HOST_NOT_CONFIGURED`:
|
||||
- ensure gateway canvas host is running and reachable, keep the app on the **Screen** tab. The app will auto-refresh canvas capability once; if it still fails, reconnect app and rerun.
|
||||
- `NODE_BACKGROUND_UNAVAILABLE: canvas unavailable`:
|
||||
- app is not effectively ready for canvas commands; keep app foregrounded and **Screen** tab active.
|
||||
|
||||
## Contributions
|
||||
|
||||
This Android app is currently being rebuilt.
|
||||
Maintainer: @obviyus. For issues/questions/contributions, please open an issue or reach out on Discord.
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
Copyright 2018 The Manrope Project Authors (https://github.com/sharanda/manrope)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -1,213 +1,127 @@
|
||||
import com.android.build.api.variant.impl.VariantOutputImpl
|
||||
|
||||
val androidStoreFile = providers.gradleProperty("OPENCLAW_ANDROID_STORE_FILE").orNull?.takeIf { it.isNotBlank() }
|
||||
val androidStorePassword = providers.gradleProperty("OPENCLAW_ANDROID_STORE_PASSWORD").orNull?.takeIf { it.isNotBlank() }
|
||||
val androidKeyAlias = providers.gradleProperty("OPENCLAW_ANDROID_KEY_ALIAS").orNull?.takeIf { it.isNotBlank() }
|
||||
val androidKeyPassword = providers.gradleProperty("OPENCLAW_ANDROID_KEY_PASSWORD").orNull?.takeIf { it.isNotBlank() }
|
||||
val resolvedAndroidStoreFile =
|
||||
androidStoreFile?.let { storeFilePath ->
|
||||
if (storeFilePath.startsWith("~/")) {
|
||||
"${System.getProperty("user.home")}/${storeFilePath.removePrefix("~/")}"
|
||||
} else {
|
||||
storeFilePath
|
||||
}
|
||||
}
|
||||
|
||||
val hasAndroidReleaseSigning =
|
||||
listOf(resolvedAndroidStoreFile, androidStorePassword, androidKeyAlias, androidKeyPassword).all { it != null }
|
||||
|
||||
val wantsAndroidReleaseBuild =
|
||||
gradle.startParameter.taskNames.any { taskName ->
|
||||
taskName.contains("Release", ignoreCase = true) ||
|
||||
Regex("""(^|:)(bundle|assemble)$""").containsMatchIn(taskName)
|
||||
}
|
||||
|
||||
if (wantsAndroidReleaseBuild && !hasAndroidReleaseSigning) {
|
||||
error(
|
||||
"Missing Android release signing properties. Set OPENCLAW_ANDROID_STORE_FILE, " +
|
||||
"OPENCLAW_ANDROID_STORE_PASSWORD, OPENCLAW_ANDROID_KEY_ALIAS, and " +
|
||||
"OPENCLAW_ANDROID_KEY_PASSWORD in ~/.gradle/gradle.properties.",
|
||||
)
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jlleitschuh.gradle.ktlint")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
id("org.jetbrains.kotlin.plugin.serialization")
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
id("org.jetbrains.kotlin.plugin.serialization")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "ai.openclaw.app"
|
||||
compileSdk = 36
|
||||
namespace = "com.clawdbot.android"
|
||||
compileSdk = 36
|
||||
|
||||
// Release signing is local-only; keep the keystore path and passwords out of the repo.
|
||||
signingConfigs {
|
||||
if (hasAndroidReleaseSigning) {
|
||||
create("release") {
|
||||
storeFile = project.file(checkNotNull(resolvedAndroidStoreFile))
|
||||
storePassword = checkNotNull(androidStorePassword)
|
||||
keyAlias = checkNotNull(androidKeyAlias)
|
||||
keyPassword = checkNotNull(androidKeyPassword)
|
||||
}
|
||||
}
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
assets.srcDir(file("../../shared/ClawdbotKit/Sources/ClawdbotKit/Resources"))
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
assets.directories.add("../../shared/OpenClawKit/Sources/OpenClawKit/Resources")
|
||||
}
|
||||
}
|
||||
defaultConfig {
|
||||
applicationId = "com.clawdbot.android"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 202601114
|
||||
versionName = "2026.1.11-4"
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "ai.openclaw.app"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 202603120
|
||||
versionName = "2026.3.12"
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
if (hasAndroidReleaseSigning) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
}
|
||||
debug {
|
||||
isMinifyEnabled = false
|
||||
}
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes +=
|
||||
setOf(
|
||||
"/META-INF/{AL2.0,LGPL2.1}",
|
||||
"/META-INF/*.version",
|
||||
"/META-INF/LICENSE*.txt",
|
||||
"DebugProbesKt.bin",
|
||||
"kotlin-tooling-metadata.json",
|
||||
)
|
||||
}
|
||||
}
|
||||
lint {
|
||||
disable += setOf("IconLauncherShape")
|
||||
warningsAsErrors = true
|
||||
}
|
||||
|
||||
lint {
|
||||
disable +=
|
||||
setOf(
|
||||
"AndroidGradlePluginVersion",
|
||||
"GradleDependency",
|
||||
"IconLauncherShape",
|
||||
"NewerVersionAvailable",
|
||||
)
|
||||
warningsAsErrors = true
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.isIncludeAndroidResources = true
|
||||
}
|
||||
testOptions {
|
||||
unitTests.isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
onVariants { variant ->
|
||||
variant.outputs
|
||||
.filterIsInstance<VariantOutputImpl>()
|
||||
.forEach { output ->
|
||||
val versionName = output.versionName.orNull ?: "0"
|
||||
val buildType = variant.buildType
|
||||
onVariants { variant ->
|
||||
variant.outputs
|
||||
.filterIsInstance<VariantOutputImpl>()
|
||||
.forEach { output ->
|
||||
val versionName = output.versionName.orNull ?: "0"
|
||||
val buildType = variant.buildType
|
||||
|
||||
val outputFileName = "openclaw-$versionName-$buildType.apk"
|
||||
output.outputFileName = outputFileName
|
||||
}
|
||||
}
|
||||
val outputFileName = "clawdbot-${versionName}-${buildType}.apk"
|
||||
output.outputFileName = outputFileName
|
||||
}
|
||||
}
|
||||
}
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
allWarningsAsErrors.set(true)
|
||||
}
|
||||
}
|
||||
|
||||
ktlint {
|
||||
android.set(true)
|
||||
ignoreFailures.set(false)
|
||||
filter {
|
||||
exclude("**/build/**")
|
||||
}
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
allWarningsAsErrors.set(true)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
val composeBom = platform("androidx.compose:compose-bom:2026.02.00")
|
||||
implementation(composeBom)
|
||||
androidTestImplementation(composeBom)
|
||||
val composeBom = platform("androidx.compose:compose-bom:2025.12.00")
|
||||
implementation(composeBom)
|
||||
androidTestImplementation(composeBom)
|
||||
|
||||
implementation("androidx.core:core-ktx:1.17.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
||||
implementation("androidx.activity:activity-compose:1.12.2")
|
||||
implementation("androidx.webkit:webkit:1.15.0")
|
||||
implementation("androidx.core:core-ktx:1.17.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
||||
implementation("androidx.activity:activity-compose:1.12.2")
|
||||
implementation("androidx.webkit:webkit:1.15.0")
|
||||
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
// material-icons-extended pulled in full icon set (~20 MB DEX). Only ~18 icons used.
|
||||
// R8 will tree-shake unused icons when minify is enabled on release builds.
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
implementation("androidx.navigation:navigation-compose:2.9.7")
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
implementation("androidx.navigation:navigation-compose:2.9.6")
|
||||
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
|
||||
// Material Components (XML theme + resources)
|
||||
implementation("com.google.android.material:material:1.13.0")
|
||||
// Material Components (XML theme + resources)
|
||||
implementation("com.google.android.material:material:1.13.0")
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
|
||||
|
||||
implementation("androidx.security:security-crypto:1.1.0")
|
||||
implementation("androidx.exifinterface:exifinterface:1.4.2")
|
||||
implementation("com.squareup.okhttp3:okhttp:5.3.2")
|
||||
implementation("org.bouncycastle:bcprov-jdk18on:1.83")
|
||||
implementation("org.commonmark:commonmark:0.27.1")
|
||||
implementation("org.commonmark:commonmark-ext-autolink:0.27.1")
|
||||
implementation("org.commonmark:commonmark-ext-gfm-strikethrough:0.27.1")
|
||||
implementation("org.commonmark:commonmark-ext-gfm-tables:0.27.1")
|
||||
implementation("org.commonmark:commonmark-ext-task-list-items:0.27.1")
|
||||
implementation("androidx.security:security-crypto:1.1.0")
|
||||
implementation("androidx.exifinterface:exifinterface:1.4.2")
|
||||
|
||||
// CameraX (for node.invoke camera.* parity)
|
||||
implementation("androidx.camera:camera-core:1.5.2")
|
||||
implementation("androidx.camera:camera-camera2:1.5.2")
|
||||
implementation("androidx.camera:camera-lifecycle:1.5.2")
|
||||
implementation("androidx.camera:camera-video:1.5.2")
|
||||
implementation("androidx.camera:camera-view:1.5.2")
|
||||
implementation("com.journeyapps:zxing-android-embedded:4.3.0")
|
||||
// CameraX (for node.invoke camera.* parity)
|
||||
implementation("androidx.camera:camera-core:1.5.2")
|
||||
implementation("androidx.camera:camera-camera2:1.5.2")
|
||||
implementation("androidx.camera:camera-lifecycle:1.5.2")
|
||||
implementation("androidx.camera:camera-video:1.5.2")
|
||||
implementation("androidx.camera:camera-view:1.5.2")
|
||||
|
||||
// Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains.
|
||||
implementation("dnsjava:dnsjava:3.6.4")
|
||||
// Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains.
|
||||
implementation("dnsjava:dnsjava:3.6.3")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
|
||||
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.1.3")
|
||||
testImplementation("io.kotest:kotest-assertions-core-jvm:6.1.3")
|
||||
testImplementation("com.squareup.okhttp3:mockwebserver:5.3.2")
|
||||
testImplementation("org.robolectric:robolectric:4.16.1")
|
||||
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.2")
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
|
||||
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.0.7")
|
||||
testImplementation("io.kotest:kotest-assertions-core-jvm:6.0.7")
|
||||
testImplementation("org.robolectric:robolectric:4.16")
|
||||
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.2")
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
28
apps/android/app/proguard-rules.pro
vendored
28
apps/android/app/proguard-rules.pro
vendored
@@ -1,28 +0,0 @@
|
||||
# ── App classes ───────────────────────────────────────────────────
|
||||
-keep class ai.openclaw.app.** { *; }
|
||||
|
||||
# ── Bouncy Castle ─────────────────────────────────────────────────
|
||||
-keep class org.bouncycastle.** { *; }
|
||||
-dontwarn org.bouncycastle.**
|
||||
|
||||
# ── CameraX ───────────────────────────────────────────────────────
|
||||
-keep class androidx.camera.** { *; }
|
||||
|
||||
# ── kotlinx.serialization ────────────────────────────────────────
|
||||
-keep class kotlinx.serialization.** { *; }
|
||||
-keepclassmembers class * {
|
||||
@kotlinx.serialization.Serializable *;
|
||||
}
|
||||
-keepattributes *Annotation*, InnerClasses
|
||||
|
||||
# ── OkHttp ────────────────────────────────────────────────────────
|
||||
-dontwarn okhttp3.**
|
||||
-dontwarn okio.**
|
||||
-keep class okhttp3.internal.platform.** { *; }
|
||||
|
||||
# ── Misc suppressions ────────────────────────────────────────────
|
||||
-dontwarn com.sun.jna.**
|
||||
-dontwarn javax.naming.**
|
||||
-dontwarn lombok.Generated
|
||||
-dontwarn org.slf4j.impl.StaticLoggerBinder
|
||||
-dontwarn sun.net.spi.nameservice.NameServiceDescriptor
|
||||
@@ -3,25 +3,18 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission
|
||||
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||
android:usesPermissionFlags="neverForLocation" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||
<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_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
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_CALENDAR" />
|
||||
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
||||
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
android:required="false" />
|
||||
@@ -39,34 +32,14 @@
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:theme="@style/Theme.OpenClawNode">
|
||||
android:theme="@style/Theme.ClawdbotNode">
|
||||
<service
|
||||
android:name=".NodeForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
<service
|
||||
android:name=".node.DeviceNotificationListenerService"
|
||||
android:label="@string/app_name"
|
||||
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.notification.NotificationListenerService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
android:foregroundServiceType="dataSync|microphone|mediaProjection" />
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|uiMode|density|keyboard|keyboardHidden|navigation">
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import ai.openclaw.app.ui.RootScreen
|
||||
import ai.openclaw.app.ui.OpenClawTheme
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val viewModel: MainViewModel by viewModels()
|
||||
private lateinit var permissionRequester: PermissionRequester
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
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) {
|
||||
viewModel.preventSleep.collect { enabled ->
|
||||
if (enabled) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
} else {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setContent {
|
||||
OpenClawTheme {
|
||||
Surface(modifier = Modifier) {
|
||||
RootScreen(viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keep startup path lean: start foreground service after first frame.
|
||||
window.decorView.post { NodeForegroundService.start(this) }
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
viewModel.setForeground(true)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
viewModel.setForeground(false)
|
||||
super.onStop()
|
||||
}
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
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.flow.StateFlow
|
||||
|
||||
class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
private val runtime: NodeRuntime = (app as NodeApp).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
|
||||
|
||||
val gateways: StateFlow<List<GatewayEndpoint>> = runtime.gateways
|
||||
val discoveryStatusText: StateFlow<String> = runtime.discoveryStatusText
|
||||
|
||||
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 cameraHud: StateFlow<CameraHudState?> = runtime.cameraHud
|
||||
val cameraFlashToken: StateFlow<Long> = runtime.cameraFlashToken
|
||||
|
||||
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 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) {
|
||||
runtime.setForeground(value)
|
||||
}
|
||||
|
||||
fun setDisplayName(value: String) {
|
||||
runtime.setDisplayName(value)
|
||||
}
|
||||
|
||||
fun setCameraEnabled(value: Boolean) {
|
||||
runtime.setCameraEnabled(value)
|
||||
}
|
||||
|
||||
fun setLocationMode(mode: LocationMode) {
|
||||
runtime.setLocationMode(mode)
|
||||
}
|
||||
|
||||
fun setLocationPreciseEnabled(value: Boolean) {
|
||||
runtime.setLocationPreciseEnabled(value)
|
||||
}
|
||||
|
||||
fun setPreventSleep(value: Boolean) {
|
||||
runtime.setPreventSleep(value)
|
||||
}
|
||||
|
||||
fun setManualEnabled(value: Boolean) {
|
||||
runtime.setManualEnabled(value)
|
||||
}
|
||||
|
||||
fun setManualHost(value: String) {
|
||||
runtime.setManualHost(value)
|
||||
}
|
||||
|
||||
fun setManualPort(value: Int) {
|
||||
runtime.setManualPort(value)
|
||||
}
|
||||
|
||||
fun setManualTls(value: Boolean) {
|
||||
runtime.setManualTls(value)
|
||||
}
|
||||
|
||||
fun setGatewayToken(value: String) {
|
||||
runtime.setGatewayToken(value)
|
||||
}
|
||||
|
||||
fun setGatewayBootstrapToken(value: String) {
|
||||
runtime.setGatewayBootstrapToken(value)
|
||||
}
|
||||
|
||||
fun setGatewayPassword(value: String) {
|
||||
runtime.setGatewayPassword(value)
|
||||
}
|
||||
|
||||
fun setOnboardingCompleted(value: Boolean) {
|
||||
runtime.setOnboardingCompleted(value)
|
||||
}
|
||||
|
||||
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
||||
runtime.setCanvasDebugStatusEnabled(value)
|
||||
}
|
||||
|
||||
fun setVoiceScreenActive(active: Boolean) {
|
||||
runtime.setVoiceScreenActive(active)
|
||||
}
|
||||
|
||||
fun setMicEnabled(enabled: Boolean) {
|
||||
runtime.setMicEnabled(enabled)
|
||||
}
|
||||
|
||||
fun setSpeakerEnabled(enabled: Boolean) {
|
||||
runtime.setSpeakerEnabled(enabled)
|
||||
}
|
||||
|
||||
fun refreshGatewayConnection() {
|
||||
runtime.refreshGatewayConnection()
|
||||
}
|
||||
|
||||
fun connect(endpoint: GatewayEndpoint) {
|
||||
runtime.connect(endpoint)
|
||||
}
|
||||
|
||||
fun connectManual() {
|
||||
runtime.connectManual()
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
runtime.disconnect()
|
||||
}
|
||||
|
||||
fun acceptGatewayTrustPrompt() {
|
||||
runtime.acceptGatewayTrustPrompt()
|
||||
}
|
||||
|
||||
fun declineGatewayTrustPrompt() {
|
||||
runtime.declineGatewayTrustPrompt()
|
||||
}
|
||||
|
||||
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
|
||||
runtime.handleCanvasA2UIActionFromWebView(payloadJson)
|
||||
}
|
||||
|
||||
fun requestCanvasRehydrate(source: String = "screen_tab") {
|
||||
runtime.requestCanvasRehydrate(source = source, force = true)
|
||||
}
|
||||
|
||||
fun loadChat(sessionKey: String) {
|
||||
runtime.loadChat(sessionKey)
|
||||
}
|
||||
|
||||
fun refreshChat() {
|
||||
runtime.refreshChat()
|
||||
}
|
||||
|
||||
fun refreshChatSessions(limit: Int? = null) {
|
||||
runtime.refreshChatSessions(limit = limit)
|
||||
}
|
||||
|
||||
fun setChatThinkingLevel(level: String) {
|
||||
runtime.setChatThinkingLevel(level)
|
||||
}
|
||||
|
||||
fun switchChatSession(sessionKey: String) {
|
||||
runtime.switchChatSession(sessionKey)
|
||||
}
|
||||
|
||||
fun abortChat() {
|
||||
runtime.abortChat()
|
||||
}
|
||||
|
||||
fun sendChat(message: String, thinking: String, attachments: List<OutgoingAttachment>) {
|
||||
runtime.sendChat(message = message, thinking = thinking, attachments = attachments)
|
||||
}
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import androidx.core.app.NotificationCompat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class NodeForegroundService : Service() {
|
||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
private var notificationJob: Job? = null
|
||||
private var didStartForeground = false
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
ensureChannel()
|
||||
val initial = buildNotification(title = "OpenClaw Node", text = "Starting…")
|
||||
startForegroundWithTypes(notification = initial)
|
||||
|
||||
val runtime = (application as NodeApp).runtime
|
||||
notificationJob =
|
||||
scope.launch {
|
||||
combine(
|
||||
runtime.statusText,
|
||||
runtime.serverName,
|
||||
runtime.isConnected,
|
||||
runtime.micEnabled,
|
||||
runtime.micIsListening,
|
||||
) { status, server, connected, micEnabled, micListening ->
|
||||
Quint(status, server, connected, micEnabled, micListening)
|
||||
}.collect { (status, server, connected, micEnabled, micListening) ->
|
||||
val title = if (connected) "OpenClaw Node · Connected" else "OpenClaw Node"
|
||||
val micSuffix =
|
||||
if (micEnabled) {
|
||||
if (micListening) " · Mic: Listening" else " · Mic: Pending"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
val text = (server?.let { "$status · $it" } ?: status) + micSuffix
|
||||
|
||||
startForegroundWithTypes(
|
||||
notification = buildNotification(title = title, text = text),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_STOP -> {
|
||||
(application as NodeApp).runtime.disconnect()
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
}
|
||||
// Keep running; connection is managed by NodeRuntime (auto-reconnect + manual).
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
notificationJob?.cancel()
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?) = null
|
||||
|
||||
private fun ensureChannel() {
|
||||
val mgr = getSystemService(NotificationManager::class.java)
|
||||
val channel =
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Connection",
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
description = "OpenClaw node connection status"
|
||||
setShowBadge(false)
|
||||
}
|
||||
mgr.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun buildNotification(title: String, text: String): Notification {
|
||||
val launchIntent = Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
val launchPending =
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
1,
|
||||
launchIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
val stopIntent = Intent(this, NodeForegroundService::class.java).setAction(ACTION_STOP)
|
||||
val stopPending =
|
||||
PendingIntent.getService(
|
||||
this,
|
||||
2,
|
||||
stopIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setContentIntent(launchPending)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||
.addAction(0, "Disconnect", stopPending)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun updateNotification(notification: Notification) {
|
||||
val mgr = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
mgr.notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
private fun startForegroundWithTypes(notification: Notification) {
|
||||
if (didStartForeground) {
|
||||
updateNotification(notification)
|
||||
return
|
||||
}
|
||||
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
didStartForeground = true
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "connection"
|
||||
private const val NOTIFICATION_ID = 1
|
||||
|
||||
private const val ACTION_STOP = "ai.openclaw.app.action.STOP"
|
||||
|
||||
fun start(context: Context) {
|
||||
val intent = Intent(context, NodeForegroundService::class.java)
|
||||
context.startForegroundService(intent)
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
val intent = Intent(context, NodeForegroundService::class.java).setAction(ACTION_STOP)
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class Quint<A, B, C, D, E>(val first: A, val second: B, val third: C, val fourth: D, val fifth: E)
|
||||
@@ -1,953 +0,0 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import ai.openclaw.app.chat.ChatController
|
||||
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.DeviceAuthStore
|
||||
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.probeGatewayTlsFingerprint
|
||||
import ai.openclaw.app.node.*
|
||||
import ai.openclaw.app.protocol.OpenClawCanvasA2UIAction
|
||||
import ai.openclaw.app.voice.MicCaptureManager
|
||||
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
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
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 java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
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)
|
||||
val location = LocationCaptureManager(appContext)
|
||||
val sms = SmsManager(appContext)
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private val externalAudioCaptureActive = MutableStateFlow(false)
|
||||
|
||||
private val discovery = GatewayDiscovery(appContext, scope = scope)
|
||||
val gateways: StateFlow<List<GatewayEndpoint>> = discovery.gateways
|
||||
val discoveryStatusText: StateFlow<String> = discovery.statusText
|
||||
|
||||
private val identityStore = DeviceIdentityStore(appContext)
|
||||
private var connectedEndpoint: GatewayEndpoint? = null
|
||||
|
||||
private val cameraHandler: CameraHandler = CameraHandler(
|
||||
appContext = appContext,
|
||||
camera = camera,
|
||||
externalAudioCaptureActive = externalAudioCaptureActive,
|
||||
showCameraHud = ::showCameraHud,
|
||||
triggerCameraFlash = ::triggerCameraFlash,
|
||||
invokeErrorFromThrowable = { invokeErrorFromThrowable(it) },
|
||||
)
|
||||
|
||||
private val debugHandler: DebugHandler = DebugHandler(
|
||||
appContext = appContext,
|
||||
identityStore = identityStore,
|
||||
)
|
||||
|
||||
private val locationHandler: LocationHandler = LocationHandler(
|
||||
appContext = appContext,
|
||||
location = location,
|
||||
json = json,
|
||||
isForeground = { _isForeground.value },
|
||||
locationPreciseEnabled = { locationPreciseEnabled.value },
|
||||
)
|
||||
|
||||
private val deviceHandler: DeviceHandler = DeviceHandler(
|
||||
appContext = appContext,
|
||||
)
|
||||
|
||||
private val notificationsHandler: NotificationsHandler = NotificationsHandler(
|
||||
appContext = appContext,
|
||||
)
|
||||
|
||||
private val systemHandler: SystemHandler = SystemHandler(
|
||||
appContext = appContext,
|
||||
)
|
||||
|
||||
private val photosHandler: PhotosHandler = PhotosHandler(
|
||||
appContext = appContext,
|
||||
)
|
||||
|
||||
private val contactsHandler: ContactsHandler = ContactsHandler(
|
||||
appContext = appContext,
|
||||
)
|
||||
|
||||
private val calendarHandler: CalendarHandler = CalendarHandler(
|
||||
appContext = appContext,
|
||||
)
|
||||
|
||||
private val motionHandler: MotionHandler = MotionHandler(
|
||||
appContext = appContext,
|
||||
)
|
||||
|
||||
private val smsHandlerImpl: SmsHandler = SmsHandler(
|
||||
sms = sms,
|
||||
)
|
||||
|
||||
private val a2uiHandler: A2UIHandler = A2UIHandler(
|
||||
canvas = canvas,
|
||||
json = json,
|
||||
getNodeCanvasHostUrl = { nodeSession.currentCanvasHostUrl() },
|
||||
getOperatorCanvasHostUrl = { operatorSession.currentCanvasHostUrl() },
|
||||
)
|
||||
|
||||
private val connectionManager: ConnectionManager = ConnectionManager(
|
||||
prefs = prefs,
|
||||
cameraEnabled = { cameraEnabled.value },
|
||||
locationMode = { locationMode.value },
|
||||
voiceWakeMode = { VoiceWakeMode.Off },
|
||||
motionActivityAvailable = { motionHandler.isActivityAvailable() },
|
||||
motionPedometerAvailable = { motionHandler.isPedometerAvailable() },
|
||||
smsAvailable = { sms.canSendSms() },
|
||||
hasRecordAudioPermission = { hasRecordAudioPermission() },
|
||||
manualTls = { manualTls.value },
|
||||
)
|
||||
|
||||
private val invokeDispatcher: InvokeDispatcher = InvokeDispatcher(
|
||||
canvas = canvas,
|
||||
cameraHandler = cameraHandler,
|
||||
locationHandler = locationHandler,
|
||||
deviceHandler = deviceHandler,
|
||||
notificationsHandler = notificationsHandler,
|
||||
systemHandler = systemHandler,
|
||||
photosHandler = photosHandler,
|
||||
contactsHandler = contactsHandler,
|
||||
calendarHandler = calendarHandler,
|
||||
motionHandler = motionHandler,
|
||||
smsHandler = smsHandlerImpl,
|
||||
a2uiHandler = a2uiHandler,
|
||||
debugHandler = debugHandler,
|
||||
isForeground = { _isForeground.value },
|
||||
cameraEnabled = { cameraEnabled.value },
|
||||
locationEnabled = { locationMode.value != LocationMode.Off },
|
||||
smsAvailable = { sms.canSendSms() },
|
||||
debugBuild = { BuildConfig.DEBUG },
|
||||
refreshNodeCanvasCapability = { nodeSession.refreshNodeCanvasCapability() },
|
||||
onCanvasA2uiPush = {
|
||||
_canvasA2uiHydrated.value = true
|
||||
_canvasRehydratePending.value = false
|
||||
_canvasRehydrateErrorText.value = null
|
||||
},
|
||||
onCanvasA2uiReset = { _canvasA2uiHydrated.value = false },
|
||||
motionActivityAvailable = { motionHandler.isActivityAvailable() },
|
||||
motionPedometerAvailable = { motionHandler.isPedometerAvailable() },
|
||||
)
|
||||
|
||||
data class GatewayTrustPrompt(
|
||||
val endpoint: GatewayEndpoint,
|
||||
val fingerprintSha256: String,
|
||||
)
|
||||
|
||||
private val _isConnected = MutableStateFlow(false)
|
||||
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
||||
private val _nodeConnected = MutableStateFlow(false)
|
||||
val nodeConnected: StateFlow<Boolean> = _nodeConnected.asStateFlow()
|
||||
|
||||
private val _statusText = MutableStateFlow("Offline")
|
||||
val statusText: StateFlow<String> = _statusText.asStateFlow()
|
||||
|
||||
private val _pendingGatewayTrust = MutableStateFlow<GatewayTrustPrompt?>(null)
|
||||
val pendingGatewayTrust: StateFlow<GatewayTrustPrompt?> = _pendingGatewayTrust.asStateFlow()
|
||||
|
||||
private val _mainSessionKey = MutableStateFlow("main")
|
||||
val mainSessionKey: StateFlow<String> = _mainSessionKey.asStateFlow()
|
||||
|
||||
private val cameraHudSeq = AtomicLong(0)
|
||||
private val _cameraHud = MutableStateFlow<CameraHudState?>(null)
|
||||
val cameraHud: StateFlow<CameraHudState?> = _cameraHud.asStateFlow()
|
||||
|
||||
private val _cameraFlashToken = MutableStateFlow(0L)
|
||||
val cameraFlashToken: StateFlow<Long> = _cameraFlashToken.asStateFlow()
|
||||
|
||||
private val _canvasA2uiHydrated = MutableStateFlow(false)
|
||||
val canvasA2uiHydrated: StateFlow<Boolean> = _canvasA2uiHydrated.asStateFlow()
|
||||
private val _canvasRehydratePending = MutableStateFlow(false)
|
||||
val canvasRehydratePending: StateFlow<Boolean> = _canvasRehydratePending.asStateFlow()
|
||||
private val _canvasRehydrateErrorText = MutableStateFlow<String?>(null)
|
||||
val canvasRehydrateErrorText: StateFlow<String?> = _canvasRehydrateErrorText.asStateFlow()
|
||||
|
||||
private val _serverName = MutableStateFlow<String?>(null)
|
||||
val serverName: StateFlow<String?> = _serverName.asStateFlow()
|
||||
|
||||
private val _remoteAddress = MutableStateFlow<String?>(null)
|
||||
val remoteAddress: StateFlow<String?> = _remoteAddress.asStateFlow()
|
||||
|
||||
private val _seamColorArgb = MutableStateFlow(DEFAULT_SEAM_COLOR_ARGB)
|
||||
val seamColorArgb: StateFlow<Long> = _seamColorArgb.asStateFlow()
|
||||
|
||||
private val _isForeground = MutableStateFlow(true)
|
||||
val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
|
||||
|
||||
private var lastAutoA2uiUrl: String? = null
|
||||
private var didAutoRequestCanvasRehydrate = false
|
||||
private val canvasRehydrateSeq = AtomicLong(0)
|
||||
private var operatorConnected = false
|
||||
private var operatorStatusText: String = "Offline"
|
||||
private var nodeStatusText: String = "Offline"
|
||||
|
||||
private val operatorSession =
|
||||
GatewaySession(
|
||||
scope = scope,
|
||||
identityStore = identityStore,
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = { name, remote, mainSessionKey ->
|
||||
operatorConnected = true
|
||||
operatorStatusText = "Connected"
|
||||
_serverName.value = name
|
||||
_remoteAddress.value = remote
|
||||
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
||||
applyMainSessionKey(mainSessionKey)
|
||||
updateStatus()
|
||||
micCapture.onGatewayConnectionChanged(true)
|
||||
scope.launch {
|
||||
refreshBrandingFromGateway()
|
||||
if (voiceReplySpeakerLazy.isInitialized()) {
|
||||
voiceReplySpeaker.refreshConfig()
|
||||
}
|
||||
}
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
operatorConnected = false
|
||||
operatorStatusText = message
|
||||
_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()
|
||||
micCapture.onGatewayConnectionChanged(false)
|
||||
},
|
||||
onEvent = { event, payloadJson ->
|
||||
handleGatewayEvent(event, payloadJson)
|
||||
},
|
||||
)
|
||||
|
||||
private val nodeSession =
|
||||
GatewaySession(
|
||||
scope = scope,
|
||||
identityStore = identityStore,
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = { _, _, _ ->
|
||||
_nodeConnected.value = true
|
||||
nodeStatusText = "Connected"
|
||||
didAutoRequestCanvasRehydrate = false
|
||||
_canvasA2uiHydrated.value = false
|
||||
_canvasRehydratePending.value = false
|
||||
_canvasRehydrateErrorText.value = null
|
||||
updateStatus()
|
||||
maybeNavigateToA2uiOnConnect()
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
_nodeConnected.value = false
|
||||
nodeStatusText = message
|
||||
didAutoRequestCanvasRehydrate = false
|
||||
_canvasA2uiHydrated.value = false
|
||||
_canvasRehydratePending.value = false
|
||||
_canvasRehydrateErrorText.value = null
|
||||
updateStatus()
|
||||
showLocalCanvasOnDisconnect()
|
||||
},
|
||||
onEvent = { _, _ -> },
|
||||
onInvoke = { req ->
|
||||
invokeDispatcher.handleInvoke(req.command, req.paramsJson)
|
||||
},
|
||||
onTlsFingerprint = { stableId, fingerprint ->
|
||||
prefs.saveGatewayTlsFingerprint(stableId, fingerprint)
|
||||
},
|
||||
)
|
||||
|
||||
init {
|
||||
DeviceNotificationListenerService.setNodeEventSink { event, payloadJson ->
|
||||
scope.launch {
|
||||
nodeSession.sendNodeEvent(event = event, payloadJson = payloadJson)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val chat: ChatController =
|
||||
ChatController(
|
||||
scope = scope,
|
||||
session = operatorSession,
|
||||
json = json,
|
||||
supportsChatSubscribe = false,
|
||||
)
|
||||
private val voiceReplySpeakerLazy: Lazy<TalkModeManager> = lazy {
|
||||
// Reuse the existing TalkMode speech engine (ElevenLabs + deterministic system-TTS fallback)
|
||||
// without enabling the legacy talk capture loop.
|
||||
TalkModeManager(
|
||||
context = appContext,
|
||||
scope = scope,
|
||||
session = operatorSession,
|
||||
supportsChatSubscribe = false,
|
||||
isConnected = { operatorConnected },
|
||||
).also { speaker ->
|
||||
speaker.setPlaybackEnabled(prefs.speakerEnabled.value)
|
||||
}
|
||||
}
|
||||
private val voiceReplySpeaker: TalkModeManager
|
||||
get() = voiceReplySpeakerLazy.value
|
||||
|
||||
private val micCapture: MicCaptureManager by lazy {
|
||||
MicCaptureManager(
|
||||
context = appContext,
|
||||
scope = scope,
|
||||
sendToGateway = { message, onRunIdKnown ->
|
||||
val idempotencyKey = UUID.randomUUID().toString()
|
||||
// Notify MicCaptureManager of the idempotency key *before* the network
|
||||
// call so pendingRunId is set before any chat events can arrive.
|
||||
onRunIdKnown(idempotencyKey)
|
||||
val params =
|
||||
buildJsonObject {
|
||||
put("sessionKey", JsonPrimitive(resolveMainSessionKey()))
|
||||
put("message", JsonPrimitive(message))
|
||||
put("thinking", JsonPrimitive(chatThinkingLevel.value))
|
||||
put("timeoutMs", JsonPrimitive(30_000))
|
||||
put("idempotencyKey", JsonPrimitive(idempotencyKey))
|
||||
}
|
||||
val response = operatorSession.request("chat.send", params.toString())
|
||||
parseChatSendRunId(response) ?: idempotencyKey
|
||||
},
|
||||
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)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val micStatusText: StateFlow<String>
|
||||
get() = micCapture.statusText
|
||||
|
||||
val micLiveTranscript: StateFlow<String?>
|
||||
get() = micCapture.liveTranscript
|
||||
|
||||
val micIsListening: StateFlow<Boolean>
|
||||
get() = micCapture.isListening
|
||||
|
||||
val micEnabled: StateFlow<Boolean>
|
||||
get() = micCapture.micEnabled
|
||||
|
||||
val micCooldown: StateFlow<Boolean>
|
||||
get() = micCapture.micCooldown
|
||||
|
||||
val micQueuedMessages: StateFlow<List<String>>
|
||||
get() = micCapture.queuedMessages
|
||||
|
||||
val micConversation: StateFlow<List<VoiceConversationEntry>>
|
||||
get() = micCapture.conversation
|
||||
|
||||
val micInputLevel: StateFlow<Float>
|
||||
get() = micCapture.inputLevel
|
||||
|
||||
val micIsSending: StateFlow<Boolean>
|
||||
get() = micCapture.isSending
|
||||
|
||||
private val talkMode: TalkModeManager by lazy {
|
||||
TalkModeManager(
|
||||
context = appContext,
|
||||
scope = scope,
|
||||
session = operatorSession,
|
||||
supportsChatSubscribe = true,
|
||||
isConnected = { operatorConnected },
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
private fun updateStatus() {
|
||||
_isConnected.value = operatorConnected
|
||||
val operator = operatorStatusText.trim()
|
||||
val node = nodeStatusText.trim()
|
||||
_statusText.value =
|
||||
when {
|
||||
operatorConnected && _nodeConnected.value -> "Connected"
|
||||
operatorConnected && !_nodeConnected.value -> "Connected (node offline)"
|
||||
!operatorConnected && _nodeConnected.value ->
|
||||
if (operator.isNotEmpty() && operator != "Offline") {
|
||||
"Connected (operator: $operator)"
|
||||
} else {
|
||||
"Connected (operator offline)"
|
||||
}
|
||||
operator.isNotBlank() && operator != "Offline" -> operator
|
||||
else -> node
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveMainSessionKey(): String {
|
||||
val trimmed = _mainSessionKey.value.trim()
|
||||
return if (trimmed.isEmpty()) "main" else trimmed
|
||||
}
|
||||
|
||||
private fun maybeNavigateToA2uiOnConnect() {
|
||||
val a2uiUrl = a2uiHandler.resolveA2uiHostUrl() ?: return
|
||||
val current = canvas.currentUrl()?.trim().orEmpty()
|
||||
if (current.isEmpty() || current == lastAutoA2uiUrl) {
|
||||
lastAutoA2uiUrl = a2uiUrl
|
||||
canvas.navigate(a2uiUrl)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showLocalCanvasOnDisconnect() {
|
||||
lastAutoA2uiUrl = null
|
||||
_canvasA2uiHydrated.value = false
|
||||
_canvasRehydratePending.value = false
|
||||
_canvasRehydrateErrorText.value = null
|
||||
canvas.navigate("")
|
||||
}
|
||||
|
||||
fun requestCanvasRehydrate(source: String = "manual", force: Boolean = true) {
|
||||
scope.launch {
|
||||
if (!_nodeConnected.value) {
|
||||
_canvasRehydratePending.value = false
|
||||
_canvasRehydrateErrorText.value = "Node offline. Reconnect and retry."
|
||||
return@launch
|
||||
}
|
||||
if (!force && didAutoRequestCanvasRehydrate) return@launch
|
||||
didAutoRequestCanvasRehydrate = true
|
||||
val requestId = canvasRehydrateSeq.incrementAndGet()
|
||||
_canvasRehydratePending.value = true
|
||||
_canvasRehydrateErrorText.value = null
|
||||
|
||||
val sessionKey = resolveMainSessionKey()
|
||||
val prompt =
|
||||
"Restore canvas now for session=$sessionKey source=$source. " +
|
||||
"If existing A2UI state exists, replay it immediately. " +
|
||||
"If not, create and render a compact mobile-friendly dashboard in Canvas."
|
||||
val sent =
|
||||
nodeSession.sendNodeEvent(
|
||||
event = "agent.request",
|
||||
payloadJson =
|
||||
buildJsonObject {
|
||||
put("message", JsonPrimitive(prompt))
|
||||
put("sessionKey", JsonPrimitive(sessionKey))
|
||||
put("thinking", JsonPrimitive("low"))
|
||||
put("deliver", JsonPrimitive(false))
|
||||
}.toString(),
|
||||
)
|
||||
if (!sent) {
|
||||
if (!force) {
|
||||
didAutoRequestCanvasRehydrate = false
|
||||
}
|
||||
if (canvasRehydrateSeq.get() == requestId) {
|
||||
_canvasRehydratePending.value = false
|
||||
_canvasRehydrateErrorText.value = "Failed to request restore. Tap to retry."
|
||||
}
|
||||
Log.w("OpenClawCanvas", "canvas rehydrate request failed ($source): transport unavailable")
|
||||
return@launch
|
||||
}
|
||||
scope.launch {
|
||||
delay(20_000)
|
||||
if (canvasRehydrateSeq.get() != requestId) return@launch
|
||||
if (!_canvasRehydratePending.value) return@launch
|
||||
if (_canvasA2uiHydrated.value) return@launch
|
||||
_canvasRehydratePending.value = false
|
||||
_canvasRehydrateErrorText.value = "No canvas update yet. Tap to retry."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 onboardingCompleted: StateFlow<Boolean> = prefs.onboardingCompleted
|
||||
fun setGatewayToken(value: String) = prefs.setGatewayToken(value)
|
||||
fun setGatewayBootstrapToken(value: String) = prefs.setGatewayBootstrapToken(value)
|
||||
fun setGatewayPassword(value: String) = prefs.setGatewayPassword(value)
|
||||
fun setOnboardingCompleted(value: Boolean) = prefs.setOnboardingCompleted(value)
|
||||
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
|
||||
|
||||
private var didAutoConnect = false
|
||||
|
||||
val chatSessionKey: StateFlow<String> = chat.sessionKey
|
||||
val chatSessionId: StateFlow<String?> = chat.sessionId
|
||||
val chatMessages: StateFlow<List<ChatMessage>> = chat.messages
|
||||
val chatError: StateFlow<String?> = chat.errorText
|
||||
val chatHealthOk: StateFlow<Boolean> = chat.healthOk
|
||||
val chatThinkingLevel: StateFlow<String> = chat.thinkingLevel
|
||||
val chatStreamingAssistantText: StateFlow<String?> = chat.streamingAssistantText
|
||||
val chatPendingToolCalls: StateFlow<List<ChatPendingToolCall>> = chat.pendingToolCalls
|
||||
val chatSessions: StateFlow<List<ChatSessionEntry>> = chat.sessions
|
||||
val pendingRunCount: StateFlow<Int> = chat.pendingRunCount
|
||||
|
||||
init {
|
||||
if (prefs.voiceWakeMode.value != VoiceWakeMode.Off) {
|
||||
prefs.setVoiceWakeMode(VoiceWakeMode.Off)
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
prefs.loadGatewayToken()
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
prefs.talkEnabled.collect { enabled ->
|
||||
// MicCaptureManager handles STT + send to gateway.
|
||||
// TalkModeManager plays TTS on assistant responses.
|
||||
micCapture.setMicEnabled(enabled)
|
||||
if (enabled) {
|
||||
// Mic on = user is on voice screen and wants TTS responses.
|
||||
talkMode.ttsOnAllResponses = true
|
||||
scope.launch { talkMode.ensureChatSubscribed() }
|
||||
}
|
||||
externalAudioCaptureActive.value = enabled
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch(Dispatchers.Default) {
|
||||
gateways.collect { list ->
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
combine(
|
||||
canvasDebugStatusEnabled,
|
||||
statusText,
|
||||
serverName,
|
||||
remoteAddress,
|
||||
) { debugEnabled, status, server, remote ->
|
||||
Quad(debugEnabled, status, server, remote)
|
||||
}.distinctUntilChanged()
|
||||
.collect { (debugEnabled, status, server, remote) ->
|
||||
canvas.setDebugStatusEnabled(debugEnabled)
|
||||
if (!debugEnabled) return@collect
|
||||
canvas.setDebugStatus(status, server ?: remote)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setForeground(value: Boolean) {
|
||||
_isForeground.value = value
|
||||
if (!value) {
|
||||
stopActiveVoiceSession()
|
||||
}
|
||||
}
|
||||
|
||||
fun setDisplayName(value: String) {
|
||||
prefs.setDisplayName(value)
|
||||
}
|
||||
|
||||
fun setCameraEnabled(value: Boolean) {
|
||||
prefs.setCameraEnabled(value)
|
||||
}
|
||||
|
||||
fun setLocationMode(mode: LocationMode) {
|
||||
prefs.setLocationMode(mode)
|
||||
}
|
||||
|
||||
fun setLocationPreciseEnabled(value: Boolean) {
|
||||
prefs.setLocationPreciseEnabled(value)
|
||||
}
|
||||
|
||||
fun setPreventSleep(value: Boolean) {
|
||||
prefs.setPreventSleep(value)
|
||||
}
|
||||
|
||||
fun setManualEnabled(value: Boolean) {
|
||||
prefs.setManualEnabled(value)
|
||||
}
|
||||
|
||||
fun setManualHost(value: String) {
|
||||
prefs.setManualHost(value)
|
||||
}
|
||||
|
||||
fun setManualPort(value: Int) {
|
||||
prefs.setManualPort(value)
|
||||
}
|
||||
|
||||
fun setManualTls(value: Boolean) {
|
||||
prefs.setManualTls(value)
|
||||
}
|
||||
|
||||
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
||||
prefs.setCanvasDebugStatusEnabled(value)
|
||||
}
|
||||
|
||||
fun setVoiceScreenActive(active: Boolean) {
|
||||
if (!active) {
|
||||
stopActiveVoiceSession()
|
||||
}
|
||||
// Don't re-enable on active=true; mic toggle drives that
|
||||
}
|
||||
|
||||
fun setMicEnabled(value: Boolean) {
|
||||
prefs.setTalkEnabled(value)
|
||||
if (value) {
|
||||
// Tapping mic on interrupts any active TTS (barge-in)
|
||||
talkMode.stopTts()
|
||||
talkMode.ttsOnAllResponses = true
|
||||
scope.launch { talkMode.ensureChatSubscribed() }
|
||||
}
|
||||
micCapture.setMicEnabled(value)
|
||||
externalAudioCaptureActive.value = value
|
||||
}
|
||||
|
||||
val speakerEnabled: StateFlow<Boolean>
|
||||
get() = prefs.speakerEnabled
|
||||
|
||||
fun setSpeakerEnabled(value: Boolean) {
|
||||
prefs.setSpeakerEnabled(value)
|
||||
if (voiceReplySpeakerLazy.isInitialized()) {
|
||||
voiceReplySpeaker.setPlaybackEnabled(value)
|
||||
}
|
||||
// Keep TalkMode in sync so speaker mute works when ttsOnAllResponses is active.
|
||||
talkMode.setPlaybackEnabled(value)
|
||||
}
|
||||
|
||||
private fun stopActiveVoiceSession() {
|
||||
talkMode.ttsOnAllResponses = false
|
||||
talkMode.stopTts()
|
||||
micCapture.setMicEnabled(false)
|
||||
prefs.setTalkEnabled(false)
|
||||
externalAudioCaptureActive.value = false
|
||||
}
|
||||
|
||||
fun refreshGatewayConnection() {
|
||||
val endpoint =
|
||||
connectedEndpoint ?: run {
|
||||
_statusText.value = "Failed: no cached gateway endpoint"
|
||||
return
|
||||
}
|
||||
operatorStatusText = "Connecting…"
|
||||
updateStatus()
|
||||
val token = prefs.loadGatewayToken()
|
||||
val bootstrapToken = prefs.loadGatewayBootstrapToken()
|
||||
val password = prefs.loadGatewayPassword()
|
||||
val tls = connectionManager.resolveTlsParams(endpoint)
|
||||
operatorSession.connect(
|
||||
endpoint,
|
||||
token,
|
||||
bootstrapToken,
|
||||
password,
|
||||
connectionManager.buildOperatorConnectOptions(),
|
||||
tls,
|
||||
)
|
||||
nodeSession.connect(
|
||||
endpoint,
|
||||
token,
|
||||
bootstrapToken,
|
||||
password,
|
||||
connectionManager.buildNodeConnectOptions(),
|
||||
tls,
|
||||
)
|
||||
operatorSession.reconnect()
|
||||
nodeSession.reconnect()
|
||||
}
|
||||
|
||||
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 fp = probeGatewayTlsFingerprint(endpoint.host, endpoint.port) ?: run {
|
||||
_statusText.value = "Failed: can't read TLS fingerprint"
|
||||
return@launch
|
||||
}
|
||||
_pendingGatewayTrust.value = GatewayTrustPrompt(endpoint = endpoint, fingerprintSha256 = fp)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
connectedEndpoint = endpoint
|
||||
operatorStatusText = "Connecting…"
|
||||
nodeStatusText = "Connecting…"
|
||||
updateStatus()
|
||||
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)
|
||||
connect(prompt.endpoint)
|
||||
}
|
||||
|
||||
fun declineGatewayTrustPrompt() {
|
||||
_pendingGatewayTrust.value = null
|
||||
_statusText.value = "Offline"
|
||||
}
|
||||
|
||||
private fun hasRecordAudioPermission(): Boolean {
|
||||
return (
|
||||
ContextCompat.checkSelfPermission(appContext, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
|
||||
fun connectManual() {
|
||||
val host = manualHost.value.trim()
|
||||
val port = manualPort.value
|
||||
if (host.isEmpty() || port <= 0 || port > 65535) {
|
||||
_statusText.value = "Failed: invalid manual host/port"
|
||||
return
|
||||
}
|
||||
connect(GatewayEndpoint.manual(host = host, port = port))
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
connectedEndpoint = null
|
||||
_pendingGatewayTrust.value = null
|
||||
operatorSession.disconnect()
|
||||
nodeSession.disconnect()
|
||||
}
|
||||
|
||||
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
|
||||
scope.launch {
|
||||
val trimmed = payloadJson.trim()
|
||||
if (trimmed.isEmpty()) return@launch
|
||||
|
||||
val root =
|
||||
try {
|
||||
json.parseToJsonElement(trimmed).asObjectOrNull() ?: return@launch
|
||||
} catch (_: Throwable) {
|
||||
return@launch
|
||||
}
|
||||
|
||||
val userActionObj = (root["userAction"] as? JsonObject) ?: root
|
||||
val actionId = (userActionObj["id"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty {
|
||||
java.util.UUID.randomUUID().toString()
|
||||
}
|
||||
val name = OpenClawCanvasA2UIAction.extractActionName(userActionObj) ?: return@launch
|
||||
|
||||
val surfaceId =
|
||||
(userActionObj["surfaceId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "main" }
|
||||
val sourceComponentId =
|
||||
(userActionObj["sourceComponentId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "-" }
|
||||
val contextJson = (userActionObj["context"] as? JsonObject)?.toString()
|
||||
|
||||
val sessionKey = resolveMainSessionKey()
|
||||
val message =
|
||||
OpenClawCanvasA2UIAction.formatAgentMessage(
|
||||
actionName = name,
|
||||
sessionKey = sessionKey,
|
||||
surfaceId = surfaceId,
|
||||
sourceComponentId = sourceComponentId,
|
||||
host = displayName.value,
|
||||
instanceId = instanceId.value.lowercase(),
|
||||
contextJson = contextJson,
|
||||
)
|
||||
|
||||
val connected = _nodeConnected.value
|
||||
var error: String? = null
|
||||
if (connected) {
|
||||
val sent =
|
||||
nodeSession.sendNodeEvent(
|
||||
event = "agent.request",
|
||||
payloadJson =
|
||||
buildJsonObject {
|
||||
put("message", JsonPrimitive(message))
|
||||
put("sessionKey", JsonPrimitive(sessionKey))
|
||||
put("thinking", JsonPrimitive("low"))
|
||||
put("deliver", JsonPrimitive(false))
|
||||
put("key", JsonPrimitive(actionId))
|
||||
}.toString(),
|
||||
)
|
||||
if (!sent) {
|
||||
error = "send failed"
|
||||
}
|
||||
} else {
|
||||
error = "gateway not connected"
|
||||
}
|
||||
|
||||
try {
|
||||
canvas.eval(
|
||||
OpenClawCanvasA2UIAction.jsDispatchA2UIActionStatus(
|
||||
actionId = actionId,
|
||||
ok = connected && error == null,
|
||||
error = error,
|
||||
),
|
||||
)
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadChat(sessionKey: String) {
|
||||
val key = sessionKey.trim().ifEmpty { resolveMainSessionKey() }
|
||||
chat.load(key)
|
||||
}
|
||||
|
||||
fun refreshChat() {
|
||||
chat.refresh()
|
||||
}
|
||||
|
||||
fun refreshChatSessions(limit: Int? = null) {
|
||||
chat.refreshSessions(limit = limit)
|
||||
}
|
||||
|
||||
fun setChatThinkingLevel(level: String) {
|
||||
chat.setThinkingLevel(level)
|
||||
}
|
||||
|
||||
fun switchChatSession(sessionKey: String) {
|
||||
chat.switchSession(sessionKey)
|
||||
}
|
||||
|
||||
fun abortChat() {
|
||||
chat.abort()
|
||||
}
|
||||
|
||||
fun sendChat(message: String, thinking: String, attachments: List<OutgoingAttachment>) {
|
||||
chat.sendMessage(message = message, thinkingLevel = thinking, attachments = attachments)
|
||||
}
|
||||
|
||||
private fun handleGatewayEvent(event: String, payloadJson: String?) {
|
||||
micCapture.handleGatewayEvent(event, payloadJson)
|
||||
talkMode.handleGatewayEvent(event, payloadJson)
|
||||
chat.handleGatewayEvent(event, payloadJson)
|
||||
}
|
||||
|
||||
private fun parseChatSendRunId(response: String): String? {
|
||||
return try {
|
||||
val root = json.parseToJsonElement(response).asObjectOrNull() ?: return null
|
||||
root["runId"].asStringOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshBrandingFromGateway() {
|
||||
if (!_isConnected.value) return
|
||||
try {
|
||||
val res = operatorSession.request("config.get", "{}")
|
||||
val root = json.parseToJsonElement(res).asObjectOrNull()
|
||||
val config = root?.get("config").asObjectOrNull()
|
||||
val ui = config?.get("ui").asObjectOrNull()
|
||||
val raw = ui?.get("seamColor").asStringOrNull()?.trim()
|
||||
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
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private fun triggerCameraFlash() {
|
||||
// Token is used as a pulse trigger; value doesn't matter as long as it changes.
|
||||
_cameraFlashToken.value = SystemClock.elapsedRealtimeNanos()
|
||||
}
|
||||
|
||||
private fun showCameraHud(message: String, kind: CameraHudKind, autoHideMs: Long? = null) {
|
||||
val token = cameraHudSeq.incrementAndGet()
|
||||
_cameraHud.value = CameraHudState(token = token, kind = kind, message = message)
|
||||
|
||||
if (autoHideMs != null && autoHideMs > 0) {
|
||||
scope.launch {
|
||||
delay(autoHideMs)
|
||||
if (_cameraHud.value?.token == token) _cameraHud.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,351 +0,0 @@
|
||||
@file:Suppress("DEPRECATION")
|
||||
|
||||
package ai.openclaw.app
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import java.util.UUID
|
||||
|
||||
class SecurePrefs(
|
||||
context: Context,
|
||||
private val securePrefsOverride: SharedPreferences? = null,
|
||||
) {
|
||||
companion object {
|
||||
val defaultWakeWords: List<String> = listOf("openclaw", "claude")
|
||||
private const val displayNameKey = "node.displayName"
|
||||
private const val locationModeKey = "location.enabledMode"
|
||||
private const val voiceWakeModeKey = "voiceWake.mode"
|
||||
private const val plainPrefsName = "openclaw.node"
|
||||
private const val securePrefsName = "openclaw.node.secure"
|
||||
}
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val plainPrefs: SharedPreferences =
|
||||
appContext.getSharedPreferences(plainPrefsName, Context.MODE_PRIVATE)
|
||||
|
||||
private val masterKey by lazy {
|
||||
MasterKey.Builder(appContext)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
}
|
||||
private val securePrefs: SharedPreferences by lazy { securePrefsOverride ?: createSecurePrefs(appContext, securePrefsName) }
|
||||
|
||||
private val _instanceId = MutableStateFlow(loadOrCreateInstanceId())
|
||||
val instanceId: StateFlow<String> = _instanceId
|
||||
|
||||
private val _displayName =
|
||||
MutableStateFlow(loadOrMigrateDisplayName(context = context))
|
||||
val displayName: StateFlow<String> = _displayName
|
||||
|
||||
private val _cameraEnabled = MutableStateFlow(plainPrefs.getBoolean("camera.enabled", true))
|
||||
val cameraEnabled: StateFlow<Boolean> = _cameraEnabled
|
||||
|
||||
private val _locationMode = MutableStateFlow(loadLocationMode())
|
||||
val locationMode: StateFlow<LocationMode> = _locationMode
|
||||
|
||||
private val _locationPreciseEnabled =
|
||||
MutableStateFlow(plainPrefs.getBoolean("location.preciseEnabled", true))
|
||||
val locationPreciseEnabled: StateFlow<Boolean> = _locationPreciseEnabled
|
||||
|
||||
private val _preventSleep = MutableStateFlow(plainPrefs.getBoolean("screen.preventSleep", true))
|
||||
val preventSleep: StateFlow<Boolean> = _preventSleep
|
||||
|
||||
private val _manualEnabled =
|
||||
MutableStateFlow(plainPrefs.getBoolean("gateway.manual.enabled", false))
|
||||
val manualEnabled: StateFlow<Boolean> = _manualEnabled
|
||||
|
||||
private val _manualHost =
|
||||
MutableStateFlow(plainPrefs.getString("gateway.manual.host", "") ?: "")
|
||||
val manualHost: StateFlow<String> = _manualHost
|
||||
|
||||
private val _manualPort =
|
||||
MutableStateFlow(plainPrefs.getInt("gateway.manual.port", 18789))
|
||||
val manualPort: StateFlow<Int> = _manualPort
|
||||
|
||||
private val _manualTls =
|
||||
MutableStateFlow(plainPrefs.getBoolean("gateway.manual.tls", true))
|
||||
val manualTls: StateFlow<Boolean> = _manualTls
|
||||
|
||||
private val _gatewayToken = MutableStateFlow("")
|
||||
val gatewayToken: StateFlow<String> = _gatewayToken
|
||||
|
||||
private val _gatewayBootstrapToken = MutableStateFlow("")
|
||||
val gatewayBootstrapToken: StateFlow<String> = _gatewayBootstrapToken
|
||||
|
||||
private val _onboardingCompleted =
|
||||
MutableStateFlow(plainPrefs.getBoolean("onboarding.completed", false))
|
||||
val onboardingCompleted: StateFlow<Boolean> = _onboardingCompleted
|
||||
|
||||
private val _lastDiscoveredStableId =
|
||||
MutableStateFlow(
|
||||
plainPrefs.getString("gateway.lastDiscoveredStableID", "") ?: "",
|
||||
)
|
||||
val lastDiscoveredStableId: StateFlow<String> = _lastDiscoveredStableId
|
||||
|
||||
private val _canvasDebugStatusEnabled =
|
||||
MutableStateFlow(plainPrefs.getBoolean("canvas.debugStatusEnabled", false))
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = _canvasDebugStatusEnabled
|
||||
|
||||
private val _wakeWords = MutableStateFlow(loadWakeWords())
|
||||
val wakeWords: StateFlow<List<String>> = _wakeWords
|
||||
|
||||
private val _voiceWakeMode = MutableStateFlow(loadVoiceWakeMode())
|
||||
val voiceWakeMode: StateFlow<VoiceWakeMode> = _voiceWakeMode
|
||||
|
||||
private val _talkEnabled = MutableStateFlow(plainPrefs.getBoolean("talk.enabled", false))
|
||||
val talkEnabled: StateFlow<Boolean> = _talkEnabled
|
||||
|
||||
private val _speakerEnabled = MutableStateFlow(plainPrefs.getBoolean("voice.speakerEnabled", true))
|
||||
val speakerEnabled: StateFlow<Boolean> = _speakerEnabled
|
||||
|
||||
fun setLastDiscoveredStableId(value: String) {
|
||||
val trimmed = value.trim()
|
||||
plainPrefs.edit { putString("gateway.lastDiscoveredStableID", trimmed) }
|
||||
_lastDiscoveredStableId.value = trimmed
|
||||
}
|
||||
|
||||
fun setDisplayName(value: String) {
|
||||
val trimmed = value.trim()
|
||||
plainPrefs.edit { putString(displayNameKey, trimmed) }
|
||||
_displayName.value = trimmed
|
||||
}
|
||||
|
||||
fun setCameraEnabled(value: Boolean) {
|
||||
plainPrefs.edit { putBoolean("camera.enabled", value) }
|
||||
_cameraEnabled.value = value
|
||||
}
|
||||
|
||||
fun setLocationMode(mode: LocationMode) {
|
||||
plainPrefs.edit { putString(locationModeKey, mode.rawValue) }
|
||||
_locationMode.value = mode
|
||||
}
|
||||
|
||||
fun setLocationPreciseEnabled(value: Boolean) {
|
||||
plainPrefs.edit { putBoolean("location.preciseEnabled", value) }
|
||||
_locationPreciseEnabled.value = value
|
||||
}
|
||||
|
||||
fun setPreventSleep(value: Boolean) {
|
||||
plainPrefs.edit { putBoolean("screen.preventSleep", value) }
|
||||
_preventSleep.value = value
|
||||
}
|
||||
|
||||
fun setManualEnabled(value: Boolean) {
|
||||
plainPrefs.edit { putBoolean("gateway.manual.enabled", value) }
|
||||
_manualEnabled.value = value
|
||||
}
|
||||
|
||||
fun setManualHost(value: String) {
|
||||
val trimmed = value.trim()
|
||||
plainPrefs.edit { putString("gateway.manual.host", trimmed) }
|
||||
_manualHost.value = trimmed
|
||||
}
|
||||
|
||||
fun setManualPort(value: Int) {
|
||||
plainPrefs.edit { putInt("gateway.manual.port", value) }
|
||||
_manualPort.value = value
|
||||
}
|
||||
|
||||
fun setManualTls(value: Boolean) {
|
||||
plainPrefs.edit { putBoolean("gateway.manual.tls", value) }
|
||||
_manualTls.value = value
|
||||
}
|
||||
|
||||
fun setGatewayToken(value: String) {
|
||||
val trimmed = value.trim()
|
||||
securePrefs.edit { putString("gateway.manual.token", trimmed) }
|
||||
_gatewayToken.value = trimmed
|
||||
}
|
||||
|
||||
fun setGatewayPassword(value: String) {
|
||||
saveGatewayPassword(value)
|
||||
}
|
||||
|
||||
fun setGatewayBootstrapToken(value: String) {
|
||||
saveGatewayBootstrapToken(value)
|
||||
}
|
||||
|
||||
fun setOnboardingCompleted(value: Boolean) {
|
||||
plainPrefs.edit { putBoolean("onboarding.completed", value) }
|
||||
_onboardingCompleted.value = value
|
||||
}
|
||||
|
||||
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
||||
plainPrefs.edit { putBoolean("canvas.debugStatusEnabled", value) }
|
||||
_canvasDebugStatusEnabled.value = value
|
||||
}
|
||||
|
||||
fun loadGatewayToken(): String? {
|
||||
val manual =
|
||||
_gatewayToken.value.trim().ifEmpty {
|
||||
val stored = securePrefs.getString("gateway.manual.token", null)?.trim().orEmpty()
|
||||
if (stored.isNotEmpty()) _gatewayToken.value = stored
|
||||
stored
|
||||
}
|
||||
if (manual.isNotEmpty()) return manual
|
||||
val key = "gateway.token.${_instanceId.value}"
|
||||
val stored = securePrefs.getString(key, null)?.trim()
|
||||
return stored?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun saveGatewayToken(token: String) {
|
||||
val key = "gateway.token.${_instanceId.value}"
|
||||
securePrefs.edit { putString(key, token.trim()) }
|
||||
}
|
||||
|
||||
fun loadGatewayBootstrapToken(): String? {
|
||||
val key = "gateway.bootstrapToken.${_instanceId.value}"
|
||||
val stored =
|
||||
_gatewayBootstrapToken.value.trim().ifEmpty {
|
||||
val persisted = securePrefs.getString(key, null)?.trim().orEmpty()
|
||||
if (persisted.isNotEmpty()) {
|
||||
_gatewayBootstrapToken.value = persisted
|
||||
}
|
||||
persisted
|
||||
}
|
||||
return stored.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun saveGatewayBootstrapToken(token: String) {
|
||||
val key = "gateway.bootstrapToken.${_instanceId.value}"
|
||||
val trimmed = token.trim()
|
||||
securePrefs.edit { putString(key, trimmed) }
|
||||
_gatewayBootstrapToken.value = trimmed
|
||||
}
|
||||
|
||||
fun loadGatewayPassword(): String? {
|
||||
val key = "gateway.password.${_instanceId.value}"
|
||||
val stored = securePrefs.getString(key, null)?.trim()
|
||||
return stored?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun saveGatewayPassword(password: String) {
|
||||
val key = "gateway.password.${_instanceId.value}"
|
||||
securePrefs.edit { putString(key, password.trim()) }
|
||||
}
|
||||
|
||||
fun loadGatewayTlsFingerprint(stableId: String): String? {
|
||||
val key = "gateway.tls.$stableId"
|
||||
return plainPrefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun saveGatewayTlsFingerprint(stableId: String, fingerprint: String) {
|
||||
val key = "gateway.tls.$stableId"
|
||||
plainPrefs.edit { putString(key, fingerprint.trim()) }
|
||||
}
|
||||
|
||||
fun getString(key: String): String? {
|
||||
return securePrefs.getString(key, null)
|
||||
}
|
||||
|
||||
fun putString(key: String, value: String) {
|
||||
securePrefs.edit { putString(key, value) }
|
||||
}
|
||||
|
||||
fun remove(key: String) {
|
||||
securePrefs.edit { remove(key) }
|
||||
}
|
||||
|
||||
private fun createSecurePrefs(context: Context, name: String): SharedPreferences {
|
||||
return EncryptedSharedPreferences.create(
|
||||
context,
|
||||
name,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
}
|
||||
|
||||
private fun loadOrCreateInstanceId(): String {
|
||||
val existing = plainPrefs.getString("node.instanceId", null)?.trim()
|
||||
if (!existing.isNullOrBlank()) return existing
|
||||
val fresh = UUID.randomUUID().toString()
|
||||
plainPrefs.edit { putString("node.instanceId", fresh) }
|
||||
return fresh
|
||||
}
|
||||
|
||||
private fun loadOrMigrateDisplayName(context: Context): String {
|
||||
val existing = plainPrefs.getString(displayNameKey, null)?.trim().orEmpty()
|
||||
if (existing.isNotEmpty() && existing != "Android Node") return existing
|
||||
|
||||
val candidate = DeviceNames.bestDefaultNodeName(context).trim()
|
||||
val resolved = candidate.ifEmpty { "Android Node" }
|
||||
|
||||
plainPrefs.edit { putString(displayNameKey, resolved) }
|
||||
return resolved
|
||||
}
|
||||
|
||||
fun setWakeWords(words: List<String>) {
|
||||
val sanitized = WakeWords.sanitize(words, defaultWakeWords)
|
||||
val encoded =
|
||||
JsonArray(sanitized.map { JsonPrimitive(it) }).toString()
|
||||
plainPrefs.edit { putString("voiceWake.triggerWords", encoded) }
|
||||
_wakeWords.value = sanitized
|
||||
}
|
||||
|
||||
fun setVoiceWakeMode(mode: VoiceWakeMode) {
|
||||
plainPrefs.edit { putString(voiceWakeModeKey, mode.rawValue) }
|
||||
_voiceWakeMode.value = mode
|
||||
}
|
||||
|
||||
fun setTalkEnabled(value: Boolean) {
|
||||
plainPrefs.edit { putBoolean("talk.enabled", value) }
|
||||
_talkEnabled.value = value
|
||||
}
|
||||
|
||||
fun setSpeakerEnabled(value: Boolean) {
|
||||
plainPrefs.edit { putBoolean("voice.speakerEnabled", value) }
|
||||
_speakerEnabled.value = value
|
||||
}
|
||||
|
||||
private fun loadVoiceWakeMode(): VoiceWakeMode {
|
||||
val raw = plainPrefs.getString(voiceWakeModeKey, null)
|
||||
val resolved = VoiceWakeMode.fromRawValue(raw)
|
||||
|
||||
// Default ON (foreground) when unset.
|
||||
if (raw.isNullOrBlank()) {
|
||||
plainPrefs.edit { putString(voiceWakeModeKey, resolved.rawValue) }
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
private fun loadLocationMode(): LocationMode {
|
||||
val raw = plainPrefs.getString(locationModeKey, "off")
|
||||
val resolved = LocationMode.fromRawValue(raw)
|
||||
if (raw?.trim()?.lowercase() == "always") {
|
||||
plainPrefs.edit { putString(locationModeKey, resolved.rawValue) }
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
private fun loadWakeWords(): List<String> {
|
||||
val raw = plainPrefs.getString("voiceWake.triggerWords", null)?.trim()
|
||||
if (raw.isNullOrEmpty()) return defaultWakeWords
|
||||
return try {
|
||||
val element = json.parseToJsonElement(raw)
|
||||
val array = element as? JsonArray ?: return defaultWakeWords
|
||||
val decoded =
|
||||
array.mapNotNull { item ->
|
||||
when (item) {
|
||||
is JsonNull -> null
|
||||
is JsonPrimitive -> item.content.trim().takeIf { it.isNotEmpty() }
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
WakeWords.sanitize(decoded, defaultWakeWords)
|
||||
} catch (_: Throwable) {
|
||||
defaultWakeWords
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
object WakeWords {
|
||||
const val maxWords: Int = 32
|
||||
const val maxWordLength: Int = 64
|
||||
|
||||
fun parseCommaSeparated(input: String): List<String> {
|
||||
return input.split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun parseIfChanged(input: String, current: List<String>): List<String>? {
|
||||
val parsed = parseCommaSeparated(input)
|
||||
return if (parsed == current) null else parsed
|
||||
}
|
||||
|
||||
fun sanitize(words: List<String>, defaults: List<String>): List<String> {
|
||||
val cleaned =
|
||||
words.map { it.trim() }.filter { it.isNotEmpty() }.take(maxWords).map { it.take(maxWordLength) }
|
||||
return cleaned.ifEmpty { defaults }
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package ai.openclaw.app.gateway
|
||||
|
||||
internal object DeviceAuthPayload {
|
||||
fun buildV3(
|
||||
deviceId: String,
|
||||
clientId: String,
|
||||
clientMode: String,
|
||||
role: String,
|
||||
scopes: List<String>,
|
||||
signedAtMs: Long,
|
||||
token: String?,
|
||||
nonce: String,
|
||||
platform: String?,
|
||||
deviceFamily: String?,
|
||||
): String {
|
||||
val scopeString = scopes.joinToString(",")
|
||||
val authToken = token.orEmpty()
|
||||
val platformNorm = normalizeMetadataField(platform)
|
||||
val deviceFamilyNorm = normalizeMetadataField(deviceFamily)
|
||||
return listOf(
|
||||
"v3",
|
||||
deviceId,
|
||||
clientId,
|
||||
clientMode,
|
||||
role,
|
||||
scopeString,
|
||||
signedAtMs.toString(),
|
||||
authToken,
|
||||
nonce,
|
||||
platformNorm,
|
||||
deviceFamilyNorm,
|
||||
).joinToString("|")
|
||||
}
|
||||
|
||||
internal fun normalizeMetadataField(value: String?): String {
|
||||
val trimmed = value?.trim().orEmpty()
|
||||
if (trimmed.isEmpty()) {
|
||||
return ""
|
||||
}
|
||||
// Keep cross-runtime normalization deterministic (TS/Swift/Kotlin):
|
||||
// lowercase ASCII A-Z only for auth payload metadata fields.
|
||||
val out = StringBuilder(trimmed.length)
|
||||
for (ch in trimmed) {
|
||||
if (ch in 'A'..'Z') {
|
||||
out.append((ch.code + 32).toChar())
|
||||
} else {
|
||||
out.append(ch)
|
||||
}
|
||||
}
|
||||
return out.toString()
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package ai.openclaw.app.gateway
|
||||
|
||||
import ai.openclaw.app.SecurePrefs
|
||||
|
||||
interface DeviceAuthTokenStore {
|
||||
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 {
|
||||
override fun loadToken(deviceId: String, role: String): String? {
|
||||
val key = tokenKey(deviceId, role)
|
||||
return prefs.getString(key)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
override fun saveToken(deviceId: String, role: String, token: String) {
|
||||
val key = tokenKey(deviceId, role)
|
||||
prefs.putString(key, token.trim())
|
||||
}
|
||||
|
||||
override fun clearToken(deviceId: String, role: String) {
|
||||
val key = tokenKey(deviceId, role)
|
||||
prefs.remove(key)
|
||||
}
|
||||
|
||||
private fun tokenKey(deviceId: String, role: String): String {
|
||||
val normalizedDevice = deviceId.trim().lowercase()
|
||||
val normalizedRole = role.trim().lowercase()
|
||||
return "gateway.deviceToken.$normalizedDevice.$normalizedRole"
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
package ai.openclaw.app.gateway
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Base64
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@Serializable
|
||||
data class DeviceIdentity(
|
||||
val deviceId: String,
|
||||
val publicKeyRawBase64: String,
|
||||
val privateKeyPkcs8Base64: String,
|
||||
val createdAtMs: Long,
|
||||
)
|
||||
|
||||
class DeviceIdentityStore(context: Context) {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val identityFile = File(context.filesDir, "openclaw/identity/device.json")
|
||||
@Volatile private var cachedIdentity: DeviceIdentity? = null
|
||||
|
||||
@Synchronized
|
||||
fun loadOrCreate(): DeviceIdentity {
|
||||
cachedIdentity?.let { return it }
|
||||
val existing = load()
|
||||
if (existing != null) {
|
||||
val derived = deriveDeviceId(existing.publicKeyRawBase64)
|
||||
if (derived != null && derived != existing.deviceId) {
|
||||
val updated = existing.copy(deviceId = derived)
|
||||
save(updated)
|
||||
cachedIdentity = updated
|
||||
return updated
|
||||
}
|
||||
cachedIdentity = existing
|
||||
return existing
|
||||
}
|
||||
val fresh = generate()
|
||||
save(fresh)
|
||||
cachedIdentity = fresh
|
||||
return fresh
|
||||
}
|
||||
|
||||
fun signPayload(payload: String, identity: DeviceIdentity): String? {
|
||||
return try {
|
||||
// Use BC lightweight API directly — JCA provider registration is broken by R8
|
||||
val privateKeyBytes = Base64.decode(identity.privateKeyPkcs8Base64, Base64.DEFAULT)
|
||||
val pkInfo = org.bouncycastle.asn1.pkcs.PrivateKeyInfo.getInstance(privateKeyBytes)
|
||||
val parsed = pkInfo.parsePrivateKey()
|
||||
val rawPrivate = org.bouncycastle.asn1.DEROctetString.getInstance(parsed).octets
|
||||
val privateKey = org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters(rawPrivate, 0)
|
||||
val signer = org.bouncycastle.crypto.signers.Ed25519Signer()
|
||||
signer.init(true, privateKey)
|
||||
val payloadBytes = payload.toByteArray(Charsets.UTF_8)
|
||||
signer.update(payloadBytes, 0, payloadBytes.size)
|
||||
base64UrlEncode(signer.generateSignature())
|
||||
} catch (e: Throwable) {
|
||||
android.util.Log.e("DeviceAuth", "signPayload FAILED: ${e.javaClass.simpleName}: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun verifySelfSignature(payload: String, signatureBase64Url: String, identity: DeviceIdentity): Boolean {
|
||||
return try {
|
||||
val rawPublicKey = Base64.decode(identity.publicKeyRawBase64, Base64.DEFAULT)
|
||||
val pubKey = org.bouncycastle.crypto.params.Ed25519PublicKeyParameters(rawPublicKey, 0)
|
||||
val sigBytes = base64UrlDecode(signatureBase64Url)
|
||||
val verifier = org.bouncycastle.crypto.signers.Ed25519Signer()
|
||||
verifier.init(false, pubKey)
|
||||
val payloadBytes = payload.toByteArray(Charsets.UTF_8)
|
||||
verifier.update(payloadBytes, 0, payloadBytes.size)
|
||||
verifier.verifySignature(sigBytes)
|
||||
} catch (e: Throwable) {
|
||||
android.util.Log.e("DeviceAuth", "self-verify exception: ${e.message}", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun base64UrlDecode(input: String): ByteArray {
|
||||
val normalized = input.replace('-', '+').replace('_', '/')
|
||||
val padded = normalized + "=".repeat((4 - normalized.length % 4) % 4)
|
||||
return Base64.decode(padded, Base64.DEFAULT)
|
||||
}
|
||||
|
||||
fun publicKeyBase64Url(identity: DeviceIdentity): String? {
|
||||
return try {
|
||||
val raw = Base64.decode(identity.publicKeyRawBase64, Base64.DEFAULT)
|
||||
base64UrlEncode(raw)
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun load(): DeviceIdentity? {
|
||||
return readIdentity(identityFile)
|
||||
}
|
||||
|
||||
private fun readIdentity(file: File): DeviceIdentity? {
|
||||
return try {
|
||||
if (!file.exists()) return null
|
||||
val raw = file.readText(Charsets.UTF_8)
|
||||
val decoded = json.decodeFromString(DeviceIdentity.serializer(), raw)
|
||||
if (decoded.deviceId.isBlank() ||
|
||||
decoded.publicKeyRawBase64.isBlank() ||
|
||||
decoded.privateKeyPkcs8Base64.isBlank()
|
||||
) {
|
||||
null
|
||||
} else {
|
||||
decoded
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun save(identity: DeviceIdentity) {
|
||||
try {
|
||||
identityFile.parentFile?.mkdirs()
|
||||
val encoded = json.encodeToString(DeviceIdentity.serializer(), identity)
|
||||
identityFile.writeText(encoded, Charsets.UTF_8)
|
||||
} catch (_: Throwable) {
|
||||
// best-effort only
|
||||
}
|
||||
}
|
||||
|
||||
private fun generate(): DeviceIdentity {
|
||||
// Use BC lightweight API directly to avoid JCA provider issues with R8
|
||||
val kpGen = org.bouncycastle.crypto.generators.Ed25519KeyPairGenerator()
|
||||
kpGen.init(org.bouncycastle.crypto.params.Ed25519KeyGenerationParameters(java.security.SecureRandom()))
|
||||
val kp = kpGen.generateKeyPair()
|
||||
val pubKey = kp.public as org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
|
||||
val privKey = kp.private as org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
|
||||
val rawPublic = pubKey.encoded // 32 bytes
|
||||
val deviceId = sha256Hex(rawPublic)
|
||||
// Encode private key as PKCS8 for storage
|
||||
val privKeyInfo = org.bouncycastle.crypto.util.PrivateKeyInfoFactory.createPrivateKeyInfo(privKey)
|
||||
val pkcs8Bytes = privKeyInfo.encoded
|
||||
return DeviceIdentity(
|
||||
deviceId = deviceId,
|
||||
publicKeyRawBase64 = Base64.encodeToString(rawPublic, Base64.NO_WRAP),
|
||||
privateKeyPkcs8Base64 = Base64.encodeToString(pkcs8Bytes, Base64.NO_WRAP),
|
||||
createdAtMs = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun deriveDeviceId(publicKeyRawBase64: String): String? {
|
||||
return try {
|
||||
val raw = Base64.decode(publicKeyRawBase64, Base64.DEFAULT)
|
||||
sha256Hex(raw)
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun sha256Hex(data: ByteArray): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(data)
|
||||
val out = CharArray(digest.size * 2)
|
||||
var i = 0
|
||||
for (byte in digest) {
|
||||
val v = byte.toInt() and 0xff
|
||||
out[i++] = HEX[v ushr 4]
|
||||
out[i++] = HEX[v and 0x0f]
|
||||
}
|
||||
return String(out)
|
||||
}
|
||||
|
||||
private fun base64UrlEncode(data: ByteArray): String {
|
||||
return Base64.encodeToString(data, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val HEX = "0123456789abcdef".toCharArray()
|
||||
}
|
||||
}
|
||||
@@ -1,521 +0,0 @@
|
||||
package ai.openclaw.app.gateway
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.DnsResolver
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.nsd.NsdManager
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import android.os.CancellationSignal
|
||||
import android.util.Log
|
||||
import java.io.IOException
|
||||
import java.net.InetSocketAddress
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.charset.CodingErrorAction
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.Executor
|
||||
import java.util.concurrent.Executors
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import org.xbill.DNS.AAAARecord
|
||||
import org.xbill.DNS.ARecord
|
||||
import org.xbill.DNS.DClass
|
||||
import org.xbill.DNS.ExtendedResolver
|
||||
import org.xbill.DNS.Message
|
||||
import org.xbill.DNS.Name
|
||||
import org.xbill.DNS.PTRRecord
|
||||
import org.xbill.DNS.Record
|
||||
import org.xbill.DNS.Rcode
|
||||
import org.xbill.DNS.Resolver
|
||||
import org.xbill.DNS.SRVRecord
|
||||
import org.xbill.DNS.Section
|
||||
import org.xbill.DNS.SimpleResolver
|
||||
import org.xbill.DNS.TextParseException
|
||||
import org.xbill.DNS.TXTRecord
|
||||
import org.xbill.DNS.Type
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
class GatewayDiscovery(
|
||||
context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val nsd = context.getSystemService(NsdManager::class.java)
|
||||
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
|
||||
private val dns = DnsResolver.getInstance()
|
||||
private val serviceType = "_openclaw-gw._tcp."
|
||||
private val wideAreaDomain = System.getenv("OPENCLAW_WIDE_AREA_DOMAIN")
|
||||
private val logTag = "OpenClaw/GatewayDiscovery"
|
||||
|
||||
private val localById = ConcurrentHashMap<String, GatewayEndpoint>()
|
||||
private val unicastById = ConcurrentHashMap<String, GatewayEndpoint>()
|
||||
private val _gateways = MutableStateFlow<List<GatewayEndpoint>>(emptyList())
|
||||
val gateways: StateFlow<List<GatewayEndpoint>> = _gateways.asStateFlow()
|
||||
|
||||
private val _statusText = MutableStateFlow("Searching…")
|
||||
val statusText: StateFlow<String> = _statusText.asStateFlow()
|
||||
|
||||
private var unicastJob: Job? = null
|
||||
private val dnsExecutor: Executor = Executors.newCachedThreadPool()
|
||||
|
||||
@Volatile private var lastWideAreaRcode: Int? = null
|
||||
@Volatile private var lastWideAreaCount: Int = 0
|
||||
|
||||
private val discoveryListener =
|
||||
object : NsdManager.DiscoveryListener {
|
||||
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {}
|
||||
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {}
|
||||
override fun onDiscoveryStarted(serviceType: String) {}
|
||||
override fun onDiscoveryStopped(serviceType: String) {}
|
||||
|
||||
override fun onServiceFound(serviceInfo: NsdServiceInfo) {
|
||||
if (serviceInfo.serviceType != this@GatewayDiscovery.serviceType) return
|
||||
resolve(serviceInfo)
|
||||
}
|
||||
|
||||
override fun onServiceLost(serviceInfo: NsdServiceInfo) {
|
||||
val serviceName = BonjourEscapes.decode(serviceInfo.serviceName)
|
||||
val id = stableId(serviceName, "local.")
|
||||
localById.remove(id)
|
||||
publish()
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
startLocalDiscovery()
|
||||
if (!wideAreaDomain.isNullOrBlank()) {
|
||||
startUnicastDiscovery(wideAreaDomain)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startLocalDiscovery() {
|
||||
try {
|
||||
nsd.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, discoveryListener)
|
||||
} catch (_: Throwable) {
|
||||
// ignore (best-effort)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopLocalDiscovery() {
|
||||
try {
|
||||
nsd.stopServiceDiscovery(discoveryListener)
|
||||
} catch (_: Throwable) {
|
||||
// ignore (best-effort)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startUnicastDiscovery(domain: String) {
|
||||
unicastJob =
|
||||
scope.launch(Dispatchers.IO) {
|
||||
while (true) {
|
||||
try {
|
||||
refreshUnicast(domain)
|
||||
} catch (_: Throwable) {
|
||||
// ignore (best-effort)
|
||||
}
|
||||
delay(5000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolve(serviceInfo: NsdServiceInfo) {
|
||||
nsd.resolveService(
|
||||
serviceInfo,
|
||||
object : NsdManager.ResolveListener {
|
||||
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {}
|
||||
|
||||
override fun onServiceResolved(resolved: NsdServiceInfo) {
|
||||
val host = resolved.host?.hostAddress ?: return
|
||||
val port = resolved.port
|
||||
if (port <= 0) return
|
||||
|
||||
val rawServiceName = resolved.serviceName
|
||||
val serviceName = BonjourEscapes.decode(rawServiceName)
|
||||
val displayName = BonjourEscapes.decode(txt(resolved, "displayName") ?: serviceName)
|
||||
val lanHost = txt(resolved, "lanHost")
|
||||
val tailnetDns = txt(resolved, "tailnetDns")
|
||||
val gatewayPort = txtInt(resolved, "gatewayPort")
|
||||
val canvasPort = txtInt(resolved, "canvasPort")
|
||||
val tlsEnabled = txtBool(resolved, "gatewayTls")
|
||||
val tlsFingerprint = txt(resolved, "gatewayTlsSha256")
|
||||
val id = stableId(serviceName, "local.")
|
||||
localById[id] =
|
||||
GatewayEndpoint(
|
||||
stableId = id,
|
||||
name = displayName,
|
||||
host = host,
|
||||
port = port,
|
||||
lanHost = lanHost,
|
||||
tailnetDns = tailnetDns,
|
||||
gatewayPort = gatewayPort,
|
||||
canvasPort = canvasPort,
|
||||
tlsEnabled = tlsEnabled,
|
||||
tlsFingerprintSha256 = tlsFingerprint,
|
||||
)
|
||||
publish()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun publish() {
|
||||
_gateways.value =
|
||||
(localById.values + unicastById.values).sortedBy { it.name.lowercase() }
|
||||
_statusText.value = buildStatusText()
|
||||
}
|
||||
|
||||
private fun buildStatusText(): String {
|
||||
val localCount = localById.size
|
||||
val wideRcode = lastWideAreaRcode
|
||||
val wideCount = lastWideAreaCount
|
||||
|
||||
val wide =
|
||||
when (wideRcode) {
|
||||
null -> "Wide: ?"
|
||||
Rcode.NOERROR -> "Wide: $wideCount"
|
||||
Rcode.NXDOMAIN -> "Wide: NXDOMAIN"
|
||||
else -> "Wide: ${Rcode.string(wideRcode)}"
|
||||
}
|
||||
|
||||
return when {
|
||||
localCount == 0 && wideRcode == null -> "Searching for gateways…"
|
||||
localCount == 0 -> "$wide"
|
||||
else -> "Local: $localCount • $wide"
|
||||
}
|
||||
}
|
||||
|
||||
private fun stableId(serviceName: String, domain: String): String {
|
||||
return "${serviceType}|${domain}|${normalizeName(serviceName)}"
|
||||
}
|
||||
|
||||
private fun normalizeName(raw: String): String {
|
||||
return raw.trim().split(Regex("\\s+")).joinToString(" ")
|
||||
}
|
||||
|
||||
private fun txt(info: NsdServiceInfo, key: String): String? {
|
||||
val bytes = info.attributes[key] ?: return null
|
||||
return try {
|
||||
String(bytes, Charsets.UTF_8).trim().ifEmpty { null }
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun txtInt(info: NsdServiceInfo, key: String): Int? {
|
||||
return txt(info, key)?.toIntOrNull()
|
||||
}
|
||||
|
||||
private fun txtBool(info: NsdServiceInfo, key: String): Boolean {
|
||||
val raw = txt(info, key)?.trim()?.lowercase() ?: return false
|
||||
return raw == "1" || raw == "true" || raw == "yes"
|
||||
}
|
||||
|
||||
private suspend fun refreshUnicast(domain: String) {
|
||||
val ptrName = "${serviceType}${domain}"
|
||||
val ptrMsg = lookupUnicastMessage(ptrName, Type.PTR) ?: return
|
||||
val ptrRecords = records(ptrMsg, Section.ANSWER).mapNotNull { it as? PTRRecord }
|
||||
|
||||
val next = LinkedHashMap<String, GatewayEndpoint>()
|
||||
for (ptr in ptrRecords) {
|
||||
val instanceFqdn = ptr.target.toString()
|
||||
val srv =
|
||||
recordByName(ptrMsg, instanceFqdn, Type.SRV) as? SRVRecord
|
||||
?: run {
|
||||
val msg = lookupUnicastMessage(instanceFqdn, Type.SRV) ?: return@run null
|
||||
recordByName(msg, instanceFqdn, Type.SRV) as? SRVRecord
|
||||
}
|
||||
?: continue
|
||||
val port = srv.port
|
||||
if (port <= 0) continue
|
||||
|
||||
val targetFqdn = srv.target.toString()
|
||||
val host =
|
||||
resolveHostFromMessage(ptrMsg, targetFqdn)
|
||||
?: resolveHostFromMessage(lookupUnicastMessage(instanceFqdn, Type.SRV), targetFqdn)
|
||||
?: resolveHostUnicast(targetFqdn)
|
||||
?: continue
|
||||
|
||||
val txtFromPtr =
|
||||
recordsByName(ptrMsg, Section.ADDITIONAL)[keyName(instanceFqdn)]
|
||||
.orEmpty()
|
||||
.mapNotNull { it as? TXTRecord }
|
||||
val txt =
|
||||
if (txtFromPtr.isNotEmpty()) {
|
||||
txtFromPtr
|
||||
} else {
|
||||
val msg = lookupUnicastMessage(instanceFqdn, Type.TXT)
|
||||
records(msg, Section.ANSWER).mapNotNull { it as? TXTRecord }
|
||||
}
|
||||
val instanceName = BonjourEscapes.decode(decodeInstanceName(instanceFqdn, domain))
|
||||
val displayName = BonjourEscapes.decode(txtValue(txt, "displayName") ?: instanceName)
|
||||
val lanHost = txtValue(txt, "lanHost")
|
||||
val tailnetDns = txtValue(txt, "tailnetDns")
|
||||
val gatewayPort = txtIntValue(txt, "gatewayPort")
|
||||
val canvasPort = txtIntValue(txt, "canvasPort")
|
||||
val tlsEnabled = txtBoolValue(txt, "gatewayTls")
|
||||
val tlsFingerprint = txtValue(txt, "gatewayTlsSha256")
|
||||
val id = stableId(instanceName, domain)
|
||||
next[id] =
|
||||
GatewayEndpoint(
|
||||
stableId = id,
|
||||
name = displayName,
|
||||
host = host,
|
||||
port = port,
|
||||
lanHost = lanHost,
|
||||
tailnetDns = tailnetDns,
|
||||
gatewayPort = gatewayPort,
|
||||
canvasPort = canvasPort,
|
||||
tlsEnabled = tlsEnabled,
|
||||
tlsFingerprintSha256 = tlsFingerprint,
|
||||
)
|
||||
}
|
||||
|
||||
unicastById.clear()
|
||||
unicastById.putAll(next)
|
||||
lastWideAreaRcode = ptrMsg.header.rcode
|
||||
lastWideAreaCount = next.size
|
||||
publish()
|
||||
|
||||
if (next.isEmpty()) {
|
||||
Log.d(
|
||||
logTag,
|
||||
"wide-area discovery: 0 results for $ptrName (rcode=${Rcode.string(ptrMsg.header.rcode)})",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeInstanceName(instanceFqdn: String, domain: String): String {
|
||||
val suffix = "${serviceType}${domain}"
|
||||
val withoutSuffix =
|
||||
if (instanceFqdn.endsWith(suffix)) {
|
||||
instanceFqdn.removeSuffix(suffix)
|
||||
} else {
|
||||
instanceFqdn.substringBefore(serviceType)
|
||||
}
|
||||
return normalizeName(stripTrailingDot(withoutSuffix))
|
||||
}
|
||||
|
||||
private fun stripTrailingDot(raw: String): String {
|
||||
return raw.removeSuffix(".")
|
||||
}
|
||||
|
||||
private suspend fun lookupUnicastMessage(name: String, type: Int): Message? {
|
||||
val query =
|
||||
try {
|
||||
Message.newQuery(
|
||||
org.xbill.DNS.Record.newRecord(
|
||||
Name.fromString(name),
|
||||
type,
|
||||
DClass.IN,
|
||||
),
|
||||
)
|
||||
} catch (_: TextParseException) {
|
||||
return null
|
||||
}
|
||||
|
||||
val system = queryViaSystemDns(query)
|
||||
if (records(system, Section.ANSWER).any { it.type == type }) return system
|
||||
|
||||
val direct = createDirectResolver() ?: return system
|
||||
return try {
|
||||
val msg = direct.send(query)
|
||||
if (records(msg, Section.ANSWER).any { it.type == type }) msg else system
|
||||
} catch (_: Throwable) {
|
||||
system
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun queryViaSystemDns(query: Message): Message? {
|
||||
val network = preferredDnsNetwork()
|
||||
val bytes =
|
||||
try {
|
||||
rawQuery(network, query.toWire())
|
||||
} catch (_: Throwable) {
|
||||
return null
|
||||
}
|
||||
|
||||
return try {
|
||||
Message(bytes)
|
||||
} catch (_: IOException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun records(msg: Message?, section: Int): List<Record> {
|
||||
return msg?.getSectionArray(section)?.toList() ?: emptyList()
|
||||
}
|
||||
|
||||
private fun keyName(raw: String): String {
|
||||
return raw.trim().lowercase()
|
||||
}
|
||||
|
||||
private fun recordsByName(msg: Message, section: Int): Map<String, List<Record>> {
|
||||
val next = LinkedHashMap<String, MutableList<Record>>()
|
||||
for (r in records(msg, section)) {
|
||||
val name = r.name?.toString() ?: continue
|
||||
next.getOrPut(keyName(name)) { mutableListOf() }.add(r)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
private fun recordByName(msg: Message, fqdn: String, type: Int): Record? {
|
||||
val key = keyName(fqdn)
|
||||
val byNameAnswer = recordsByName(msg, Section.ANSWER)
|
||||
val fromAnswer = byNameAnswer[key].orEmpty().firstOrNull { it.type == type }
|
||||
if (fromAnswer != null) return fromAnswer
|
||||
|
||||
val byNameAdditional = recordsByName(msg, Section.ADDITIONAL)
|
||||
return byNameAdditional[key].orEmpty().firstOrNull { it.type == type }
|
||||
}
|
||||
|
||||
private fun resolveHostFromMessage(msg: Message?, hostname: String): String? {
|
||||
val m = msg ?: return null
|
||||
val key = keyName(hostname)
|
||||
val additional = recordsByName(m, Section.ADDITIONAL)[key].orEmpty()
|
||||
val a = additional.mapNotNull { it as? ARecord }.mapNotNull { it.address?.hostAddress }
|
||||
val aaaa = additional.mapNotNull { it as? AAAARecord }.mapNotNull { it.address?.hostAddress }
|
||||
return a.firstOrNull() ?: aaaa.firstOrNull()
|
||||
}
|
||||
|
||||
private fun preferredDnsNetwork(): android.net.Network? {
|
||||
val cm = connectivity ?: return null
|
||||
|
||||
// Prefer VPN (Tailscale) when present; otherwise use the active network.
|
||||
cm.allNetworks.firstOrNull { n ->
|
||||
val caps = cm.getNetworkCapabilities(n) ?: return@firstOrNull false
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
|
||||
}?.let { return it }
|
||||
|
||||
return cm.activeNetwork
|
||||
}
|
||||
|
||||
private fun createDirectResolver(): Resolver? {
|
||||
val cm = connectivity ?: return null
|
||||
|
||||
val candidateNetworks =
|
||||
buildList {
|
||||
cm.allNetworks
|
||||
.firstOrNull { n ->
|
||||
val caps = cm.getNetworkCapabilities(n) ?: return@firstOrNull false
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
|
||||
}?.let(::add)
|
||||
cm.activeNetwork?.let(::add)
|
||||
}.distinct()
|
||||
|
||||
val servers =
|
||||
candidateNetworks
|
||||
.asSequence()
|
||||
.flatMap { n ->
|
||||
cm.getLinkProperties(n)?.dnsServers?.asSequence() ?: emptySequence()
|
||||
}
|
||||
.distinctBy { it.hostAddress ?: it.toString() }
|
||||
.toList()
|
||||
if (servers.isEmpty()) return null
|
||||
|
||||
return try {
|
||||
val resolvers =
|
||||
servers.mapNotNull { addr ->
|
||||
try {
|
||||
SimpleResolver().apply {
|
||||
setAddress(InetSocketAddress(addr, 53))
|
||||
setTimeout(3)
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
if (resolvers.isEmpty()) return null
|
||||
ExtendedResolver(resolvers.toTypedArray()).apply { setTimeout(3) }
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun rawQuery(network: android.net.Network?, wireQuery: ByteArray): ByteArray =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
val signal = CancellationSignal()
|
||||
cont.invokeOnCancellation { signal.cancel() }
|
||||
|
||||
dns.rawQuery(
|
||||
network,
|
||||
wireQuery,
|
||||
DnsResolver.FLAG_EMPTY,
|
||||
dnsExecutor,
|
||||
signal,
|
||||
object : DnsResolver.Callback<ByteArray> {
|
||||
override fun onAnswer(answer: ByteArray, rcode: Int) {
|
||||
cont.resume(answer)
|
||||
}
|
||||
|
||||
override fun onError(error: DnsResolver.DnsException) {
|
||||
cont.resumeWithException(error)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun txtValue(records: List<TXTRecord>, key: String): String? {
|
||||
val prefix = "$key="
|
||||
for (r in records) {
|
||||
val strings: List<String> =
|
||||
try {
|
||||
r.strings.mapNotNull { it as? String }
|
||||
} catch (_: Throwable) {
|
||||
emptyList()
|
||||
}
|
||||
for (s in strings) {
|
||||
val trimmed = decodeDnsTxtString(s).trim()
|
||||
if (trimmed.startsWith(prefix)) {
|
||||
return trimmed.removePrefix(prefix).trim().ifEmpty { null }
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun txtIntValue(records: List<TXTRecord>, key: String): Int? {
|
||||
return txtValue(records, key)?.toIntOrNull()
|
||||
}
|
||||
|
||||
private fun txtBoolValue(records: List<TXTRecord>, key: String): Boolean {
|
||||
val raw = txtValue(records, key)?.trim()?.lowercase() ?: return false
|
||||
return raw == "1" || raw == "true" || raw == "yes"
|
||||
}
|
||||
|
||||
private fun decodeDnsTxtString(raw: String): String {
|
||||
// dnsjava treats TXT as opaque bytes and decodes as ISO-8859-1 to preserve bytes.
|
||||
// Our TXT payload is UTF-8 (written by the gateway), so re-decode when possible.
|
||||
val bytes = raw.toByteArray(Charsets.ISO_8859_1)
|
||||
val decoder =
|
||||
Charsets.UTF_8
|
||||
.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||
return try {
|
||||
decoder.decode(ByteBuffer.wrap(bytes)).toString()
|
||||
} catch (_: Throwable) {
|
||||
raw
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun resolveHostUnicast(hostname: String): String? {
|
||||
val a =
|
||||
records(lookupUnicastMessage(hostname, Type.A), Section.ANSWER)
|
||||
.mapNotNull { it as? ARecord }
|
||||
.mapNotNull { it.address?.hostAddress }
|
||||
val aaaa =
|
||||
records(lookupUnicastMessage(hostname, Type.AAAA), Section.ANSWER)
|
||||
.mapNotNull { it as? AAAARecord }
|
||||
.mapNotNull { it.address?.hostAddress }
|
||||
|
||||
return a.firstOrNull() ?: aaaa.firstOrNull()
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package ai.openclaw.app.gateway
|
||||
|
||||
data class GatewayEndpoint(
|
||||
val stableId: String,
|
||||
val name: String,
|
||||
val host: String,
|
||||
val port: Int,
|
||||
val lanHost: String? = null,
|
||||
val tailnetDns: String? = null,
|
||||
val gatewayPort: Int? = null,
|
||||
val canvasPort: Int? = null,
|
||||
val tlsEnabled: Boolean = false,
|
||||
val tlsFingerprintSha256: String? = null,
|
||||
) {
|
||||
companion object {
|
||||
fun manual(host: String, port: Int): GatewayEndpoint =
|
||||
GatewayEndpoint(
|
||||
stableId = "manual|${host.lowercase()}|$port",
|
||||
name = "$host:$port",
|
||||
host = host,
|
||||
port = port,
|
||||
tlsEnabled = false,
|
||||
tlsFingerprintSha256 = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
package ai.openclaw.app.gateway
|
||||
|
||||
const val GATEWAY_PROTOCOL_VERSION = 3
|
||||
@@ -1,965 +0,0 @@
|
||||
package ai.openclaw.app.gateway
|
||||
|
||||
import android.util.Log
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
|
||||
data class GatewayClientInfo(
|
||||
val id: String,
|
||||
val displayName: String?,
|
||||
val version: String,
|
||||
val platform: String,
|
||||
val mode: String,
|
||||
val instanceId: String?,
|
||||
val deviceFamily: String?,
|
||||
val modelIdentifier: String?,
|
||||
)
|
||||
|
||||
data class GatewayConnectOptions(
|
||||
val role: String,
|
||||
val scopes: List<String>,
|
||||
val caps: List<String>,
|
||||
val commands: List<String>,
|
||||
val permissions: Map<String, Boolean>,
|
||||
val client: GatewayClientInfo,
|
||||
val userAgent: String? = null,
|
||||
)
|
||||
|
||||
private enum class GatewayConnectAuthSource {
|
||||
DEVICE_TOKEN,
|
||||
SHARED_TOKEN,
|
||||
BOOTSTRAP_TOKEN,
|
||||
PASSWORD,
|
||||
NONE,
|
||||
}
|
||||
|
||||
data class GatewayConnectErrorDetails(
|
||||
val code: String?,
|
||||
val canRetryWithDeviceToken: Boolean,
|
||||
val recommendedNextStep: String?,
|
||||
)
|
||||
|
||||
private data class SelectedConnectAuth(
|
||||
val authToken: String?,
|
||||
val authBootstrapToken: String?,
|
||||
val authDeviceToken: String?,
|
||||
val authPassword: String?,
|
||||
val signatureToken: String?,
|
||||
val authSource: GatewayConnectAuthSource,
|
||||
val attemptedDeviceTokenRetry: Boolean,
|
||||
)
|
||||
|
||||
private class GatewayConnectFailure(val gatewayError: GatewaySession.ErrorShape) :
|
||||
IllegalStateException(gatewayError.message)
|
||||
|
||||
class GatewaySession(
|
||||
private val scope: CoroutineScope,
|
||||
private val identityStore: DeviceIdentityStore,
|
||||
private val deviceAuthStore: DeviceAuthTokenStore,
|
||||
private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit,
|
||||
private val onDisconnected: (message: String) -> Unit,
|
||||
private val onEvent: (event: String, payloadJson: String?) -> Unit,
|
||||
private val onInvoke: (suspend (InvokeRequest) -> InvokeResult)? = null,
|
||||
private val onTlsFingerprint: ((stableId: String, fingerprint: String) -> Unit)? = null,
|
||||
) {
|
||||
private companion object {
|
||||
// Keep connect timeout above observed gateway unauthorized close on lower-end devices.
|
||||
private const val CONNECT_RPC_TIMEOUT_MS = 12_000L
|
||||
}
|
||||
|
||||
data class InvokeRequest(
|
||||
val id: String,
|
||||
val nodeId: String,
|
||||
val command: String,
|
||||
val paramsJson: String?,
|
||||
val timeoutMs: Long?,
|
||||
)
|
||||
|
||||
data class InvokeResult(val ok: Boolean, val payloadJson: String?, val error: ErrorShape?) {
|
||||
companion object {
|
||||
fun ok(payloadJson: String?) = InvokeResult(ok = true, payloadJson = payloadJson, error = null)
|
||||
fun error(code: String, message: String) =
|
||||
InvokeResult(ok = false, payloadJson = null, error = ErrorShape(code = code, message = message))
|
||||
}
|
||||
}
|
||||
|
||||
data class ErrorShape(
|
||||
val code: String,
|
||||
val message: String,
|
||||
val details: GatewayConnectErrorDetails? = null,
|
||||
)
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val writeLock = Mutex()
|
||||
private val pending = ConcurrentHashMap<String, CompletableDeferred<RpcResponse>>()
|
||||
|
||||
@Volatile private var canvasHostUrl: String? = null
|
||||
@Volatile private var mainSessionKey: String? = null
|
||||
|
||||
private data class DesiredConnection(
|
||||
val endpoint: GatewayEndpoint,
|
||||
val token: String?,
|
||||
val bootstrapToken: String?,
|
||||
val password: String?,
|
||||
val options: GatewayConnectOptions,
|
||||
val tls: GatewayTlsParams?,
|
||||
)
|
||||
|
||||
private var desired: DesiredConnection? = null
|
||||
private var job: Job? = null
|
||||
@Volatile private var currentConnection: Connection? = null
|
||||
@Volatile private var pendingDeviceTokenRetry = false
|
||||
@Volatile private var deviceTokenRetryBudgetUsed = false
|
||||
@Volatile private var reconnectPausedForAuthFailure = false
|
||||
|
||||
fun connect(
|
||||
endpoint: GatewayEndpoint,
|
||||
token: String?,
|
||||
bootstrapToken: String?,
|
||||
password: String?,
|
||||
options: GatewayConnectOptions,
|
||||
tls: GatewayTlsParams? = null,
|
||||
) {
|
||||
desired = DesiredConnection(endpoint, token, bootstrapToken, password, options, tls)
|
||||
pendingDeviceTokenRetry = false
|
||||
deviceTokenRetryBudgetUsed = false
|
||||
reconnectPausedForAuthFailure = false
|
||||
if (job == null) {
|
||||
job = scope.launch(Dispatchers.IO) { runLoop() }
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
desired = null
|
||||
pendingDeviceTokenRetry = false
|
||||
deviceTokenRetryBudgetUsed = false
|
||||
reconnectPausedForAuthFailure = false
|
||||
currentConnection?.closeQuietly()
|
||||
scope.launch(Dispatchers.IO) {
|
||||
job?.cancelAndJoin()
|
||||
job = null
|
||||
canvasHostUrl = null
|
||||
mainSessionKey = null
|
||||
onDisconnected("Offline")
|
||||
}
|
||||
}
|
||||
|
||||
fun reconnect() {
|
||||
reconnectPausedForAuthFailure = false
|
||||
currentConnection?.closeQuietly()
|
||||
}
|
||||
|
||||
fun currentCanvasHostUrl(): String? = canvasHostUrl
|
||||
fun currentMainSessionKey(): String? = mainSessionKey
|
||||
|
||||
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))
|
||||
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)
|
||||
return true
|
||||
} catch (err: Throwable) {
|
||||
Log.w("OpenClawGateway", "node.event failed: ${err.message ?: err::class.java.simpleName}")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun request(method: String, paramsJson: String?, timeoutMs: Long = 15_000): String {
|
||||
val conn = currentConnection ?: throw IllegalStateException("not connected")
|
||||
val params =
|
||||
if (paramsJson.isNullOrBlank()) {
|
||||
null
|
||||
} else {
|
||||
json.parseToJsonElement(paramsJson)
|
||||
}
|
||||
val res = conn.request(method, params, timeoutMs)
|
||||
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 {
|
||||
val conn = currentConnection ?: return false
|
||||
val response =
|
||||
try {
|
||||
conn.request(
|
||||
"node.canvas.capability.refresh",
|
||||
params = buildJsonObject {},
|
||||
timeoutMs = timeoutMs,
|
||||
)
|
||||
} catch (err: Throwable) {
|
||||
Log.w("OpenClawGateway", "node.canvas.capability.refresh failed: ${err.message ?: err::class.java.simpleName}")
|
||||
return false
|
||||
}
|
||||
if (!response.ok) {
|
||||
val err = response.error
|
||||
Log.w(
|
||||
"OpenClawGateway",
|
||||
"node.canvas.capability.refresh rejected: ${err?.code ?: "UNAVAILABLE"}: ${err?.message ?: "request failed"}",
|
||||
)
|
||||
return false
|
||||
}
|
||||
val payloadObj = response.payloadJson?.let(::parseJsonOrNull)?.asObjectOrNull()
|
||||
val refreshedCapability = payloadObj?.get("canvasCapability").asStringOrNull()?.trim().orEmpty()
|
||||
if (refreshedCapability.isEmpty()) {
|
||||
Log.w("OpenClawGateway", "node.canvas.capability.refresh missing canvasCapability")
|
||||
return false
|
||||
}
|
||||
val scopedCanvasHostUrl = canvasHostUrl?.trim().orEmpty()
|
||||
if (scopedCanvasHostUrl.isEmpty()) {
|
||||
Log.w("OpenClawGateway", "node.canvas.capability.refresh missing local canvasHostUrl")
|
||||
return false
|
||||
}
|
||||
val refreshedUrl = replaceCanvasCapabilityInScopedHostUrl(scopedCanvasHostUrl, refreshedCapability)
|
||||
if (refreshedUrl == null) {
|
||||
Log.w("OpenClawGateway", "node.canvas.capability.refresh unable to rewrite scoped canvas URL")
|
||||
return false
|
||||
}
|
||||
canvasHostUrl = refreshedUrl
|
||||
return true
|
||||
}
|
||||
|
||||
private data class RpcResponse(val id: String, val ok: Boolean, val payloadJson: String?, val error: ErrorShape?)
|
||||
|
||||
private inner class Connection(
|
||||
private val endpoint: GatewayEndpoint,
|
||||
private val token: String?,
|
||||
private val bootstrapToken: String?,
|
||||
private val password: String?,
|
||||
private val options: GatewayConnectOptions,
|
||||
private val tls: GatewayTlsParams?,
|
||||
) {
|
||||
private val connectDeferred = CompletableDeferred<Unit>()
|
||||
private val closedDeferred = CompletableDeferred<Unit>()
|
||||
private val isClosed = AtomicBoolean(false)
|
||||
private val connectNonceDeferred = CompletableDeferred<String>()
|
||||
private val client: OkHttpClient = buildClient()
|
||||
private var socket: WebSocket? = null
|
||||
private val loggerTag = "OpenClawGateway"
|
||||
|
||||
val remoteAddress: String =
|
||||
if (endpoint.host.contains(":")) {
|
||||
"[${endpoint.host}]:${endpoint.port}"
|
||||
} else {
|
||||
"${endpoint.host}:${endpoint.port}"
|
||||
}
|
||||
|
||||
suspend fun connect() {
|
||||
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 {
|
||||
connectDeferred.await()
|
||||
} catch (err: Throwable) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun request(method: String, params: JsonElement?, timeoutMs: Long): RpcResponse {
|
||||
val id = UUID.randomUUID().toString()
|
||||
val deferred = CompletableDeferred<RpcResponse>()
|
||||
pending[id] = deferred
|
||||
val frame =
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("req"))
|
||||
put("id", JsonPrimitive(id))
|
||||
put("method", JsonPrimitive(method))
|
||||
if (params != null) put("params", params)
|
||||
}
|
||||
sendJson(frame)
|
||||
return try {
|
||||
withTimeout(timeoutMs) { deferred.await() }
|
||||
} catch (err: TimeoutCancellationException) {
|
||||
pending.remove(id)
|
||||
throw IllegalStateException("request timeout")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendJson(obj: JsonObject) {
|
||||
val jsonString = obj.toString()
|
||||
writeLock.withLock {
|
||||
socket?.send(jsonString)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun awaitClose() = closedDeferred.await()
|
||||
|
||||
fun closeQuietly() {
|
||||
if (isClosed.compareAndSet(false, true)) {
|
||||
socket?.close(1000, "bye")
|
||||
socket = null
|
||||
closedDeferred.complete(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildClient(): OkHttpClient {
|
||||
val builder = OkHttpClient.Builder()
|
||||
.writeTimeout(60, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.readTimeout(0, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.pingInterval(30, java.util.concurrent.TimeUnit.SECONDS)
|
||||
val tlsConfig = buildGatewayTlsConfig(tls) { fingerprint ->
|
||||
onTlsFingerprint?.invoke(tls?.stableId ?: endpoint.stableId, fingerprint)
|
||||
}
|
||||
if (tlsConfig != null) {
|
||||
builder.sslSocketFactory(tlsConfig.sslSocketFactory, tlsConfig.trustManager)
|
||||
builder.hostnameVerifier(tlsConfig.hostnameVerifier)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private inner class Listener : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
scope.launch {
|
||||
try {
|
||||
val nonce = awaitConnectNonce()
|
||||
sendConnect(nonce)
|
||||
} catch (err: Throwable) {
|
||||
connectDeferred.completeExceptionally(err)
|
||||
closeQuietly()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
scope.launch { handleMessage(text) }
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
if (!connectDeferred.isCompleted) {
|
||||
connectDeferred.completeExceptionally(t)
|
||||
}
|
||||
if (isClosed.compareAndSet(false, true)) {
|
||||
failPending()
|
||||
closedDeferred.complete(Unit)
|
||||
onDisconnected("Gateway error: ${t.message ?: t::class.java.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
if (!connectDeferred.isCompleted) {
|
||||
connectDeferred.completeExceptionally(IllegalStateException("Gateway closed: $reason"))
|
||||
}
|
||||
if (isClosed.compareAndSet(false, true)) {
|
||||
failPending()
|
||||
closedDeferred.complete(Unit)
|
||||
onDisconnected("Gateway closed: $reason")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendConnect(connectNonce: String) {
|
||||
val identity = identityStore.loadOrCreate()
|
||||
val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role)?.trim()
|
||||
val selectedAuth =
|
||||
selectConnectAuth(
|
||||
endpoint = endpoint,
|
||||
tls = tls,
|
||||
role = options.role,
|
||||
explicitGatewayToken = token?.trim()?.takeIf { it.isNotEmpty() },
|
||||
explicitBootstrapToken = bootstrapToken?.trim()?.takeIf { it.isNotEmpty() },
|
||||
explicitPassword = password?.trim()?.takeIf { it.isNotEmpty() },
|
||||
storedToken = storedToken?.takeIf { it.isNotEmpty() },
|
||||
)
|
||||
if (selectedAuth.attemptedDeviceTokenRetry) {
|
||||
pendingDeviceTokenRetry = false
|
||||
}
|
||||
val payload =
|
||||
buildConnectParams(
|
||||
identity = identity,
|
||||
connectNonce = connectNonce,
|
||||
selectedAuth = selectedAuth,
|
||||
)
|
||||
val res = request("connect", payload, timeoutMs = CONNECT_RPC_TIMEOUT_MS)
|
||||
if (!res.ok) {
|
||||
val error = res.error ?: ErrorShape("UNAVAILABLE", "connect failed")
|
||||
val shouldRetryWithDeviceToken =
|
||||
shouldRetryWithStoredDeviceToken(
|
||||
error = error,
|
||||
explicitGatewayToken = token?.trim()?.takeIf { it.isNotEmpty() },
|
||||
storedToken = storedToken?.takeIf { it.isNotEmpty() },
|
||||
attemptedDeviceTokenRetry = selectedAuth.attemptedDeviceTokenRetry,
|
||||
endpoint = endpoint,
|
||||
tls = tls,
|
||||
)
|
||||
if (shouldRetryWithDeviceToken) {
|
||||
pendingDeviceTokenRetry = true
|
||||
deviceTokenRetryBudgetUsed = true
|
||||
} else if (
|
||||
selectedAuth.attemptedDeviceTokenRetry &&
|
||||
shouldClearStoredDeviceTokenAfterRetry(error)
|
||||
) {
|
||||
deviceAuthStore.clearToken(identity.deviceId, options.role)
|
||||
}
|
||||
throw GatewayConnectFailure(error)
|
||||
}
|
||||
handleConnectSuccess(res, identity.deviceId)
|
||||
connectDeferred.complete(Unit)
|
||||
}
|
||||
|
||||
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
|
||||
deviceTokenRetryBudgetUsed = false
|
||||
reconnectPausedForAuthFailure = false
|
||||
val serverName = obj["server"].asObjectOrNull()?.get("host").asStringOrNull()
|
||||
val authObj = obj["auth"].asObjectOrNull()
|
||||
val deviceToken = authObj?.get("deviceToken").asStringOrNull()
|
||||
val authRole = authObj?.get("role").asStringOrNull() ?: options.role
|
||||
if (!deviceToken.isNullOrBlank()) {
|
||||
deviceAuthStore.saveToken(deviceId, authRole, deviceToken)
|
||||
}
|
||||
val rawCanvas = obj["canvasHostUrl"].asStringOrNull()
|
||||
canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint, isTlsConnection = tls != null)
|
||||
val sessionDefaults =
|
||||
obj["snapshot"].asObjectOrNull()
|
||||
?.get("sessionDefaults").asObjectOrNull()
|
||||
mainSessionKey = sessionDefaults?.get("mainSessionKey").asStringOrNull()
|
||||
onConnected(serverName, remoteAddress, mainSessionKey)
|
||||
}
|
||||
|
||||
private fun buildConnectParams(
|
||||
identity: DeviceIdentity,
|
||||
connectNonce: String,
|
||||
selectedAuth: SelectedConnectAuth,
|
||||
): JsonObject {
|
||||
val client = options.client
|
||||
val locale = Locale.getDefault().toLanguageTag()
|
||||
val clientObj =
|
||||
buildJsonObject {
|
||||
put("id", JsonPrimitive(client.id))
|
||||
client.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
||||
put("version", JsonPrimitive(client.version))
|
||||
put("platform", JsonPrimitive(client.platform))
|
||||
put("mode", JsonPrimitive(client.mode))
|
||||
client.instanceId?.let { put("instanceId", JsonPrimitive(it)) }
|
||||
client.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
||||
client.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||
}
|
||||
|
||||
val authJson =
|
||||
when {
|
||||
selectedAuth.authToken != null ->
|
||||
buildJsonObject {
|
||||
put("token", JsonPrimitive(selectedAuth.authToken))
|
||||
selectedAuth.authDeviceToken?.let { put("deviceToken", JsonPrimitive(it)) }
|
||||
}
|
||||
selectedAuth.authBootstrapToken != null ->
|
||||
buildJsonObject {
|
||||
put("bootstrapToken", JsonPrimitive(selectedAuth.authBootstrapToken))
|
||||
}
|
||||
selectedAuth.authPassword != null ->
|
||||
buildJsonObject {
|
||||
put("password", JsonPrimitive(selectedAuth.authPassword))
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
val signedAtMs = System.currentTimeMillis()
|
||||
val payload =
|
||||
DeviceAuthPayload.buildV3(
|
||||
deviceId = identity.deviceId,
|
||||
clientId = client.id,
|
||||
clientMode = client.mode,
|
||||
role = options.role,
|
||||
scopes = options.scopes,
|
||||
signedAtMs = signedAtMs,
|
||||
token = selectedAuth.signatureToken,
|
||||
nonce = connectNonce,
|
||||
platform = client.platform,
|
||||
deviceFamily = client.deviceFamily,
|
||||
)
|
||||
val signature = identityStore.signPayload(payload, identity)
|
||||
val publicKey = identityStore.publicKeyBase64Url(identity)
|
||||
val deviceJson =
|
||||
if (!signature.isNullOrBlank() && !publicKey.isNullOrBlank()) {
|
||||
buildJsonObject {
|
||||
put("id", JsonPrimitive(identity.deviceId))
|
||||
put("publicKey", JsonPrimitive(publicKey))
|
||||
put("signature", JsonPrimitive(signature))
|
||||
put("signedAt", JsonPrimitive(signedAtMs))
|
||||
put("nonce", JsonPrimitive(connectNonce))
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
return buildJsonObject {
|
||||
put("minProtocol", JsonPrimitive(GATEWAY_PROTOCOL_VERSION))
|
||||
put("maxProtocol", JsonPrimitive(GATEWAY_PROTOCOL_VERSION))
|
||||
put("client", clientObj)
|
||||
if (options.caps.isNotEmpty()) put("caps", JsonArray(options.caps.map(::JsonPrimitive)))
|
||||
if (options.commands.isNotEmpty()) put("commands", JsonArray(options.commands.map(::JsonPrimitive)))
|
||||
if (options.permissions.isNotEmpty()) {
|
||||
put(
|
||||
"permissions",
|
||||
buildJsonObject {
|
||||
options.permissions.forEach { (key, value) ->
|
||||
put(key, JsonPrimitive(value))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
put("role", JsonPrimitive(options.role))
|
||||
if (options.scopes.isNotEmpty()) put("scopes", JsonArray(options.scopes.map(::JsonPrimitive)))
|
||||
authJson?.let { put("auth", it) }
|
||||
deviceJson?.let { put("device", it) }
|
||||
put("locale", JsonPrimitive(locale))
|
||||
options.userAgent?.trim()?.takeIf { it.isNotEmpty() }?.let {
|
||||
put("userAgent", JsonPrimitive(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleMessage(text: String) {
|
||||
val frame = json.parseToJsonElement(text).asObjectOrNull() ?: return
|
||||
when (frame["type"].asStringOrNull()) {
|
||||
"res" -> handleResponse(frame)
|
||||
"event" -> handleEvent(frame)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleResponse(frame: JsonObject) {
|
||||
val id = frame["id"].asStringOrNull() ?: return
|
||||
val ok = frame["ok"].asBooleanOrNull() ?: false
|
||||
val payloadJson = frame["payload"]?.let { payload -> payload.toString() }
|
||||
val error =
|
||||
frame["error"]?.asObjectOrNull()?.let { obj ->
|
||||
val code = obj["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||
val msg = obj["message"].asStringOrNull() ?: "request failed"
|
||||
val detailObj = obj["details"].asObjectOrNull()
|
||||
val details =
|
||||
detailObj?.let {
|
||||
GatewayConnectErrorDetails(
|
||||
code = it["code"].asStringOrNull(),
|
||||
canRetryWithDeviceToken = it["canRetryWithDeviceToken"].asBooleanOrNull() == true,
|
||||
recommendedNextStep = it["recommendedNextStep"].asStringOrNull(),
|
||||
)
|
||||
}
|
||||
ErrorShape(code, msg, details)
|
||||
}
|
||||
pending.remove(id)?.complete(RpcResponse(id, ok, payloadJson, error))
|
||||
}
|
||||
|
||||
private fun handleEvent(frame: JsonObject) {
|
||||
val event = frame["event"].asStringOrNull() ?: return
|
||||
val payloadJson =
|
||||
frame["payload"]?.let { it.toString() } ?: frame["payloadJSON"].asStringOrNull()
|
||||
if (event == "connect.challenge") {
|
||||
val nonce = extractConnectNonce(payloadJson)
|
||||
if (!connectNonceDeferred.isCompleted && !nonce.isNullOrBlank()) {
|
||||
connectNonceDeferred.complete(nonce.trim())
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event == "node.invoke.request" && payloadJson != null && onInvoke != null) {
|
||||
handleInvokeEvent(payloadJson)
|
||||
return
|
||||
}
|
||||
onEvent(event, payloadJson)
|
||||
}
|
||||
|
||||
private suspend fun awaitConnectNonce(): String {
|
||||
return try {
|
||||
withTimeout(2_000) { connectNonceDeferred.await() }
|
||||
} catch (err: Throwable) {
|
||||
throw IllegalStateException("connect challenge timeout", err)
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractConnectNonce(payloadJson: String?): String? {
|
||||
if (payloadJson.isNullOrBlank()) return null
|
||||
val obj = parseJsonOrNull(payloadJson)?.asObjectOrNull() ?: return null
|
||||
return obj["nonce"].asStringOrNull()
|
||||
}
|
||||
|
||||
private fun handleInvokeEvent(payloadJson: String) {
|
||||
val payload =
|
||||
try {
|
||||
json.parseToJsonElement(payloadJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} ?: return
|
||||
val id = payload["id"].asStringOrNull() ?: return
|
||||
val nodeId = payload["nodeId"].asStringOrNull() ?: return
|
||||
val command = payload["command"].asStringOrNull() ?: return
|
||||
val params =
|
||||
payload["paramsJSON"].asStringOrNull()
|
||||
?: payload["params"]?.let { value -> if (value is JsonNull) null else value.toString() }
|
||||
val timeoutMs = payload["timeoutMs"].asLongOrNull()
|
||||
scope.launch {
|
||||
val result =
|
||||
try {
|
||||
onInvoke?.invoke(InvokeRequest(id, nodeId, command, params, timeoutMs))
|
||||
?: InvokeResult.error("UNAVAILABLE", "invoke handler missing")
|
||||
} catch (err: Throwable) {
|
||||
invokeErrorFromThrowable(err)
|
||||
}
|
||||
sendInvokeResult(id, nodeId, result, timeoutMs)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendInvokeResult(
|
||||
id: String,
|
||||
nodeId: String,
|
||||
result: InvokeResult,
|
||||
invokeTimeoutMs: Long?,
|
||||
) {
|
||||
val parsedPayload = result.payloadJson?.let { parseJsonOrNull(it) }
|
||||
val params =
|
||||
buildJsonObject {
|
||||
put("id", JsonPrimitive(id))
|
||||
put("nodeId", JsonPrimitive(nodeId))
|
||||
put("ok", JsonPrimitive(result.ok))
|
||||
if (parsedPayload != null) {
|
||||
put("payload", parsedPayload)
|
||||
} else if (result.payloadJson != null) {
|
||||
put("payloadJSON", JsonPrimitive(result.payloadJson))
|
||||
}
|
||||
result.error?.let { err ->
|
||||
put(
|
||||
"error",
|
||||
buildJsonObject {
|
||||
put("code", JsonPrimitive(err.code))
|
||||
put("message", JsonPrimitive(err.message))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
val ackTimeoutMs = resolveInvokeResultAckTimeoutMs(invokeTimeoutMs)
|
||||
try {
|
||||
request("node.invoke.result", params, timeoutMs = ackTimeoutMs)
|
||||
} catch (err: Throwable) {
|
||||
Log.w(
|
||||
loggerTag,
|
||||
"node.invoke.result failed (ackTimeoutMs=$ackTimeoutMs): ${err.message ?: err::class.java.simpleName}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun invokeErrorFromThrowable(err: Throwable): InvokeResult {
|
||||
val parsed = parseInvokeErrorFromThrowable(err, fallbackMessage = err::class.java.simpleName)
|
||||
return InvokeResult.error(code = parsed.code, message = parsed.message)
|
||||
}
|
||||
|
||||
private fun failPending() {
|
||||
for ((_, waiter) in pending) {
|
||||
waiter.cancel()
|
||||
}
|
||||
pending.clear()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runLoop() {
|
||||
var attempt = 0
|
||||
while (scope.isActive) {
|
||||
val target = desired
|
||||
if (target == null) {
|
||||
currentConnection?.closeQuietly()
|
||||
currentConnection = null
|
||||
delay(250)
|
||||
continue
|
||||
}
|
||||
if (reconnectPausedForAuthFailure) {
|
||||
delay(250)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…")
|
||||
connectOnce(target)
|
||||
attempt = 0
|
||||
} catch (err: Throwable) {
|
||||
attempt += 1
|
||||
onDisconnected("Gateway error: ${err.message ?: err::class.java.simpleName}")
|
||||
if (
|
||||
err is GatewayConnectFailure &&
|
||||
shouldPauseReconnectAfterAuthFailure(err.gatewayError)
|
||||
) {
|
||||
reconnectPausedForAuthFailure = true
|
||||
continue
|
||||
}
|
||||
val sleepMs = minOf(8_000L, (350.0 * Math.pow(1.7, attempt.toDouble())).toLong())
|
||||
delay(sleepMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun connectOnce(target: DesiredConnection) = withContext(Dispatchers.IO) {
|
||||
val conn =
|
||||
Connection(
|
||||
target.endpoint,
|
||||
target.token,
|
||||
target.bootstrapToken,
|
||||
target.password,
|
||||
target.options,
|
||||
target.tls,
|
||||
)
|
||||
currentConnection = conn
|
||||
try {
|
||||
conn.connect()
|
||||
conn.awaitClose()
|
||||
} finally {
|
||||
currentConnection = null
|
||||
canvasHostUrl = null
|
||||
mainSessionKey = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeCanvasHostUrl(
|
||||
raw: String?,
|
||||
endpoint: GatewayEndpoint,
|
||||
isTlsConnection: Boolean,
|
||||
): String? {
|
||||
val trimmed = raw?.trim().orEmpty()
|
||||
val parsed = trimmed.takeIf { it.isNotBlank() }?.let { runCatching { java.net.URI(it) }.getOrNull() }
|
||||
val host = parsed?.host?.trim().orEmpty()
|
||||
val port = parsed?.port ?: -1
|
||||
val scheme = parsed?.scheme?.trim().orEmpty().ifBlank { "http" }
|
||||
val suffix = buildUrlSuffix(parsed)
|
||||
|
||||
// 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() && !isLoopbackHost(host)) {
|
||||
val needsTlsRewrite =
|
||||
isTlsConnection &&
|
||||
(
|
||||
!scheme.equals("https", ignoreCase = true) ||
|
||||
(port > 0 && port != endpoint.port) ||
|
||||
(port <= 0 && endpoint.port != 443)
|
||||
)
|
||||
if (needsTlsRewrite) {
|
||||
return buildCanvasUrl(host = host, scheme = "https", port = endpoint.port, suffix = suffix)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
val fallbackHost =
|
||||
endpoint.tailnetDns?.trim().takeIf { !it.isNullOrEmpty() }
|
||||
?: endpoint.lanHost?.trim().takeIf { !it.isNullOrEmpty() }
|
||||
?: endpoint.host.trim()
|
||||
if (fallbackHost.isEmpty()) return trimmed.ifBlank { null }
|
||||
|
||||
// For TLS connections, use the connected endpoint's scheme/port instead of raw canvas metadata.
|
||||
val fallbackScheme = if (isTlsConnection) "https" else scheme
|
||||
// For TLS, always use the connected endpoint port.
|
||||
val fallbackPort = if (isTlsConnection) endpoint.port else (endpoint.canvasPort ?: endpoint.port)
|
||||
return buildCanvasUrl(host = fallbackHost, scheme = fallbackScheme, port = fallbackPort, suffix = suffix)
|
||||
}
|
||||
|
||||
private fun buildCanvasUrl(host: String, scheme: String, port: Int, suffix: String): String {
|
||||
val loweredScheme = scheme.lowercase()
|
||||
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"
|
||||
}
|
||||
|
||||
private fun buildUrlSuffix(uri: java.net.URI?): String {
|
||||
if (uri == null) return ""
|
||||
val path = uri.rawPath?.takeIf { it.isNotBlank() } ?: ""
|
||||
val query = uri.rawQuery?.takeIf { it.isNotBlank() }?.let { "?$it" } ?: ""
|
||||
val fragment = uri.rawFragment?.takeIf { it.isNotBlank() }?.let { "#$it" } ?: ""
|
||||
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?,
|
||||
role: String,
|
||||
explicitGatewayToken: String?,
|
||||
explicitBootstrapToken: String?,
|
||||
explicitPassword: String?,
|
||||
storedToken: String?,
|
||||
): SelectedConnectAuth {
|
||||
val shouldUseDeviceRetryToken =
|
||||
pendingDeviceTokenRetry &&
|
||||
explicitGatewayToken != null &&
|
||||
storedToken != null &&
|
||||
isTrustedDeviceRetryEndpoint(endpoint, tls)
|
||||
val authToken =
|
||||
explicitGatewayToken
|
||||
?: if (
|
||||
explicitPassword == null &&
|
||||
(explicitBootstrapToken == null || storedToken != null)
|
||||
) {
|
||||
storedToken
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val authDeviceToken = if (shouldUseDeviceRetryToken) storedToken else null
|
||||
val authBootstrapToken = if (authToken == null) explicitBootstrapToken else null
|
||||
val authSource =
|
||||
when {
|
||||
authDeviceToken != null || (explicitGatewayToken == null && authToken != null) ->
|
||||
GatewayConnectAuthSource.DEVICE_TOKEN
|
||||
authToken != null -> GatewayConnectAuthSource.SHARED_TOKEN
|
||||
authBootstrapToken != null -> GatewayConnectAuthSource.BOOTSTRAP_TOKEN
|
||||
explicitPassword != null -> GatewayConnectAuthSource.PASSWORD
|
||||
else -> GatewayConnectAuthSource.NONE
|
||||
}
|
||||
return SelectedConnectAuth(
|
||||
authToken = authToken,
|
||||
authBootstrapToken = authBootstrapToken,
|
||||
authDeviceToken = authDeviceToken,
|
||||
authPassword = explicitPassword,
|
||||
signatureToken = authToken ?: authBootstrapToken,
|
||||
authSource = authSource,
|
||||
attemptedDeviceTokenRetry = shouldUseDeviceRetryToken,
|
||||
)
|
||||
}
|
||||
|
||||
private fun shouldRetryWithStoredDeviceToken(
|
||||
error: ErrorShape,
|
||||
explicitGatewayToken: String?,
|
||||
storedToken: String?,
|
||||
attemptedDeviceTokenRetry: Boolean,
|
||||
endpoint: GatewayEndpoint,
|
||||
tls: GatewayTlsParams?,
|
||||
): Boolean {
|
||||
if (deviceTokenRetryBudgetUsed) return false
|
||||
if (attemptedDeviceTokenRetry) return false
|
||||
if (explicitGatewayToken == null || storedToken == null) return false
|
||||
if (!isTrustedDeviceRetryEndpoint(endpoint, tls)) return false
|
||||
val detailCode = error.details?.code
|
||||
val recommendedNextStep = error.details?.recommendedNextStep
|
||||
return error.details?.canRetryWithDeviceToken == true ||
|
||||
recommendedNextStep == "retry_with_device_token" ||
|
||||
detailCode == "AUTH_TOKEN_MISMATCH"
|
||||
}
|
||||
|
||||
private fun shouldPauseReconnectAfterAuthFailure(error: ErrorShape): Boolean {
|
||||
return when (error.details?.code) {
|
||||
"AUTH_TOKEN_MISSING",
|
||||
"AUTH_BOOTSTRAP_TOKEN_INVALID",
|
||||
"AUTH_PASSWORD_MISSING",
|
||||
"AUTH_PASSWORD_MISMATCH",
|
||||
"AUTH_RATE_LIMITED",
|
||||
"PAIRING_REQUIRED",
|
||||
"CONTROL_UI_DEVICE_IDENTITY_REQUIRED",
|
||||
"DEVICE_IDENTITY_REQUIRED" -> true
|
||||
"AUTH_TOKEN_MISMATCH" -> deviceTokenRetryBudgetUsed && !pendingDeviceTokenRetry
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldClearStoredDeviceTokenAfterRetry(error: ErrorShape): Boolean {
|
||||
return error.details?.code == "AUTH_DEVICE_TOKEN_MISMATCH"
|
||||
}
|
||||
|
||||
private fun isTrustedDeviceRetryEndpoint(
|
||||
endpoint: GatewayEndpoint,
|
||||
tls: GatewayTlsParams?,
|
||||
): Boolean {
|
||||
if (isLoopbackHost(endpoint.host)) {
|
||||
return true
|
||||
}
|
||||
return tls?.expectedFingerprint?.trim()?.isNotEmpty() == true
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
|
||||
private fun JsonElement?.asStringOrNull(): String? =
|
||||
when (this) {
|
||||
is JsonNull -> null
|
||||
is JsonPrimitive -> content
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun JsonElement?.asBooleanOrNull(): Boolean? =
|
||||
when (this) {
|
||||
is JsonPrimitive -> {
|
||||
val c = content.trim()
|
||||
when {
|
||||
c.equals("true", ignoreCase = true) -> true
|
||||
c.equals("false", ignoreCase = true) -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun JsonElement?.asLongOrNull(): Long? =
|
||||
when (this) {
|
||||
is JsonPrimitive -> content.toLongOrNull()
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun parseJsonOrNull(payload: String): JsonElement? {
|
||||
val trimmed = payload.trim()
|
||||
if (trimmed.isEmpty()) return null
|
||||
return try {
|
||||
Json.parseToJsonElement(trimmed)
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
internal fun replaceCanvasCapabilityInScopedHostUrl(
|
||||
scopedUrl: String,
|
||||
capability: String,
|
||||
): String? {
|
||||
val marker = "/__openclaw__/cap/"
|
||||
val markerStart = scopedUrl.indexOf(marker)
|
||||
if (markerStart < 0) return null
|
||||
val capabilityStart = markerStart + marker.length
|
||||
val slashEnd = scopedUrl.indexOf("/", capabilityStart).takeIf { it >= 0 }
|
||||
val queryEnd = scopedUrl.indexOf("?", capabilityStart).takeIf { it >= 0 }
|
||||
val fragmentEnd = scopedUrl.indexOf("#", capabilityStart).takeIf { it >= 0 }
|
||||
val capabilityEnd = listOfNotNull(slashEnd, queryEnd, fragmentEnd).minOrNull() ?: scopedUrl.length
|
||||
if (capabilityEnd <= capabilityStart) return null
|
||||
return scopedUrl.substring(0, capabilityStart) + capability + scopedUrl.substring(capabilityEnd)
|
||||
}
|
||||
|
||||
internal fun resolveInvokeResultAckTimeoutMs(invokeTimeoutMs: Long?): Long {
|
||||
val normalized = invokeTimeoutMs?.takeIf { it > 0L } ?: 15_000L
|
||||
return normalized.coerceIn(15_000L, 120_000L)
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
package ai.openclaw.app.gateway
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.InetSocketAddress
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.security.cert.CertificateException
|
||||
import java.security.cert.X509Certificate
|
||||
import java.util.Locale
|
||||
import javax.net.ssl.HttpsURLConnection
|
||||
import javax.net.ssl.HostnameVerifier
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLParameters
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
import javax.net.ssl.SNIHostName
|
||||
import javax.net.ssl.SSLSocket
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
data class GatewayTlsParams(
|
||||
val required: Boolean,
|
||||
val expectedFingerprint: String?,
|
||||
val allowTOFU: Boolean,
|
||||
val stableId: String,
|
||||
)
|
||||
|
||||
data class GatewayTlsConfig(
|
||||
val sslSocketFactory: SSLSocketFactory,
|
||||
val trustManager: X509TrustManager,
|
||||
val hostnameVerifier: HostnameVerifier,
|
||||
)
|
||||
|
||||
fun buildGatewayTlsConfig(
|
||||
params: GatewayTlsParams?,
|
||||
onStore: ((String) -> Unit)? = null,
|
||||
): GatewayTlsConfig? {
|
||||
if (params == null) return null
|
||||
val expected = params.expectedFingerprint?.let(::normalizeFingerprint)
|
||||
val defaultTrust = defaultTrustManager()
|
||||
@SuppressLint("CustomX509TrustManager")
|
||||
val trustManager =
|
||||
object : X509TrustManager {
|
||||
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {
|
||||
defaultTrust.checkClientTrusted(chain, authType)
|
||||
}
|
||||
|
||||
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
|
||||
if (chain.isEmpty()) throw CertificateException("empty certificate chain")
|
||||
val fingerprint = sha256Hex(chain[0].encoded)
|
||||
if (expected != null) {
|
||||
if (fingerprint != expected) {
|
||||
throw CertificateException("gateway TLS fingerprint mismatch")
|
||||
}
|
||||
return
|
||||
}
|
||||
if (params.allowTOFU) {
|
||||
onStore?.invoke(fingerprint)
|
||||
return
|
||||
}
|
||||
defaultTrust.checkServerTrusted(chain, authType)
|
||||
}
|
||||
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> = defaultTrust.acceptedIssuers
|
||||
}
|
||||
|
||||
val context = SSLContext.getInstance("TLS")
|
||||
context.init(null, arrayOf(trustManager), SecureRandom())
|
||||
val verifier =
|
||||
if (expected != null || params.allowTOFU) {
|
||||
// When pinning, we intentionally ignore hostname mismatch (service discovery often yields IPs).
|
||||
HostnameVerifier { _, _ -> true }
|
||||
} else {
|
||||
HttpsURLConnection.getDefaultHostnameVerifier()
|
||||
}
|
||||
return GatewayTlsConfig(
|
||||
sslSocketFactory = context.socketFactory,
|
||||
trustManager = trustManager,
|
||||
hostnameVerifier = verifier,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun probeGatewayTlsFingerprint(
|
||||
host: String,
|
||||
port: Int,
|
||||
timeoutMs: Int = 3_000,
|
||||
): String? {
|
||||
val trimmedHost = host.trim()
|
||||
if (trimmedHost.isEmpty()) return null
|
||||
if (port !in 1..65535) return null
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
val trustAll =
|
||||
@SuppressLint("CustomX509TrustManager", "TrustAllX509TrustManager")
|
||||
object : X509TrustManager {
|
||||
@SuppressLint("TrustAllX509TrustManager")
|
||||
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {}
|
||||
@SuppressLint("TrustAllX509TrustManager")
|
||||
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {}
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
|
||||
}
|
||||
|
||||
val context = SSLContext.getInstance("TLS")
|
||||
context.init(null, arrayOf(trustAll), SecureRandom())
|
||||
|
||||
val socket = (context.socketFactory.createSocket() as SSLSocket)
|
||||
try {
|
||||
socket.soTimeout = timeoutMs
|
||||
socket.connect(InetSocketAddress(trimmedHost, port), timeoutMs)
|
||||
|
||||
// Best-effort SNI for hostnames (avoid crashing on IP literals).
|
||||
try {
|
||||
if (trimmedHost.any { it.isLetter() }) {
|
||||
val params = SSLParameters()
|
||||
params.serverNames = listOf(SNIHostName(trimmedHost))
|
||||
socket.sslParameters = params
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
socket.startHandshake()
|
||||
val cert = socket.session.peerCertificates.firstOrNull() as? X509Certificate ?: return@withContext null
|
||||
sha256Hex(cert.encoded)
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} finally {
|
||||
try {
|
||||
socket.close()
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun defaultTrustManager(): X509TrustManager {
|
||||
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||
factory.init(null as java.security.KeyStore?)
|
||||
val trust =
|
||||
factory.trustManagers.firstOrNull { it is X509TrustManager } as? X509TrustManager
|
||||
return trust ?: throw IllegalStateException("No default X509TrustManager found")
|
||||
}
|
||||
|
||||
private fun sha256Hex(data: ByteArray): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(data)
|
||||
val out = StringBuilder(digest.size * 2)
|
||||
for (byte in digest) {
|
||||
out.append(String.format(Locale.US, "%02x", byte))
|
||||
}
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
private fun normalizeFingerprint(raw: String): String {
|
||||
val stripped = raw.trim()
|
||||
.replace(Regex("^sha-?256\\s*:?\\s*", RegexOption.IGNORE_CASE), "")
|
||||
return stripped.lowercase(Locale.US).filter { it in '0'..'9' || it in 'a'..'f' }
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package ai.openclaw.app.gateway
|
||||
|
||||
data class ParsedInvokeError(
|
||||
val code: String,
|
||||
val message: String,
|
||||
val hadExplicitCode: Boolean,
|
||||
) {
|
||||
val prefixedMessage: String
|
||||
get() = "$code: $message"
|
||||
}
|
||||
|
||||
fun parseInvokeErrorMessage(raw: String): ParsedInvokeError {
|
||||
val trimmed = raw.trim()
|
||||
if (trimmed.isEmpty()) {
|
||||
return ParsedInvokeError(code = "UNAVAILABLE", message = "error", hadExplicitCode = false)
|
||||
}
|
||||
|
||||
val parts = trimmed.split(":", limit = 2)
|
||||
if (parts.size == 2) {
|
||||
val code = parts[0].trim()
|
||||
val rest = parts[1].trim()
|
||||
if (code.isNotEmpty() && code.all { it.isUpperCase() || it == '_' }) {
|
||||
return ParsedInvokeError(
|
||||
code = code,
|
||||
message = rest.ifEmpty { trimmed },
|
||||
hadExplicitCode = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
return ParsedInvokeError(code = "UNAVAILABLE", message = trimmed, hadExplicitCode = false)
|
||||
}
|
||||
|
||||
fun parseInvokeErrorFromThrowable(
|
||||
err: Throwable,
|
||||
fallbackMessage: String = "error",
|
||||
): ParsedInvokeError {
|
||||
val raw = err.message?.trim().takeIf { !it.isNullOrEmpty() } ?: fallbackMessage
|
||||
return parseInvokeErrorMessage(raw)
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
package ai.openclaw.app.node
|
||||
|
||||
import ai.openclaw.app.gateway.GatewaySession
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
class A2UIHandler(
|
||||
private val canvas: CanvasController,
|
||||
private val json: Json,
|
||||
private val getNodeCanvasHostUrl: () -> String?,
|
||||
private val getOperatorCanvasHostUrl: () -> String?,
|
||||
) {
|
||||
fun resolveA2uiHostUrl(): String? {
|
||||
val nodeRaw = getNodeCanvasHostUrl()?.trim().orEmpty()
|
||||
val operatorRaw = getOperatorCanvasHostUrl()?.trim().orEmpty()
|
||||
val raw = if (nodeRaw.isNotBlank()) nodeRaw else operatorRaw
|
||||
if (raw.isBlank()) return null
|
||||
val base = raw.trimEnd('/')
|
||||
return "${base}/__openclaw__/a2ui/?platform=android"
|
||||
}
|
||||
|
||||
suspend fun ensureA2uiReady(a2uiUrl: String): Boolean {
|
||||
try {
|
||||
val already = canvas.eval(a2uiReadyCheckJS)
|
||||
if (already == "true") return true
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
canvas.navigate(a2uiUrl)
|
||||
repeat(50) {
|
||||
try {
|
||||
val ready = canvas.eval(a2uiReadyCheckJS)
|
||||
if (ready == "true") return true
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
delay(120)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun decodeA2uiMessages(command: String, paramsJson: String?): String {
|
||||
val raw = paramsJson?.trim().orEmpty()
|
||||
if (raw.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: paramsJSON required")
|
||||
|
||||
val obj =
|
||||
json.parseToJsonElement(raw) as? JsonObject
|
||||
?: throw IllegalArgumentException("INVALID_REQUEST: expected object params")
|
||||
|
||||
val jsonlField = (obj["jsonl"] as? JsonPrimitive)?.content?.trim().orEmpty()
|
||||
val hasMessagesArray = obj["messages"] is JsonArray
|
||||
|
||||
if (command == "canvas.a2ui.pushJSONL" || (!hasMessagesArray && jsonlField.isNotBlank())) {
|
||||
val jsonl = jsonlField
|
||||
if (jsonl.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: jsonl required")
|
||||
val messages =
|
||||
jsonl
|
||||
.lineSequence()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
.mapIndexed { idx, line ->
|
||||
val el = json.parseToJsonElement(line)
|
||||
val msg =
|
||||
el as? JsonObject
|
||||
?: throw IllegalArgumentException("A2UI JSONL line ${idx + 1}: expected a JSON object")
|
||||
validateA2uiV0_8(msg, idx + 1)
|
||||
msg
|
||||
}
|
||||
.toList()
|
||||
return JsonArray(messages).toString()
|
||||
}
|
||||
|
||||
val arr = obj["messages"] as? JsonArray ?: throw IllegalArgumentException("INVALID_REQUEST: messages[] required")
|
||||
val out =
|
||||
arr.mapIndexed { idx, el ->
|
||||
val msg =
|
||||
el as? JsonObject
|
||||
?: throw IllegalArgumentException("A2UI messages[${idx}]: expected a JSON object")
|
||||
validateA2uiV0_8(msg, idx + 1)
|
||||
msg
|
||||
}
|
||||
return JsonArray(out).toString()
|
||||
}
|
||||
|
||||
private fun validateA2uiV0_8(msg: JsonObject, lineNumber: Int) {
|
||||
if (msg.containsKey("createSurface")) {
|
||||
throw IllegalArgumentException(
|
||||
"A2UI JSONL line $lineNumber: looks like A2UI v0.9 (`createSurface`). Canvas supports v0.8 messages only.",
|
||||
)
|
||||
}
|
||||
val allowed = setOf("beginRendering", "surfaceUpdate", "dataModelUpdate", "deleteSurface")
|
||||
val matched = msg.keys.filter { allowed.contains(it) }
|
||||
if (matched.size != 1) {
|
||||
val found = msg.keys.sorted().joinToString(", ")
|
||||
throw IllegalArgumentException(
|
||||
"A2UI JSONL line $lineNumber: expected exactly one of ${allowed.sorted().joinToString(", ")}; found: $found",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val a2uiReadyCheckJS: String =
|
||||
"""
|
||||
(() => {
|
||||
try {
|
||||
const host = globalThis.openclawA2UI;
|
||||
return !!host && typeof host.applyMessages === 'function';
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
})()
|
||||
"""
|
||||
|
||||
const val a2uiResetJS: String =
|
||||
"""
|
||||
(() => {
|
||||
try {
|
||||
const host = globalThis.openclawA2UI;
|
||||
if (!host) return { ok: false, error: "missing openclawA2UI" };
|
||||
return host.reset();
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e?.message ?? e) };
|
||||
}
|
||||
})()
|
||||
"""
|
||||
|
||||
fun a2uiApplyMessagesJS(messagesJson: String): String {
|
||||
return """
|
||||
(() => {
|
||||
try {
|
||||
const host = globalThis.openclawA2UI;
|
||||
if (!host) return { ok: false, error: "missing openclawA2UI" };
|
||||
const messages = $messagesJson;
|
||||
return host.applyMessages(messages);
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e?.message ?? e) };
|
||||
}
|
||||
})()
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,384 +0,0 @@
|
||||
package ai.openclaw.app.node
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
import androidx.core.content.ContextCompat
|
||||
import ai.openclaw.app.gateway.GatewaySession
|
||||
import java.time.Instant
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.util.TimeZone
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
private const val DEFAULT_CALENDAR_LIMIT = 50
|
||||
|
||||
internal data class CalendarEventsRequest(
|
||||
val startMs: Long,
|
||||
val endMs: Long,
|
||||
val limit: Int,
|
||||
)
|
||||
|
||||
internal data class CalendarAddRequest(
|
||||
val title: String,
|
||||
val startMs: Long,
|
||||
val endMs: Long,
|
||||
val isAllDay: Boolean,
|
||||
val location: String?,
|
||||
val notes: String?,
|
||||
val calendarId: Long?,
|
||||
val calendarTitle: String?,
|
||||
)
|
||||
|
||||
internal data class CalendarEventRecord(
|
||||
val identifier: String,
|
||||
val title: String,
|
||||
val startISO: String,
|
||||
val endISO: String,
|
||||
val isAllDay: Boolean,
|
||||
val location: String?,
|
||||
val calendarTitle: String?,
|
||||
)
|
||||
|
||||
internal interface CalendarDataSource {
|
||||
fun hasReadPermission(context: Context): Boolean
|
||||
|
||||
fun hasWritePermission(context: Context): Boolean
|
||||
|
||||
fun events(context: Context, request: CalendarEventsRequest): List<CalendarEventRecord>
|
||||
|
||||
fun add(context: Context, request: CalendarAddRequest): CalendarEventRecord
|
||||
}
|
||||
|
||||
private object SystemCalendarDataSource : CalendarDataSource {
|
||||
override fun hasReadPermission(context: Context): Boolean {
|
||||
return ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) ==
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
override fun hasWritePermission(context: Context): Boolean {
|
||||
return ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) ==
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
override fun events(context: Context, request: CalendarEventsRequest): List<CalendarEventRecord> {
|
||||
val resolver = context.contentResolver
|
||||
val builder = CalendarContract.Instances.CONTENT_URI.buildUpon()
|
||||
ContentUris.appendId(builder, request.startMs)
|
||||
ContentUris.appendId(builder, request.endMs)
|
||||
val projection =
|
||||
arrayOf(
|
||||
CalendarContract.Instances.EVENT_ID,
|
||||
CalendarContract.Instances.TITLE,
|
||||
CalendarContract.Instances.BEGIN,
|
||||
CalendarContract.Instances.END,
|
||||
CalendarContract.Instances.ALL_DAY,
|
||||
CalendarContract.Instances.EVENT_LOCATION,
|
||||
CalendarContract.Instances.CALENDAR_DISPLAY_NAME,
|
||||
)
|
||||
val sortOrder = "${CalendarContract.Instances.BEGIN} ASC LIMIT ${request.limit}"
|
||||
resolver.query(builder.build(), projection, null, null, sortOrder).use { cursor ->
|
||||
if (cursor == null) return emptyList()
|
||||
val out = mutableListOf<CalendarEventRecord>()
|
||||
while (cursor.moveToNext() && out.size < request.limit) {
|
||||
val id = cursor.getLong(0)
|
||||
val title = cursor.getString(1)?.trim().orEmpty().ifEmpty { "(untitled)" }
|
||||
val beginMs = cursor.getLong(2)
|
||||
val endMs = cursor.getLong(3)
|
||||
val isAllDay = cursor.getInt(4) == 1
|
||||
val location = cursor.getString(5)?.trim()?.ifEmpty { null }
|
||||
val calendarTitle = cursor.getString(6)?.trim()?.ifEmpty { null }
|
||||
out +=
|
||||
CalendarEventRecord(
|
||||
identifier = id.toString(),
|
||||
title = title,
|
||||
startISO = Instant.ofEpochMilli(beginMs).toString(),
|
||||
endISO = Instant.ofEpochMilli(endMs).toString(),
|
||||
isAllDay = isAllDay,
|
||||
location = location,
|
||||
calendarTitle = calendarTitle,
|
||||
)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
override fun add(context: Context, request: CalendarAddRequest): CalendarEventRecord {
|
||||
val resolver = context.contentResolver
|
||||
val resolvedCalendarId = resolveCalendarId(resolver, request.calendarId, request.calendarTitle)
|
||||
val values =
|
||||
ContentValues().apply {
|
||||
put(CalendarContract.Events.CALENDAR_ID, resolvedCalendarId)
|
||||
put(CalendarContract.Events.TITLE, request.title)
|
||||
put(CalendarContract.Events.DTSTART, request.startMs)
|
||||
put(CalendarContract.Events.DTEND, request.endMs)
|
||||
put(CalendarContract.Events.ALL_DAY, if (request.isAllDay) 1 else 0)
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, TimeZone.getDefault().id)
|
||||
request.location?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||
request.notes?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
||||
}
|
||||
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
|
||||
?: throw IllegalStateException("calendar insert failed")
|
||||
val eventId = uri.lastPathSegment?.toLongOrNull()
|
||||
?: throw IllegalStateException("calendar insert failed")
|
||||
return loadEventById(resolver, eventId)
|
||||
?: throw IllegalStateException("calendar insert failed")
|
||||
}
|
||||
|
||||
private fun resolveCalendarId(
|
||||
resolver: ContentResolver,
|
||||
calendarId: Long?,
|
||||
calendarTitle: String?,
|
||||
): Long {
|
||||
if (calendarId != null) {
|
||||
if (calendarExists(resolver, calendarId)) return calendarId
|
||||
throw IllegalArgumentException("CALENDAR_NOT_FOUND: no calendar id $calendarId")
|
||||
}
|
||||
if (!calendarTitle.isNullOrEmpty()) {
|
||||
findCalendarByTitle(resolver, calendarTitle)?.let { return it }
|
||||
throw IllegalArgumentException("CALENDAR_NOT_FOUND: no calendar named $calendarTitle")
|
||||
}
|
||||
findDefaultCalendarId(resolver)?.let { return it }
|
||||
throw IllegalArgumentException("CALENDAR_NOT_FOUND: no default calendar")
|
||||
}
|
||||
|
||||
private fun calendarExists(resolver: ContentResolver, id: Long): Boolean {
|
||||
val projection = arrayOf(CalendarContract.Calendars._ID)
|
||||
resolver.query(
|
||||
CalendarContract.Calendars.CONTENT_URI,
|
||||
projection,
|
||||
"${CalendarContract.Calendars._ID}=?",
|
||||
arrayOf(id.toString()),
|
||||
null,
|
||||
).use { cursor ->
|
||||
return cursor != null && cursor.moveToFirst()
|
||||
}
|
||||
}
|
||||
|
||||
private fun findCalendarByTitle(resolver: ContentResolver, title: String): Long? {
|
||||
val projection = arrayOf(CalendarContract.Calendars._ID)
|
||||
resolver.query(
|
||||
CalendarContract.Calendars.CONTENT_URI,
|
||||
projection,
|
||||
"${CalendarContract.Calendars.CALENDAR_DISPLAY_NAME}=?",
|
||||
arrayOf(title),
|
||||
"${CalendarContract.Calendars.IS_PRIMARY} DESC",
|
||||
).use { cursor ->
|
||||
if (cursor == null || !cursor.moveToFirst()) return null
|
||||
return cursor.getLong(0)
|
||||
}
|
||||
}
|
||||
|
||||
private fun findDefaultCalendarId(resolver: ContentResolver): Long? {
|
||||
val projection = arrayOf(CalendarContract.Calendars._ID)
|
||||
resolver.query(
|
||||
CalendarContract.Calendars.CONTENT_URI,
|
||||
projection,
|
||||
"${CalendarContract.Calendars.VISIBLE}=1",
|
||||
null,
|
||||
"${CalendarContract.Calendars.IS_PRIMARY} DESC, ${CalendarContract.Calendars._ID} ASC",
|
||||
).use { cursor ->
|
||||
if (cursor == null || !cursor.moveToFirst()) return null
|
||||
return cursor.getLong(0)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadEventById(
|
||||
resolver: ContentResolver,
|
||||
eventId: Long,
|
||||
): CalendarEventRecord? {
|
||||
val projection =
|
||||
arrayOf(
|
||||
CalendarContract.Events._ID,
|
||||
CalendarContract.Events.TITLE,
|
||||
CalendarContract.Events.DTSTART,
|
||||
CalendarContract.Events.DTEND,
|
||||
CalendarContract.Events.ALL_DAY,
|
||||
CalendarContract.Events.EVENT_LOCATION,
|
||||
CalendarContract.Events.CALENDAR_DISPLAY_NAME,
|
||||
)
|
||||
resolver.query(
|
||||
CalendarContract.Events.CONTENT_URI,
|
||||
projection,
|
||||
"${CalendarContract.Events._ID}=?",
|
||||
arrayOf(eventId.toString()),
|
||||
null,
|
||||
).use { cursor ->
|
||||
if (cursor == null || !cursor.moveToFirst()) return null
|
||||
return CalendarEventRecord(
|
||||
identifier = cursor.getLong(0).toString(),
|
||||
title = cursor.getString(1)?.trim().orEmpty().ifEmpty { "(untitled)" },
|
||||
startISO = Instant.ofEpochMilli(cursor.getLong(2)).toString(),
|
||||
endISO = Instant.ofEpochMilli(cursor.getLong(3)).toString(),
|
||||
isAllDay = cursor.getInt(4) == 1,
|
||||
location = cursor.getString(5)?.trim()?.ifEmpty { null },
|
||||
calendarTitle = cursor.getString(6)?.trim()?.ifEmpty { null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CalendarHandler private constructor(
|
||||
private val appContext: Context,
|
||||
private val dataSource: CalendarDataSource,
|
||||
) {
|
||||
constructor(appContext: Context) : this(appContext = appContext, dataSource = SystemCalendarDataSource)
|
||||
|
||||
fun handleCalendarEvents(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
if (!dataSource.hasReadPermission(appContext)) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "CALENDAR_PERMISSION_REQUIRED",
|
||||
message = "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission",
|
||||
)
|
||||
}
|
||||
val request =
|
||||
parseEventsRequest(paramsJson)
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: expected JSON object",
|
||||
)
|
||||
return try {
|
||||
val events = dataSource.events(appContext, request)
|
||||
GatewaySession.InvokeResult.ok(
|
||||
buildJsonObject {
|
||||
put(
|
||||
"events",
|
||||
buildJsonArray { events.forEach { add(eventJson(it)) } },
|
||||
)
|
||||
}.toString(),
|
||||
)
|
||||
} catch (err: Throwable) {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "CALENDAR_UNAVAILABLE",
|
||||
message = "CALENDAR_UNAVAILABLE: ${err.message ?: "calendar query failed"}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun handleCalendarAdd(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
if (!dataSource.hasWritePermission(appContext)) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "CALENDAR_PERMISSION_REQUIRED",
|
||||
message = "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission",
|
||||
)
|
||||
}
|
||||
val request =
|
||||
parseAddRequest(paramsJson)
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: expected JSON object",
|
||||
)
|
||||
if (request.title.isEmpty()) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "CALENDAR_INVALID",
|
||||
message = "CALENDAR_INVALID: title required",
|
||||
)
|
||||
}
|
||||
if (request.endMs <= request.startMs) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "CALENDAR_INVALID",
|
||||
message = "CALENDAR_INVALID: endISO must be after startISO",
|
||||
)
|
||||
}
|
||||
return try {
|
||||
val event = dataSource.add(appContext, request)
|
||||
GatewaySession.InvokeResult.ok(
|
||||
buildJsonObject {
|
||||
put("event", eventJson(event))
|
||||
}.toString(),
|
||||
)
|
||||
} catch (err: IllegalArgumentException) {
|
||||
val msg = err.message ?: "CALENDAR_INVALID: invalid request"
|
||||
val code = if (msg.startsWith("CALENDAR_NOT_FOUND")) "CALENDAR_NOT_FOUND" else "CALENDAR_INVALID"
|
||||
GatewaySession.InvokeResult.error(code = code, message = msg)
|
||||
} catch (err: Throwable) {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "CALENDAR_UNAVAILABLE",
|
||||
message = "CALENDAR_UNAVAILABLE: ${err.message ?: "calendar add failed"}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseEventsRequest(paramsJson: String?): CalendarEventsRequest? {
|
||||
if (paramsJson.isNullOrBlank()) {
|
||||
val start = Instant.now()
|
||||
val end = start.plus(7, ChronoUnit.DAYS)
|
||||
return CalendarEventsRequest(startMs = start.toEpochMilli(), endMs = end.toEpochMilli(), limit = DEFAULT_CALENDAR_LIMIT)
|
||||
}
|
||||
val params =
|
||||
try {
|
||||
Json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} ?: return null
|
||||
val start = parseISO((params["startISO"] as? JsonPrimitive)?.content)
|
||||
val end = parseISO((params["endISO"] as? JsonPrimitive)?.content)
|
||||
val resolvedStart = start ?: Instant.now()
|
||||
val resolvedEnd = end ?: resolvedStart.plus(7, ChronoUnit.DAYS)
|
||||
val limit = ((params["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: DEFAULT_CALENDAR_LIMIT).coerceIn(1, 500)
|
||||
return CalendarEventsRequest(
|
||||
startMs = resolvedStart.toEpochMilli(),
|
||||
endMs = resolvedEnd.toEpochMilli(),
|
||||
limit = limit,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseAddRequest(paramsJson: String?): CalendarAddRequest? {
|
||||
val params =
|
||||
try {
|
||||
paramsJson?.let { Json.parseToJsonElement(it).asObjectOrNull() }
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} ?: return null
|
||||
val start = parseISO((params["startISO"] as? JsonPrimitive)?.content)
|
||||
?: return null
|
||||
val end = parseISO((params["endISO"] as? JsonPrimitive)?.content)
|
||||
?: return null
|
||||
return CalendarAddRequest(
|
||||
title = (params["title"] as? JsonPrimitive)?.content?.trim().orEmpty(),
|
||||
startMs = start.toEpochMilli(),
|
||||
endMs = end.toEpochMilli(),
|
||||
isAllDay = (params["isAllDay"] as? JsonPrimitive)?.content?.toBooleanStrictOrNull() ?: false,
|
||||
location = (params["location"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
|
||||
notes = (params["notes"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
|
||||
calendarId = (params["calendarId"] as? JsonPrimitive)?.content?.toLongOrNull(),
|
||||
calendarTitle = (params["calendarTitle"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseISO(raw: String?): Instant? {
|
||||
val value = raw?.trim().orEmpty()
|
||||
if (value.isEmpty()) return null
|
||||
return try {
|
||||
Instant.parse(value)
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun eventJson(event: CalendarEventRecord): JsonObject {
|
||||
return buildJsonObject {
|
||||
put("identifier", JsonPrimitive(event.identifier))
|
||||
put("title", JsonPrimitive(event.title))
|
||||
put("startISO", JsonPrimitive(event.startISO))
|
||||
put("endISO", JsonPrimitive(event.endISO))
|
||||
put("isAllDay", JsonPrimitive(event.isAllDay))
|
||||
event.location?.let { put("location", JsonPrimitive(it)) }
|
||||
event.calendarTitle?.let { put("calendarTitle", JsonPrimitive(it)) }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal fun forTesting(
|
||||
appContext: Context,
|
||||
dataSource: CalendarDataSource,
|
||||
): CalendarHandler = CalendarHandler(appContext = appContext, dataSource = dataSource)
|
||||
}
|
||||
}
|
||||
@@ -1,419 +0,0 @@
|
||||
package ai.openclaw.app.node
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Matrix
|
||||
import android.content.pm.PackageManager
|
||||
import android.hardware.camera2.CameraCharacteristics
|
||||
import android.util.Base64
|
||||
import androidx.camera.camera2.interop.Camera2CameraInfo
|
||||
import androidx.camera.core.CameraInfo
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageCapture
|
||||
import androidx.camera.core.ImageCaptureException
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.video.FileOutputOptions
|
||||
import androidx.camera.video.FallbackStrategy
|
||||
import androidx.camera.video.Quality
|
||||
import androidx.camera.video.QualitySelector
|
||||
import androidx.camera.video.Recorder
|
||||
import androidx.camera.video.Recording
|
||||
import androidx.camera.video.VideoCapture
|
||||
import androidx.camera.video.VideoRecordEvent
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.ContextCompat.checkSelfPermission
|
||||
import androidx.core.graphics.scale
|
||||
import ai.openclaw.app.PermissionRequester
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.util.concurrent.Executor
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
class CameraCaptureManager(private val context: Context) {
|
||||
data class Payload(val payloadJson: String)
|
||||
data class FilePayload(val file: File, val durationMs: Long, val hasAudio: Boolean)
|
||||
data class CameraDeviceInfo(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val position: String,
|
||||
val deviceType: String,
|
||||
)
|
||||
|
||||
@Volatile private var lifecycleOwner: LifecycleOwner? = null
|
||||
@Volatile private var permissionRequester: PermissionRequester? = null
|
||||
|
||||
fun attachLifecycleOwner(owner: LifecycleOwner) {
|
||||
lifecycleOwner = owner
|
||||
}
|
||||
|
||||
fun attachPermissionRequester(requester: PermissionRequester) {
|
||||
permissionRequester = requester
|
||||
}
|
||||
|
||||
suspend fun listDevices(): List<CameraDeviceInfo> =
|
||||
withContext(Dispatchers.Main) {
|
||||
val provider = context.cameraProvider()
|
||||
provider.availableCameraInfos
|
||||
.mapNotNull { info -> cameraDeviceInfoOrNull(info) }
|
||||
.sortedBy { it.id }
|
||||
}
|
||||
|
||||
private suspend fun ensureCameraPermission() {
|
||||
val granted = checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
|
||||
if (granted) return
|
||||
|
||||
val requester = permissionRequester
|
||||
?: throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
|
||||
val results = requester.requestIfMissing(listOf(Manifest.permission.CAMERA))
|
||||
if (results[Manifest.permission.CAMERA] != true) {
|
||||
throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ensureMicPermission() {
|
||||
val granted = checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
|
||||
if (granted) return
|
||||
|
||||
val requester = permissionRequester
|
||||
?: throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission")
|
||||
val results = requester.requestIfMissing(listOf(Manifest.permission.RECORD_AUDIO))
|
||||
if (results[Manifest.permission.RECORD_AUDIO] != true) {
|
||||
throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun snap(paramsJson: String?): Payload =
|
||||
withContext(Dispatchers.Main) {
|
||||
ensureCameraPermission()
|
||||
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
|
||||
val params = parseJsonParamsObject(paramsJson)
|
||||
val facing = parseFacing(params) ?: "front"
|
||||
val quality = (parseQuality(params) ?: 0.95).coerceIn(0.1, 1.0)
|
||||
val maxWidth = parseMaxWidth(params) ?: 1600
|
||||
val deviceId = parseDeviceId(params)
|
||||
|
||||
val provider = context.cameraProvider()
|
||||
val capture = ImageCapture.Builder().build()
|
||||
val selector = resolveCameraSelector(provider, facing, deviceId)
|
||||
|
||||
provider.unbindAll()
|
||||
provider.bindToLifecycle(owner, selector, capture)
|
||||
|
||||
val (bytes, orientation) = capture.takeJpegWithExif(context.mainExecutor())
|
||||
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||
?: throw IllegalStateException("UNAVAILABLE: failed to decode captured image")
|
||||
val rotated = rotateBitmapByExif(decoded, orientation)
|
||||
val scaled =
|
||||
if (maxWidth > 0 && rotated.width > maxWidth) {
|
||||
val h =
|
||||
(rotated.height.toDouble() * (maxWidth.toDouble() / rotated.width.toDouble()))
|
||||
.toInt()
|
||||
.coerceAtLeast(1)
|
||||
rotated.scale(maxWidth, h)
|
||||
} else {
|
||||
rotated
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
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}}""",
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
suspend fun clip(paramsJson: String?): FilePayload =
|
||||
withContext(Dispatchers.Main) {
|
||||
ensureCameraPermission()
|
||||
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
|
||||
val params = parseJsonParamsObject(paramsJson)
|
||||
val facing = parseFacing(params) ?: "front"
|
||||
val durationMs = (parseDurationMs(params) ?: 3_000).coerceIn(200, 60_000)
|
||||
val includeAudio = parseIncludeAudio(params) ?: true
|
||||
val deviceId = parseDeviceId(params)
|
||||
if (includeAudio) ensureMicPermission()
|
||||
|
||||
android.util.Log.w("CameraCaptureManager", "clip: start facing=$facing duration=$durationMs audio=$includeAudio deviceId=${deviceId ?: "-"}")
|
||||
|
||||
val provider = context.cameraProvider()
|
||||
android.util.Log.w("CameraCaptureManager", "clip: got camera provider")
|
||||
|
||||
// Use LOWEST quality for smallest files over WebSocket
|
||||
val recorder = Recorder.Builder()
|
||||
.setQualitySelector(
|
||||
QualitySelector.from(Quality.LOWEST, FallbackStrategy.lowerQualityOrHigherThan(Quality.LOWEST))
|
||||
)
|
||||
.build()
|
||||
val videoCapture = VideoCapture.withOutput(recorder)
|
||||
val selector = resolveCameraSelector(provider, facing, deviceId)
|
||||
|
||||
// CameraX requires a Preview use case for the camera to start producing frames;
|
||||
// without it, the encoder may get no data (ERROR_NO_VALID_DATA).
|
||||
val preview = androidx.camera.core.Preview.Builder().build()
|
||||
// Provide a dummy SurfaceTexture so the preview pipeline activates
|
||||
val surfaceTexture = android.graphics.SurfaceTexture(0)
|
||||
surfaceTexture.setDefaultBufferSize(640, 480)
|
||||
preview.setSurfaceProvider { request ->
|
||||
val surface = android.view.Surface(surfaceTexture)
|
||||
request.provideSurface(surface, context.mainExecutor()) { result ->
|
||||
surface.release()
|
||||
surfaceTexture.release()
|
||||
}
|
||||
}
|
||||
|
||||
provider.unbindAll()
|
||||
android.util.Log.w("CameraCaptureManager", "clip: binding preview + videoCapture to lifecycle")
|
||||
val camera = provider.bindToLifecycle(owner, selector, preview, videoCapture)
|
||||
android.util.Log.w("CameraCaptureManager", "clip: bound, cameraInfo=${camera.cameraInfo}")
|
||||
|
||||
// Give camera pipeline time to initialize before recording
|
||||
android.util.Log.w("CameraCaptureManager", "clip: warming up camera 1.5s...")
|
||||
kotlinx.coroutines.delay(1_500)
|
||||
|
||||
val file = File.createTempFile("openclaw-clip-", ".mp4")
|
||||
val outputOptions = FileOutputOptions.Builder(file).build()
|
||||
|
||||
val finalized = kotlinx.coroutines.CompletableDeferred<VideoRecordEvent.Finalize>()
|
||||
android.util.Log.w("CameraCaptureManager", "clip: starting recording to ${file.absolutePath}")
|
||||
val recording: Recording =
|
||||
videoCapture.output
|
||||
.prepareRecording(context, outputOptions)
|
||||
.apply {
|
||||
if (includeAudio) withAudioEnabled()
|
||||
}
|
||||
.start(context.mainExecutor()) { event ->
|
||||
android.util.Log.w("CameraCaptureManager", "clip: event ${event.javaClass.simpleName}")
|
||||
if (event is VideoRecordEvent.Status) {
|
||||
android.util.Log.w("CameraCaptureManager", "clip: recording status update")
|
||||
}
|
||||
if (event is VideoRecordEvent.Finalize) {
|
||||
android.util.Log.w("CameraCaptureManager", "clip: finalize hasError=${event.hasError()} error=${event.error} cause=${event.cause}")
|
||||
finalized.complete(event)
|
||||
}
|
||||
}
|
||||
|
||||
android.util.Log.w("CameraCaptureManager", "clip: recording started, delaying ${durationMs}ms")
|
||||
try {
|
||||
kotlinx.coroutines.delay(durationMs.toLong())
|
||||
} finally {
|
||||
android.util.Log.w("CameraCaptureManager", "clip: stopping recording")
|
||||
recording.stop()
|
||||
}
|
||||
|
||||
val finalizeEvent =
|
||||
try {
|
||||
withTimeout(15_000) { finalized.await() }
|
||||
} catch (err: Throwable) {
|
||||
android.util.Log.e("CameraCaptureManager", "clip: finalize timed out", err)
|
||||
withContext(Dispatchers.IO) { file.delete() }
|
||||
provider.unbindAll()
|
||||
throw IllegalStateException("UNAVAILABLE: camera clip finalize timed out")
|
||||
}
|
||||
if (finalizeEvent.hasError()) {
|
||||
android.util.Log.e("CameraCaptureManager", "clip: FAILED error=${finalizeEvent.error}, cause=${finalizeEvent.cause}", finalizeEvent.cause)
|
||||
// Check file size for debugging
|
||||
val fileSize = withContext(Dispatchers.IO) { if (file.exists()) file.length() else -1 }
|
||||
android.util.Log.e("CameraCaptureManager", "clip: file exists=${file.exists()} size=$fileSize")
|
||||
withContext(Dispatchers.IO) { file.delete() }
|
||||
provider.unbindAll()
|
||||
throw IllegalStateException("UNAVAILABLE: camera clip failed (error=${finalizeEvent.error})")
|
||||
}
|
||||
|
||||
val fileSize = withContext(Dispatchers.IO) { file.length() }
|
||||
android.util.Log.w("CameraCaptureManager", "clip: SUCCESS file size=$fileSize")
|
||||
|
||||
provider.unbindAll()
|
||||
|
||||
FilePayload(file = file, durationMs = durationMs.toLong(), hasAudio = includeAudio)
|
||||
}
|
||||
|
||||
private fun rotateBitmapByExif(bitmap: Bitmap, orientation: Int): Bitmap {
|
||||
val matrix = Matrix()
|
||||
when (orientation) {
|
||||
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
|
||||
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
|
||||
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
|
||||
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f)
|
||||
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f)
|
||||
ExifInterface.ORIENTATION_TRANSPOSE -> {
|
||||
matrix.postRotate(90f)
|
||||
matrix.postScale(-1f, 1f)
|
||||
}
|
||||
ExifInterface.ORIENTATION_TRANSVERSE -> {
|
||||
matrix.postRotate(-90f)
|
||||
matrix.postScale(-1f, 1f)
|
||||
}
|
||||
else -> return bitmap
|
||||
}
|
||||
val rotated = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
||||
if (rotated !== bitmap) {
|
||||
bitmap.recycle()
|
||||
}
|
||||
return rotated
|
||||
}
|
||||
|
||||
private fun parseFacing(params: JsonObject?): String? {
|
||||
val value = parseJsonString(params, "facing")?.trim()?.lowercase() ?: return null
|
||||
return when (value) {
|
||||
"front", "back" -> value
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseQuality(params: JsonObject?): Double? =
|
||||
parseJsonDouble(params, "quality")
|
||||
|
||||
private fun parseMaxWidth(params: JsonObject?): Int? =
|
||||
parseJsonInt(params, "maxWidth")
|
||||
?.takeIf { it > 0 }
|
||||
|
||||
private fun parseDurationMs(params: JsonObject?): Int? =
|
||||
parseJsonInt(params, "durationMs")
|
||||
|
||||
private fun parseDeviceId(params: JsonObject?): String? =
|
||||
parseJsonString(params, "deviceId")
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun parseIncludeAudio(params: JsonObject?): Boolean? = parseJsonBooleanFlag(params, "includeAudio")
|
||||
|
||||
private fun Context.mainExecutor(): Executor = ContextCompat.getMainExecutor(this)
|
||||
|
||||
private fun resolveCameraSelector(
|
||||
provider: ProcessCameraProvider,
|
||||
facing: String,
|
||||
deviceId: String?,
|
||||
): CameraSelector {
|
||||
if (deviceId.isNullOrEmpty()) {
|
||||
return if (facing == "front") CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA
|
||||
}
|
||||
val availableIds = provider.availableCameraInfos.mapNotNull { cameraIdOrNull(it) }.toSet()
|
||||
if (!availableIds.contains(deviceId)) {
|
||||
throw IllegalStateException("INVALID_REQUEST: unknown camera deviceId '$deviceId'")
|
||||
}
|
||||
return CameraSelector.Builder()
|
||||
.addCameraFilter { infos -> infos.filter { cameraIdOrNull(it) == deviceId } }
|
||||
.build()
|
||||
}
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
private fun cameraDeviceInfoOrNull(info: CameraInfo): CameraDeviceInfo? {
|
||||
val cameraId = cameraIdOrNull(info) ?: return null
|
||||
val lensFacing =
|
||||
runCatching {
|
||||
Camera2CameraInfo.from(info).getCameraCharacteristic(CameraCharacteristics.LENS_FACING)
|
||||
}.getOrNull()
|
||||
val position =
|
||||
when (lensFacing) {
|
||||
CameraCharacteristics.LENS_FACING_FRONT -> "front"
|
||||
CameraCharacteristics.LENS_FACING_BACK -> "back"
|
||||
CameraCharacteristics.LENS_FACING_EXTERNAL -> "external"
|
||||
else -> "unspecified"
|
||||
}
|
||||
val deviceType =
|
||||
if (lensFacing == CameraCharacteristics.LENS_FACING_EXTERNAL) "external" else "builtIn"
|
||||
val name =
|
||||
when (position) {
|
||||
"front" -> "Front Camera"
|
||||
"back" -> "Back Camera"
|
||||
"external" -> "External Camera"
|
||||
else -> "Camera $cameraId"
|
||||
}
|
||||
return CameraDeviceInfo(
|
||||
id = cameraId,
|
||||
name = name,
|
||||
position = position,
|
||||
deviceType = deviceType,
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
private fun cameraIdOrNull(info: CameraInfo): String? =
|
||||
runCatching { Camera2CameraInfo.from(info).cameraId }.getOrNull()
|
||||
}
|
||||
|
||||
private suspend fun Context.cameraProvider(): ProcessCameraProvider =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
val future = ProcessCameraProvider.getInstance(this)
|
||||
future.addListener(
|
||||
{
|
||||
try {
|
||||
cont.resume(future.get())
|
||||
} catch (e: Exception) {
|
||||
cont.resumeWithException(e)
|
||||
}
|
||||
},
|
||||
ContextCompat.getMainExecutor(this),
|
||||
)
|
||||
}
|
||||
|
||||
/** Returns (jpegBytes, exifOrientation) so caller can rotate the decoded bitmap. */
|
||||
private suspend fun ImageCapture.takeJpegWithExif(executor: Executor): Pair<ByteArray, Int> =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
val file = File.createTempFile("openclaw-snap-", ".jpg")
|
||||
val options = ImageCapture.OutputFileOptions.Builder(file).build()
|
||||
takePicture(
|
||||
options,
|
||||
executor,
|
||||
object : ImageCapture.OnImageSavedCallback {
|
||||
override fun onError(exception: ImageCaptureException) {
|
||||
file.delete()
|
||||
cont.resumeWithException(exception)
|
||||
}
|
||||
|
||||
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
|
||||
try {
|
||||
val exif = ExifInterface(file.absolutePath)
|
||||
val orientation = exif.getAttributeInt(
|
||||
ExifInterface.TAG_ORIENTATION,
|
||||
ExifInterface.ORIENTATION_NORMAL,
|
||||
)
|
||||
val bytes = file.readBytes()
|
||||
cont.resume(Pair(bytes, orientation))
|
||||
} catch (e: Exception) {
|
||||
cont.resumeWithException(e)
|
||||
} finally {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
package ai.openclaw.app.node
|
||||
|
||||
import android.content.Context
|
||||
import ai.openclaw.app.CameraHudKind
|
||||
import ai.openclaw.app.BuildConfig
|
||||
import ai.openclaw.app.gateway.GatewaySession
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
internal const val CAMERA_CLIP_MAX_RAW_BYTES: Long = 18L * 1024L * 1024L
|
||||
|
||||
internal fun isCameraClipWithinPayloadLimit(rawBytes: Long): Boolean =
|
||||
rawBytes in 0L..CAMERA_CLIP_MAX_RAW_BYTES
|
||||
|
||||
class CameraHandler(
|
||||
private val appContext: Context,
|
||||
private val camera: CameraCaptureManager,
|
||||
private val externalAudioCaptureActive: MutableStateFlow<Boolean>,
|
||||
private val showCameraHud: (message: String, kind: CameraHudKind, autoHideMs: Long?) -> Unit,
|
||||
private val triggerCameraFlash: () -> Unit,
|
||||
private val invokeErrorFromThrowable: (err: Throwable) -> Pair<String, String>,
|
||||
) {
|
||||
suspend fun handleList(_paramsJson: String?): GatewaySession.InvokeResult {
|
||||
return try {
|
||||
val devices = camera.listDevices()
|
||||
val payload =
|
||||
buildJsonObject {
|
||||
put(
|
||||
"devices",
|
||||
buildJsonArray {
|
||||
devices.forEach { device ->
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("id", JsonPrimitive(device.id))
|
||||
put("name", JsonPrimitive(device.name))
|
||||
put("position", JsonPrimitive(device.position))
|
||||
put("deviceType", JsonPrimitive(device.deviceType))
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}.toString()
|
||||
GatewaySession.InvokeResult.ok(payload)
|
||||
} catch (err: Throwable) {
|
||||
val (code, message) = invokeErrorFromThrowable(err)
|
||||
GatewaySession.InvokeResult.error(code = code, message = message)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun handleSnap(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
val logFile = if (BuildConfig.DEBUG) java.io.File(appContext.cacheDir, "camera_debug.log") else null
|
||||
fun camLog(msg: String) {
|
||||
if (!BuildConfig.DEBUG) return
|
||||
val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.US).format(java.util.Date())
|
||||
logFile?.appendText("[$ts] $msg\n")
|
||||
android.util.Log.w("openclaw", "camera.snap: $msg")
|
||||
}
|
||||
try {
|
||||
logFile?.writeText("") // clear
|
||||
camLog("starting, params=$paramsJson")
|
||||
camLog("calling showCameraHud")
|
||||
showCameraHud("Taking photo…", CameraHudKind.Photo, null)
|
||||
camLog("calling triggerCameraFlash")
|
||||
triggerCameraFlash()
|
||||
val res =
|
||||
try {
|
||||
camLog("calling camera.snap()")
|
||||
val r = camera.snap(paramsJson)
|
||||
camLog("success, payload size=${r.payloadJson.length}")
|
||||
r
|
||||
} catch (err: Throwable) {
|
||||
camLog("inner error: ${err::class.java.simpleName}: ${err.message}")
|
||||
camLog("stack: ${err.stackTraceToString().take(2000)}")
|
||||
val (code, message) = invokeErrorFromThrowable(err)
|
||||
showCameraHud(message, CameraHudKind.Error, 2200)
|
||||
return GatewaySession.InvokeResult.error(code = code, message = message)
|
||||
}
|
||||
camLog("returning result")
|
||||
showCameraHud("Photo captured", CameraHudKind.Success, 1600)
|
||||
return GatewaySession.InvokeResult.ok(res.payloadJson)
|
||||
} catch (err: Throwable) {
|
||||
camLog("outer error: ${err::class.java.simpleName}: ${err.message}")
|
||||
camLog("stack: ${err.stackTraceToString().take(2000)}")
|
||||
return GatewaySession.InvokeResult.error(code = "UNAVAILABLE", message = err.message ?: "camera snap failed")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun handleClip(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
val clipLogFile = if (BuildConfig.DEBUG) java.io.File(appContext.cacheDir, "camera_debug.log") else null
|
||||
fun clipLog(msg: String) {
|
||||
if (!BuildConfig.DEBUG) return
|
||||
val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.US).format(java.util.Date())
|
||||
clipLogFile?.appendText("[CLIP $ts] $msg\n")
|
||||
android.util.Log.w("openclaw", "camera.clip: $msg")
|
||||
}
|
||||
val includeAudio = parseIncludeAudio(paramsJson) ?: true
|
||||
if (includeAudio) externalAudioCaptureActive.value = true
|
||||
try {
|
||||
clipLogFile?.writeText("") // clear
|
||||
clipLog("starting, params=$paramsJson includeAudio=$includeAudio")
|
||||
clipLog("calling showCameraHud")
|
||||
showCameraHud("Recording…", CameraHudKind.Recording, null)
|
||||
val filePayload =
|
||||
try {
|
||||
clipLog("calling camera.clip()")
|
||||
val r = camera.clip(paramsJson)
|
||||
clipLog("success, file size=${r.file.length()}")
|
||||
r
|
||||
} catch (err: Throwable) {
|
||||
clipLog("inner error: ${err::class.java.simpleName}: ${err.message}")
|
||||
clipLog("stack: ${err.stackTraceToString().take(2000)}")
|
||||
val (code, message) = invokeErrorFromThrowable(err)
|
||||
showCameraHud(message, CameraHudKind.Error, 2400)
|
||||
return GatewaySession.InvokeResult.error(code = code, message = message)
|
||||
}
|
||||
val rawBytes = filePayload.file.length()
|
||||
if (!isCameraClipWithinPayloadLimit(rawBytes)) {
|
||||
clipLog("payload too large: bytes=$rawBytes max=$CAMERA_CLIP_MAX_RAW_BYTES")
|
||||
withContext(Dispatchers.IO) { filePayload.file.delete() }
|
||||
showCameraHud("Clip too large", CameraHudKind.Error, 2400)
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "PAYLOAD_TOO_LARGE",
|
||||
message =
|
||||
"PAYLOAD_TOO_LARGE: camera clip is $rawBytes bytes; max is $CAMERA_CLIP_MAX_RAW_BYTES bytes. Reduce durationMs and retry.",
|
||||
)
|
||||
}
|
||||
|
||||
val bytes = withContext(Dispatchers.IO) {
|
||||
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")
|
||||
showCameraHud("Clip captured", CameraHudKind.Success, 1800)
|
||||
return GatewaySession.InvokeResult.ok(
|
||||
"""{"format":"mp4","base64":"$base64","durationMs":${filePayload.durationMs},"hasAudio":${filePayload.hasAudio}}"""
|
||||
)
|
||||
} catch (err: Throwable) {
|
||||
clipLog("outer error: ${err::class.java.simpleName}: ${err.message}")
|
||||
clipLog("stack: ${err.stackTraceToString().take(2000)}")
|
||||
return GatewaySession.InvokeResult.error(code = "UNAVAILABLE", message = err.message ?: "camera clip failed")
|
||||
} finally {
|
||||
if (includeAudio) externalAudioCaptureActive.value = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseIncludeAudio(paramsJson: String?): Boolean? {
|
||||
if (paramsJson.isNullOrBlank()) return null
|
||||
val root =
|
||||
try {
|
||||
Json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} ?: return null
|
||||
val value =
|
||||
(root["includeAudio"] as? JsonPrimitive)
|
||||
?.contentOrNull
|
||||
?.trim()
|
||||
?.lowercase()
|
||||
return when (value) {
|
||||
"true" -> true
|
||||
"false" -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
package ai.openclaw.app.node
|
||||
|
||||
import android.os.Build
|
||||
import ai.openclaw.app.BuildConfig
|
||||
import ai.openclaw.app.SecurePrefs
|
||||
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.LocationMode
|
||||
import ai.openclaw.app.VoiceWakeMode
|
||||
|
||||
class ConnectionManager(
|
||||
private val prefs: SecurePrefs,
|
||||
private val cameraEnabled: () -> Boolean,
|
||||
private val locationMode: () -> LocationMode,
|
||||
private val voiceWakeMode: () -> VoiceWakeMode,
|
||||
private val motionActivityAvailable: () -> Boolean,
|
||||
private val motionPedometerAvailable: () -> Boolean,
|
||||
private val smsAvailable: () -> Boolean,
|
||||
private val hasRecordAudioPermission: () -> Boolean,
|
||||
private val manualTls: () -> Boolean,
|
||||
) {
|
||||
companion object {
|
||||
internal fun resolveTlsParamsForEndpoint(
|
||||
endpoint: GatewayEndpoint,
|
||||
storedFingerprint: String?,
|
||||
manualTlsEnabled: Boolean,
|
||||
): GatewayTlsParams? {
|
||||
val stableId = endpoint.stableId
|
||||
val stored = storedFingerprint?.trim().takeIf { !it.isNullOrEmpty() }
|
||||
val isManual = stableId.startsWith("manual|")
|
||||
|
||||
if (isManual) {
|
||||
if (!manualTlsEnabled) return null
|
||||
if (!stored.isNullOrBlank()) {
|
||||
return GatewayTlsParams(
|
||||
required = true,
|
||||
expectedFingerprint = stored,
|
||||
allowTOFU = false,
|
||||
stableId = stableId,
|
||||
)
|
||||
}
|
||||
return GatewayTlsParams(
|
||||
required = true,
|
||||
expectedFingerprint = null,
|
||||
allowTOFU = false,
|
||||
stableId = stableId,
|
||||
)
|
||||
}
|
||||
|
||||
// Prefer stored pins. Never let discovery-provided TXT override a stored fingerprint.
|
||||
if (!stored.isNullOrBlank()) {
|
||||
return GatewayTlsParams(
|
||||
required = true,
|
||||
expectedFingerprint = stored,
|
||||
allowTOFU = false,
|
||||
stableId = stableId,
|
||||
)
|
||||
}
|
||||
|
||||
val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank()
|
||||
if (hinted) {
|
||||
// TXT is unauthenticated. Do not treat the advertised fingerprint as authoritative.
|
||||
return GatewayTlsParams(
|
||||
required = true,
|
||||
expectedFingerprint = null,
|
||||
allowTOFU = false,
|
||||
stableId = stableId,
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private fun runtimeFlags(): NodeRuntimeFlags =
|
||||
NodeRuntimeFlags(
|
||||
cameraEnabled = cameraEnabled(),
|
||||
locationEnabled = locationMode() != LocationMode.Off,
|
||||
smsAvailable = smsAvailable(),
|
||||
voiceWakeEnabled = voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission(),
|
||||
motionActivityAvailable = motionActivityAvailable(),
|
||||
motionPedometerAvailable = motionPedometerAvailable(),
|
||||
debugBuild = BuildConfig.DEBUG,
|
||||
)
|
||||
|
||||
fun buildInvokeCommands(): List<String> = InvokeCommandRegistry.advertisedCommands(runtimeFlags())
|
||||
|
||||
fun buildCapabilities(): List<String> = InvokeCommandRegistry.advertisedCapabilities(runtimeFlags())
|
||||
|
||||
fun resolvedVersionName(): String {
|
||||
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
|
||||
return if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
|
||||
"$versionName-dev"
|
||||
} else {
|
||||
versionName
|
||||
}
|
||||
}
|
||||
|
||||
fun resolveModelIdentifier(): String? {
|
||||
return listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
||||
.joinToString(" ")
|
||||
.trim()
|
||||
.ifEmpty { null }
|
||||
}
|
||||
|
||||
fun buildUserAgent(): String {
|
||||
val version = resolvedVersionName()
|
||||
val release = Build.VERSION.RELEASE?.trim().orEmpty()
|
||||
val releaseLabel = if (release.isEmpty()) "unknown" else release
|
||||
return "OpenClawAndroid/$version (Android $releaseLabel; SDK ${Build.VERSION.SDK_INT})"
|
||||
}
|
||||
|
||||
fun buildClientInfo(clientId: String, clientMode: String): GatewayClientInfo {
|
||||
return GatewayClientInfo(
|
||||
id = clientId,
|
||||
displayName = prefs.displayName.value,
|
||||
version = resolvedVersionName(),
|
||||
platform = "android",
|
||||
mode = clientMode,
|
||||
instanceId = prefs.instanceId.value,
|
||||
deviceFamily = "Android",
|
||||
modelIdentifier = resolveModelIdentifier(),
|
||||
)
|
||||
}
|
||||
|
||||
fun buildNodeConnectOptions(): GatewayConnectOptions {
|
||||
return GatewayConnectOptions(
|
||||
role = "node",
|
||||
scopes = emptyList(),
|
||||
caps = buildCapabilities(),
|
||||
commands = buildInvokeCommands(),
|
||||
permissions = emptyMap(),
|
||||
client = buildClientInfo(clientId = "openclaw-android", clientMode = "node"),
|
||||
userAgent = buildUserAgent(),
|
||||
)
|
||||
}
|
||||
|
||||
fun buildOperatorConnectOptions(): GatewayConnectOptions {
|
||||
return GatewayConnectOptions(
|
||||
role = "operator",
|
||||
scopes = listOf("operator.read", "operator.write", "operator.talk.secrets"),
|
||||
caps = emptyList(),
|
||||
commands = emptyList(),
|
||||
permissions = emptyMap(),
|
||||
client = buildClientInfo(clientId = "openclaw-android", clientMode = "ui"),
|
||||
userAgent = buildUserAgent(),
|
||||
)
|
||||
}
|
||||
|
||||
fun resolveTlsParams(endpoint: GatewayEndpoint): GatewayTlsParams? {
|
||||
val stored = prefs.loadGatewayTlsFingerprint(endpoint.stableId)
|
||||
return resolveTlsParamsForEndpoint(endpoint, storedFingerprint = stored, manualTlsEnabled = manualTls())
|
||||
}
|
||||
}
|
||||
@@ -1,430 +0,0 @@
|
||||
package ai.openclaw.app.node
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.provider.ContactsContract
|
||||
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.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
private const val DEFAULT_CONTACTS_LIMIT = 25
|
||||
|
||||
internal data class ContactRecord(
|
||||
val identifier: String,
|
||||
val displayName: String,
|
||||
val givenName: String,
|
||||
val familyName: String,
|
||||
val organizationName: String,
|
||||
val phoneNumbers: List<String>,
|
||||
val emails: List<String>,
|
||||
)
|
||||
|
||||
internal data class ContactsSearchRequest(
|
||||
val query: String?,
|
||||
val limit: Int,
|
||||
)
|
||||
|
||||
internal data class ContactsAddRequest(
|
||||
val givenName: String?,
|
||||
val familyName: String?,
|
||||
val organizationName: String?,
|
||||
val displayName: String?,
|
||||
val phoneNumbers: List<String>,
|
||||
val emails: List<String>,
|
||||
)
|
||||
|
||||
internal interface ContactsDataSource {
|
||||
fun hasReadPermission(context: Context): Boolean
|
||||
|
||||
fun hasWritePermission(context: Context): Boolean
|
||||
|
||||
fun search(context: Context, request: ContactsSearchRequest): List<ContactRecord>
|
||||
|
||||
fun add(context: Context, request: ContactsAddRequest): ContactRecord
|
||||
}
|
||||
|
||||
private object SystemContactsDataSource : ContactsDataSource {
|
||||
override fun hasReadPermission(context: Context): Boolean {
|
||||
return ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) ==
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
override fun hasWritePermission(context: Context): Boolean {
|
||||
return ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CONTACTS) ==
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
override fun search(context: Context, request: ContactsSearchRequest): List<ContactRecord> {
|
||||
val resolver = context.contentResolver
|
||||
val projection =
|
||||
arrayOf(
|
||||
ContactsContract.Contacts._ID,
|
||||
ContactsContract.Contacts.DISPLAY_NAME_PRIMARY,
|
||||
)
|
||||
val selection: String?
|
||||
val selectionArgs: Array<String>?
|
||||
if (request.query.isNullOrBlank()) {
|
||||
selection = null
|
||||
selectionArgs = null
|
||||
} else {
|
||||
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(
|
||||
ContactsContract.Contacts.CONTENT_URI,
|
||||
projection,
|
||||
selection,
|
||||
selectionArgs,
|
||||
sortOrder,
|
||||
).use { cursor ->
|
||||
if (cursor == null) return emptyList()
|
||||
val idIndex = cursor.getColumnIndexOrThrow(ContactsContract.Contacts._ID)
|
||||
val displayNameIndex = cursor.getColumnIndexOrThrow(ContactsContract.Contacts.DISPLAY_NAME_PRIMARY)
|
||||
val out = mutableListOf<ContactRecord>()
|
||||
while (cursor.moveToNext() && out.size < request.limit) {
|
||||
val contactId = cursor.getLong(idIndex)
|
||||
val displayName = cursor.getString(displayNameIndex).orEmpty()
|
||||
out += loadContactRecord(resolver, contactId, fallbackDisplayName = displayName)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
override fun add(context: Context, request: ContactsAddRequest): ContactRecord {
|
||||
val resolver = context.contentResolver
|
||||
val operations = ArrayList<ContentProviderOperation>()
|
||||
operations +=
|
||||
ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI)
|
||||
.withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, null)
|
||||
.withValue(ContactsContract.RawContacts.ACCOUNT_NAME, null)
|
||||
.build()
|
||||
if (!request.givenName.isNullOrEmpty() || !request.familyName.isNullOrEmpty() || !request.displayName.isNullOrEmpty()) {
|
||||
operations +=
|
||||
ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
|
||||
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
|
||||
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
|
||||
.withValue(ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME, request.givenName)
|
||||
.withValue(ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME, request.familyName)
|
||||
.withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, request.displayName)
|
||||
.build()
|
||||
}
|
||||
if (!request.organizationName.isNullOrEmpty()) {
|
||||
operations +=
|
||||
ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
|
||||
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
|
||||
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Organization.CONTENT_ITEM_TYPE)
|
||||
.withValue(ContactsContract.CommonDataKinds.Organization.COMPANY, request.organizationName)
|
||||
.build()
|
||||
}
|
||||
request.phoneNumbers.forEach { number ->
|
||||
operations +=
|
||||
ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
|
||||
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
|
||||
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE)
|
||||
.withValue(ContactsContract.CommonDataKinds.Phone.NUMBER, number)
|
||||
.withValue(ContactsContract.CommonDataKinds.Phone.TYPE, ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE)
|
||||
.build()
|
||||
}
|
||||
request.emails.forEach { email ->
|
||||
operations +=
|
||||
ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
|
||||
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
|
||||
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE)
|
||||
.withValue(ContactsContract.CommonDataKinds.Email.ADDRESS, email)
|
||||
.withValue(ContactsContract.CommonDataKinds.Email.TYPE, ContactsContract.CommonDataKinds.Email.TYPE_HOME)
|
||||
.build()
|
||||
}
|
||||
|
||||
val results = resolver.applyBatch(ContactsContract.AUTHORITY, operations)
|
||||
val rawContactUri = results.firstOrNull()?.uri
|
||||
?: throw IllegalStateException("contact insert failed")
|
||||
val rawContactId = rawContactUri.lastPathSegment?.toLongOrNull()
|
||||
?: throw IllegalStateException("contact insert failed")
|
||||
val contactId = resolveContactIdForRawContact(resolver, rawContactId)
|
||||
?: throw IllegalStateException("contact insert failed")
|
||||
return loadContactRecord(
|
||||
resolver = resolver,
|
||||
contactId = contactId,
|
||||
fallbackDisplayName = request.displayName.orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun resolveContactIdForRawContact(resolver: ContentResolver, rawContactId: Long): Long? {
|
||||
val projection = arrayOf(ContactsContract.RawContacts.CONTACT_ID)
|
||||
resolver.query(
|
||||
ContactsContract.RawContacts.CONTENT_URI,
|
||||
projection,
|
||||
"${ContactsContract.RawContacts._ID}=?",
|
||||
arrayOf(rawContactId.toString()),
|
||||
null,
|
||||
).use { cursor ->
|
||||
if (cursor == null || !cursor.moveToFirst()) return null
|
||||
val index = cursor.getColumnIndexOrThrow(ContactsContract.RawContacts.CONTACT_ID)
|
||||
return cursor.getLong(index)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadContactRecord(
|
||||
resolver: ContentResolver,
|
||||
contactId: Long,
|
||||
fallbackDisplayName: String,
|
||||
): ContactRecord {
|
||||
val nameRow = loadNameRow(resolver, contactId)
|
||||
val organization = loadOrganization(resolver, contactId)
|
||||
val phones = loadPhones(resolver, contactId)
|
||||
val emails = loadEmails(resolver, contactId)
|
||||
val displayName =
|
||||
when {
|
||||
!nameRow.displayName.isNullOrEmpty() -> nameRow.displayName
|
||||
!fallbackDisplayName.isNullOrEmpty() -> fallbackDisplayName
|
||||
else -> listOfNotNull(nameRow.givenName, nameRow.familyName).joinToString(" ").trim()
|
||||
}.ifEmpty { "(unnamed)" }
|
||||
return ContactRecord(
|
||||
identifier = contactId.toString(),
|
||||
displayName = displayName,
|
||||
givenName = nameRow.givenName.orEmpty(),
|
||||
familyName = nameRow.familyName.orEmpty(),
|
||||
organizationName = organization.orEmpty(),
|
||||
phoneNumbers = phones,
|
||||
emails = emails,
|
||||
)
|
||||
}
|
||||
|
||||
private data class NameRow(
|
||||
val givenName: String?,
|
||||
val familyName: String?,
|
||||
val displayName: String?,
|
||||
)
|
||||
|
||||
private fun loadNameRow(resolver: ContentResolver, contactId: Long): NameRow {
|
||||
val projection =
|
||||
arrayOf(
|
||||
ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME,
|
||||
ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME,
|
||||
ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME,
|
||||
)
|
||||
resolver.query(
|
||||
ContactsContract.Data.CONTENT_URI,
|
||||
projection,
|
||||
"${ContactsContract.Data.CONTACT_ID}=? AND ${ContactsContract.Data.MIMETYPE}=?",
|
||||
arrayOf(
|
||||
contactId.toString(),
|
||||
ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE,
|
||||
),
|
||||
null,
|
||||
).use { cursor ->
|
||||
if (cursor == null || !cursor.moveToFirst()) {
|
||||
return NameRow(givenName = null, familyName = null, displayName = null)
|
||||
}
|
||||
val given = cursor.getString(0)?.trim()?.ifEmpty { null }
|
||||
val family = cursor.getString(1)?.trim()?.ifEmpty { null }
|
||||
val display = cursor.getString(2)?.trim()?.ifEmpty { null }
|
||||
return NameRow(givenName = given, familyName = family, displayName = display)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadOrganization(resolver: ContentResolver, contactId: Long): String? {
|
||||
val projection = arrayOf(ContactsContract.CommonDataKinds.Organization.COMPANY)
|
||||
resolver.query(
|
||||
ContactsContract.Data.CONTENT_URI,
|
||||
projection,
|
||||
"${ContactsContract.Data.CONTACT_ID}=? AND ${ContactsContract.Data.MIMETYPE}=?",
|
||||
arrayOf(contactId.toString(), ContactsContract.CommonDataKinds.Organization.CONTENT_ITEM_TYPE),
|
||||
null,
|
||||
).use { cursor ->
|
||||
if (cursor == null || !cursor.moveToFirst()) return null
|
||||
return cursor.getString(0)?.trim()?.ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadPhones(resolver: ContentResolver, contactId: Long): List<String> {
|
||||
return queryContactValues(
|
||||
resolver = resolver,
|
||||
contentUri = ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
|
||||
valueColumn = ContactsContract.CommonDataKinds.Phone.NUMBER,
|
||||
contactIdColumn = ContactsContract.CommonDataKinds.Phone.CONTACT_ID,
|
||||
contactId = contactId,
|
||||
)
|
||||
}
|
||||
|
||||
private fun loadEmails(resolver: ContentResolver, contactId: Long): List<String> {
|
||||
return queryContactValues(
|
||||
resolver = resolver,
|
||||
contentUri = ContactsContract.CommonDataKinds.Email.CONTENT_URI,
|
||||
valueColumn = ContactsContract.CommonDataKinds.Email.ADDRESS,
|
||||
contactIdColumn = ContactsContract.CommonDataKinds.Email.CONTACT_ID,
|
||||
contactId = contactId,
|
||||
)
|
||||
}
|
||||
|
||||
private fun queryContactValues(
|
||||
resolver: ContentResolver,
|
||||
contentUri: android.net.Uri,
|
||||
valueColumn: String,
|
||||
contactIdColumn: String,
|
||||
contactId: Long,
|
||||
): List<String> {
|
||||
val projection = arrayOf(valueColumn)
|
||||
resolver.query(
|
||||
contentUri,
|
||||
projection,
|
||||
"$contactIdColumn=?",
|
||||
arrayOf(contactId.toString()),
|
||||
null,
|
||||
).use { cursor ->
|
||||
if (cursor == null) return emptyList()
|
||||
val out = LinkedHashSet<String>()
|
||||
while (cursor.moveToNext()) {
|
||||
val value = cursor.getString(0)?.trim().orEmpty()
|
||||
if (value.isNotEmpty()) out += value
|
||||
}
|
||||
return out.toList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ContactsHandler private constructor(
|
||||
private val appContext: Context,
|
||||
private val dataSource: ContactsDataSource,
|
||||
) {
|
||||
constructor(appContext: Context) : this(appContext = appContext, dataSource = SystemContactsDataSource)
|
||||
|
||||
fun handleContactsSearch(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
if (!dataSource.hasReadPermission(appContext)) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "CONTACTS_PERMISSION_REQUIRED",
|
||||
message = "CONTACTS_PERMISSION_REQUIRED: grant Contacts permission",
|
||||
)
|
||||
}
|
||||
val request =
|
||||
parseSearchRequest(paramsJson)
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: expected JSON object",
|
||||
)
|
||||
return try {
|
||||
val contacts = dataSource.search(appContext, request)
|
||||
GatewaySession.InvokeResult.ok(
|
||||
buildJsonObject {
|
||||
put(
|
||||
"contacts",
|
||||
buildJsonArray {
|
||||
contacts.forEach { add(contactJson(it)) }
|
||||
},
|
||||
)
|
||||
}.toString(),
|
||||
)
|
||||
} catch (err: Throwable) {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "CONTACTS_UNAVAILABLE",
|
||||
message = "CONTACTS_UNAVAILABLE: ${err.message ?: "contacts query failed"}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun handleContactsAdd(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
if (!dataSource.hasWritePermission(appContext)) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "CONTACTS_PERMISSION_REQUIRED",
|
||||
message = "CONTACTS_PERMISSION_REQUIRED: grant Contacts permission",
|
||||
)
|
||||
}
|
||||
val request =
|
||||
parseAddRequest(paramsJson)
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: expected JSON object",
|
||||
)
|
||||
val hasName =
|
||||
!(request.givenName.isNullOrEmpty() && request.familyName.isNullOrEmpty() && request.displayName.isNullOrEmpty())
|
||||
val hasOrg = !request.organizationName.isNullOrEmpty()
|
||||
val hasDetails = request.phoneNumbers.isNotEmpty() || request.emails.isNotEmpty()
|
||||
if (!hasName && !hasOrg && !hasDetails) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "CONTACTS_INVALID",
|
||||
message = "CONTACTS_INVALID: include a name, organization, phone, or email",
|
||||
)
|
||||
}
|
||||
return try {
|
||||
val contact = dataSource.add(appContext, request)
|
||||
GatewaySession.InvokeResult.ok(
|
||||
buildJsonObject {
|
||||
put("contact", contactJson(contact))
|
||||
}.toString(),
|
||||
)
|
||||
} catch (err: Throwable) {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "CONTACTS_UNAVAILABLE",
|
||||
message = "CONTACTS_UNAVAILABLE: ${err.message ?: "contact add failed"}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseSearchRequest(paramsJson: String?): ContactsSearchRequest? {
|
||||
if (paramsJson.isNullOrBlank()) {
|
||||
return ContactsSearchRequest(query = null, limit = DEFAULT_CONTACTS_LIMIT)
|
||||
}
|
||||
val params =
|
||||
try {
|
||||
Json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} ?: return null
|
||||
val query = (params["query"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null }
|
||||
val limit = ((params["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: DEFAULT_CONTACTS_LIMIT).coerceIn(1, 200)
|
||||
return ContactsSearchRequest(query = query, limit = limit)
|
||||
}
|
||||
|
||||
private fun parseAddRequest(paramsJson: String?): ContactsAddRequest? {
|
||||
val params =
|
||||
try {
|
||||
paramsJson?.let { Json.parseToJsonElement(it).asObjectOrNull() }
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} ?: return null
|
||||
return ContactsAddRequest(
|
||||
givenName = (params["givenName"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
|
||||
familyName = (params["familyName"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
|
||||
organizationName = (params["organizationName"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
|
||||
displayName = (params["displayName"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
|
||||
phoneNumbers = stringArray(params["phoneNumbers"] as? JsonArray),
|
||||
emails = stringArray(params["emails"] as? JsonArray).map { it.lowercase() },
|
||||
)
|
||||
}
|
||||
|
||||
private fun stringArray(array: JsonArray?): List<String> {
|
||||
if (array == null) return emptyList()
|
||||
return array.mapNotNull { element ->
|
||||
(element as? JsonPrimitive)?.content?.trim()?.ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
private fun contactJson(contact: ContactRecord): JsonObject {
|
||||
return buildJsonObject {
|
||||
put("identifier", JsonPrimitive(contact.identifier))
|
||||
put("displayName", JsonPrimitive(contact.displayName))
|
||||
put("givenName", JsonPrimitive(contact.givenName))
|
||||
put("familyName", JsonPrimitive(contact.familyName))
|
||||
put("organizationName", JsonPrimitive(contact.organizationName))
|
||||
put("phoneNumbers", buildJsonArray { contact.phoneNumbers.forEach { add(JsonPrimitive(it)) } })
|
||||
put("emails", buildJsonArray { contact.emails.forEach { add(JsonPrimitive(it)) } })
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal fun forTesting(
|
||||
appContext: Context,
|
||||
dataSource: ContactsDataSource,
|
||||
): ContactsHandler = ContactsHandler(appContext = appContext, dataSource = dataSource)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user