Compare commits
No commits in common. "main" and "pr17" have entirely different histories.
@ -1,366 +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,30 +0,0 @@
|
|||||||
# detect-secrets exclusion patterns (regex)
|
|
||||||
#
|
|
||||||
# Note: detect-secrets does not read this file by default. If you want these
|
|
||||||
# applied, wire them into your scan command (e.g. translate to --exclude-files
|
|
||||||
# / --exclude-lines) or into a baseline's filters_used.
|
|
||||||
|
|
||||||
[exclude-files]
|
|
||||||
# pnpm lockfiles contain lots of high-entropy package integrity blobs.
|
|
||||||
pattern = (^|/)pnpm-lock\.yaml$
|
|
||||||
# Generated output and vendored assets.
|
|
||||||
pattern = (^|/)(dist|vendor)/
|
|
||||||
# Local config file with allowlist patterns.
|
|
||||||
pattern = (^|/)\.detect-secrets\.cfg$
|
|
||||||
|
|
||||||
[exclude-lines]
|
|
||||||
# Fastlane checks for private key marker; not a real key.
|
|
||||||
pattern = key_content\.include\?\("BEGIN PRIVATE KEY"\)
|
|
||||||
# UI label string for Anthropic auth mode.
|
|
||||||
pattern = case \.apiKeyEnv: "API key \(env var\)"
|
|
||||||
# CodingKeys mapping uses apiKey literal.
|
|
||||||
pattern = case apikey = "apiKey"
|
|
||||||
# Schema labels referencing password fields (not actual secrets).
|
|
||||||
pattern = "gateway\.remote\.password"
|
|
||||||
pattern = "gateway\.auth\.password"
|
|
||||||
# Schema label for talk API key (label text only).
|
|
||||||
pattern = "talk\.apiKey"
|
|
||||||
# checking for typeof is not something we care about.
|
|
||||||
pattern = === "string"
|
|
||||||
# specific optional-chaining password check that didn't match the line above.
|
|
||||||
pattern = typeof remote\?\.password === "string"
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
.git
|
|
||||||
.worktrees
|
|
||||||
.bun-cache
|
|
||||||
.bun
|
|
||||||
.tmp
|
|
||||||
**/.tmp
|
|
||||||
.DS_Store
|
|
||||||
**/.DS_Store
|
|
||||||
*.png
|
|
||||||
*.jpg
|
|
||||||
*.jpeg
|
|
||||||
*.webp
|
|
||||||
*.gif
|
|
||||||
*.mp4
|
|
||||||
*.mov
|
|
||||||
*.wav
|
|
||||||
*.mp3
|
|
||||||
node_modules
|
|
||||||
**/node_modules
|
|
||||||
.pnpm-store
|
|
||||||
**/.pnpm-store
|
|
||||||
.turbo
|
|
||||||
**/.turbo
|
|
||||||
.cache
|
|
||||||
**/.cache
|
|
||||||
.next
|
|
||||||
**/.next
|
|
||||||
coverage
|
|
||||||
**/coverage
|
|
||||||
*.log
|
|
||||||
tmp
|
|
||||||
**/tmp
|
|
||||||
|
|
||||||
# build artifacts
|
|
||||||
dist
|
|
||||||
**/dist
|
|
||||||
apps/macos/.build
|
|
||||||
apps/ios/build
|
|
||||||
**/*.trace
|
|
||||||
|
|
||||||
# large app trees not needed for CLI build
|
|
||||||
apps/
|
|
||||||
assets/
|
|
||||||
Peekaboo/
|
|
||||||
Swabble/
|
|
||||||
Core/
|
|
||||||
Users/
|
|
||||||
vendor/
|
|
||||||
1
.gitattributes
vendored
1
.gitattributes
vendored
@ -1 +0,0 @@
|
|||||||
* text=auto eol=lf
|
|
||||||
1
.github/FUNDING.yml
vendored
1
.github/FUNDING.yml
vendored
@ -1 +0,0 @@
|
|||||||
custom: ['https://github.com/sponsors/steipete']
|
|
||||||
28
.github/ISSUE_TEMPLATE/bug_report.md
vendored
28
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -1,28 +0,0 @@
|
|||||||
---
|
|
||||||
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).
|
|
||||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
8
.github/ISSUE_TEMPLATE/config.yml
vendored
@ -1,8 +0,0 @@
|
|||||||
blank_issues_enabled: true
|
|
||||||
contact_links:
|
|
||||||
- name: Onboarding
|
|
||||||
url: https://discord.gg/clawd
|
|
||||||
about: New to Clawdbot? Join Discord for setup guidance from Krill in #help.
|
|
||||||
- name: Support
|
|
||||||
url: https://discord.gg/clawd
|
|
||||||
about: Get help from Krill and the community on Discord in #help.
|
|
||||||
18
.github/ISSUE_TEMPLATE/feature_request.md
vendored
18
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@ -1,18 +0,0 @@
|
|||||||
---
|
|
||||||
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.
|
|
||||||
17
.github/actionlint.yaml
vendored
17
.github/actionlint.yaml
vendored
@ -1,17 +0,0 @@
|
|||||||
# actionlint configuration
|
|
||||||
# https://github.com/rhysd/actionlint/blob/main/docs/config.md
|
|
||||||
|
|
||||||
self-hosted-runner:
|
|
||||||
labels:
|
|
||||||
# Blacksmith CI runners
|
|
||||||
- blacksmith-4vcpu-ubuntu-2404
|
|
||||||
- blacksmith-4vcpu-windows-2025
|
|
||||||
|
|
||||||
# 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'
|
|
||||||
113
.github/dependabot.yml
vendored
113
.github/dependabot.yml
vendored
@ -1,113 +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
|
|
||||||
replaces-base: true
|
|
||||||
|
|
||||||
updates:
|
|
||||||
# npm dependencies (root)
|
|
||||||
- package-ecosystem: npm
|
|
||||||
directory: /
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
cooldown:
|
|
||||||
default-days: 7
|
|
||||||
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: weekly
|
|
||||||
cooldown:
|
|
||||||
default-days: 7
|
|
||||||
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: weekly
|
|
||||||
cooldown:
|
|
||||||
default-days: 7
|
|
||||||
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: weekly
|
|
||||||
cooldown:
|
|
||||||
default-days: 7
|
|
||||||
groups:
|
|
||||||
swift-deps:
|
|
||||||
patterns:
|
|
||||||
- "*"
|
|
||||||
update-types:
|
|
||||||
- minor
|
|
||||||
- patch
|
|
||||||
open-pull-requests-limit: 5
|
|
||||||
|
|
||||||
# Swift Package Manager - Swabble
|
|
||||||
- package-ecosystem: swift
|
|
||||||
directory: /Swabble
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
cooldown:
|
|
||||||
default-days: 7
|
|
||||||
groups:
|
|
||||||
swift-deps:
|
|
||||||
patterns:
|
|
||||||
- "*"
|
|
||||||
update-types:
|
|
||||||
- minor
|
|
||||||
- patch
|
|
||||||
open-pull-requests-limit: 5
|
|
||||||
|
|
||||||
# Gradle - Android app
|
|
||||||
- package-ecosystem: gradle
|
|
||||||
directory: /apps/android
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
cooldown:
|
|
||||||
default-days: 7
|
|
||||||
groups:
|
|
||||||
android-deps:
|
|
||||||
patterns:
|
|
||||||
- "*"
|
|
||||||
update-types:
|
|
||||||
- minor
|
|
||||||
- patch
|
|
||||||
open-pull-requests-limit: 5
|
|
||||||
222
.github/labeler.yml
vendored
222
.github/labeler.yml
vendored
@ -1,222 +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: 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: 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/**"
|
|
||||||
78
.github/workflows/auto-response.yml
vendored
78
.github/workflows/auto-response.yml
vendored
@ -1,78 +0,0 @@
|
|||||||
name: Auto response
|
|
||||||
|
|
||||||
on:
|
|
||||||
issues:
|
|
||||||
types: [labeled]
|
|
||||||
pull_request_target:
|
|
||||||
types: [labeled]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
issues: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
auto-response:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/create-github-app-token@v1
|
|
||||||
id: app-token
|
|
||||||
with:
|
|
||||||
app-id: "2729701"
|
|
||||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
|
||||||
- name: Handle labeled items
|
|
||||||
uses: actions/github-script@v7
|
|
||||||
with:
|
|
||||||
github-token: ${{ steps.app-token.outputs.token }}
|
|
||||||
script: |
|
|
||||||
// Labels prefixed with "r:" are auto-response triggers.
|
|
||||||
const rules = [
|
|
||||||
{
|
|
||||||
label: "r: skill",
|
|
||||||
close: true,
|
|
||||||
message:
|
|
||||||
"Thanks for the contribution! New skills should be published to Clawdhub 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://molt.bot/discord and ask in #help or #users-helping-users to resolve this, or follow the stuck FAQ at https://docs.molt.bot/help/faq#im-stuck-whats-the-fastest-way-to-get-unstuck.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "r: third-party-extension",
|
|
||||||
close: true,
|
|
||||||
message:
|
|
||||||
"This would be better made as a third-party extension with our SDK that you maintain yourself. Docs: https://docs.molt.bot/plugin.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const labelName = context.payload.label?.name;
|
|
||||||
if (!labelName) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rule = rules.find((item) => item.label === labelName);
|
|
||||||
if (!rule) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const issueNumber = context.payload.issue?.number ?? context.payload.pull_request?.number;
|
|
||||||
if (!issueNumber) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
622
.github/workflows/ci.yml
vendored
622
.github/workflows/ci.yml
vendored
@ -5,147 +5,32 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
install-check:
|
build:
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
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
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22.x
|
node-version: 22
|
||||||
check-latest: true
|
check-latest: true
|
||||||
|
|
||||||
- name: Setup pnpm (corepack retry)
|
- name: Node version
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
corepack enable
|
|
||||||
for attempt in 1 2 3; do
|
|
||||||
if corepack prepare pnpm@10.23.0 --activate; then
|
|
||||||
pnpm -v
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
|
|
||||||
sleep $((attempt * 10))
|
|
||||||
done
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
- name: Runtime versions
|
|
||||||
run: |
|
run: |
|
||||||
node -v
|
node -v
|
||||||
npm -v
|
npm -v
|
||||||
pnpm -v
|
|
||||||
|
|
||||||
- name: Capture node path
|
- name: Capture node path
|
||||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Install dependencies (frozen)
|
- name: Enable corepack and pin pnpm
|
||||||
env:
|
|
||||||
CI: true
|
|
||||||
run: |
|
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 canvas:a2ui:bundle && 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: pnpm canvas:a2ui:bundle && 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 pnpm (corepack retry)
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
corepack enable
|
corepack enable
|
||||||
for attempt in 1 2 3; do
|
corepack prepare pnpm@10.23.0 --activate
|
||||||
if corepack prepare pnpm@10.23.0 --activate; then
|
|
||||||
pnpm -v
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
|
|
||||||
sleep $((attempt * 10))
|
|
||||||
done
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
- name: Setup Bun
|
|
||||||
uses: oven-sh/setup-bun@v2
|
|
||||||
with:
|
|
||||||
bun-version: latest
|
|
||||||
|
|
||||||
- name: Runtime versions
|
|
||||||
run: |
|
|
||||||
node -v
|
|
||||||
npm -v
|
|
||||||
bun -v
|
|
||||||
pnpm -v
|
pnpm -v
|
||||||
|
|
||||||
- name: Capture node path
|
|
||||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
env:
|
env:
|
||||||
CI: true
|
CI: true
|
||||||
@ -154,494 +39,13 @@ jobs:
|
|||||||
which node
|
which node
|
||||||
node -v
|
node -v
|
||||||
pnpm -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
|
pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||||
|
|
||||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
- name: Lint
|
||||||
run: ${{ matrix.command }}
|
run: pnpm lint
|
||||||
|
|
||||||
secrets:
|
- name: Test
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
run: pnpm test
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: false
|
|
||||||
|
|
||||||
- name: Setup Python
|
- name: Build
|
||||||
uses: actions/setup-python@v5
|
run: pnpm build
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
- 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
|
|
||||||
env:
|
|
||||||
NODE_OPTIONS: --max-old-space-size=4096
|
|
||||||
CLAWDBOT_TEST_WORKERS: 1
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: bash
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- runtime: node
|
|
||||||
task: lint
|
|
||||||
command: pnpm lint
|
|
||||||
- runtime: node
|
|
||||||
task: test
|
|
||||||
command: pnpm canvas:a2ui:bundle && 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 pnpm (corepack retry)
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
corepack enable
|
|
||||||
for attempt in 1 2 3; do
|
|
||||||
if corepack prepare pnpm@10.23.0 --activate; then
|
|
||||||
pnpm -v
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
|
|
||||||
sleep $((attempt * 10))
|
|
||||||
done
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
- name: Setup Bun
|
|
||||||
uses: oven-sh/setup-bun@v2
|
|
||||||
with:
|
|
||||||
bun-version: latest
|
|
||||||
|
|
||||||
- name: Runtime versions
|
|
||||||
run: |
|
|
||||||
node -v
|
|
||||||
npm -v
|
|
||||||
bun -v
|
|
||||||
pnpm -v
|
|
||||||
|
|
||||||
- name: Capture node path
|
|
||||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- 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 }} (${{ 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: Setup pnpm (corepack retry)
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
corepack enable
|
|
||||||
for attempt in 1 2 3; do
|
|
||||||
if corepack prepare pnpm@10.23.0 --activate; then
|
|
||||||
pnpm -v
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
|
|
||||||
sleep $((attempt * 10))
|
|
||||||
done
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
- 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: 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 }}
|
|
||||||
env:
|
|
||||||
NODE_OPTIONS: --max-old-space-size=4096
|
|
||||||
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
|
|
||||||
|
|
||||||
- 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
|
|
||||||
|
|
||||||
- name: Show toolchain
|
|
||||||
run: |
|
|
||||||
sw_vers
|
|
||||||
xcodebuild -version
|
|
||||||
swift --version
|
|
||||||
|
|
||||||
- name: Run ${{ matrix.task }}
|
|
||||||
run: ${{ matrix.command }}
|
|
||||||
ios:
|
|
||||||
if: false # ignore iOS in CI for now
|
|
||||||
runs-on: macos-latest
|
|
||||||
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: Select Xcode 26.1
|
|
||||||
run: |
|
|
||||||
sudo xcode-select -s /Applications/Xcode_26.1.app
|
|
||||||
xcodebuild -version
|
|
||||||
|
|
||||||
- name: Install XcodeGen
|
|
||||||
run: brew install xcodegen
|
|
||||||
|
|
||||||
- name: Install SwiftLint / SwiftFormat
|
|
||||||
run: brew install swiftlint swiftformat
|
|
||||||
|
|
||||||
- name: Show toolchain
|
|
||||||
run: |
|
|
||||||
sw_vers
|
|
||||||
xcodebuild -version
|
|
||||||
swift --version
|
|
||||||
|
|
||||||
- name: Generate iOS project
|
|
||||||
run: |
|
|
||||||
cd apps/ios
|
|
||||||
xcodegen generate
|
|
||||||
|
|
||||||
- name: iOS tests
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
RESULT_BUNDLE_PATH="$RUNNER_TEMP/Clawdis-iOS.xcresult"
|
|
||||||
DEST_ID="$(
|
|
||||||
python3 - <<'PY'
|
|
||||||
import json
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
def sh(args: list[str]) -> str:
|
|
||||||
return subprocess.check_output(args, text=True).strip()
|
|
||||||
|
|
||||||
# Prefer an already-created iPhone simulator if it exists.
|
|
||||||
devices = json.loads(sh(["xcrun", "simctl", "list", "devices", "-j"]))
|
|
||||||
candidates: list[tuple[str, str]] = []
|
|
||||||
for runtime, devs in (devices.get("devices") or {}).items():
|
|
||||||
for dev in devs or []:
|
|
||||||
if not dev.get("isAvailable"):
|
|
||||||
continue
|
|
||||||
name = str(dev.get("name") or "")
|
|
||||||
udid = str(dev.get("udid") or "")
|
|
||||||
if not udid or not name.startswith("iPhone"):
|
|
||||||
continue
|
|
||||||
candidates.append((name, udid))
|
|
||||||
|
|
||||||
candidates.sort(key=lambda it: (0 if "iPhone 16" in it[0] else 1, it[0]))
|
|
||||||
if candidates:
|
|
||||||
print(candidates[0][1])
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
# Otherwise, create one from the newest available iOS runtime.
|
|
||||||
runtimes = json.loads(sh(["xcrun", "simctl", "list", "runtimes", "-j"])).get("runtimes") or []
|
|
||||||
ios = [rt for rt in runtimes if rt.get("platform") == "iOS" and rt.get("isAvailable")]
|
|
||||||
if not ios:
|
|
||||||
print("No available iOS runtimes found.", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
def version_key(rt: dict) -> tuple[int, ...]:
|
|
||||||
parts: list[int] = []
|
|
||||||
for p in str(rt.get("version") or "0").split("."):
|
|
||||||
try:
|
|
||||||
parts.append(int(p))
|
|
||||||
except ValueError:
|
|
||||||
parts.append(0)
|
|
||||||
return tuple(parts)
|
|
||||||
|
|
||||||
ios.sort(key=version_key, reverse=True)
|
|
||||||
runtime = ios[0]
|
|
||||||
runtime_id = str(runtime.get("identifier") or "")
|
|
||||||
if not runtime_id:
|
|
||||||
print("Missing iOS runtime identifier.", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
supported = runtime.get("supportedDeviceTypes") or []
|
|
||||||
iphones = [dt for dt in supported if dt.get("productFamily") == "iPhone"]
|
|
||||||
if not iphones:
|
|
||||||
print("No iPhone device types for iOS runtime.", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
iphones.sort(
|
|
||||||
key=lambda dt: (
|
|
||||||
0 if "iPhone 16" in str(dt.get("name") or "") else 1,
|
|
||||||
str(dt.get("name") or ""),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
device_type_id = str(iphones[0].get("identifier") or "")
|
|
||||||
if not device_type_id:
|
|
||||||
print("Missing iPhone device type identifier.", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
sim_name = f"CI iPhone {uuid.uuid4().hex[:8]}"
|
|
||||||
udid = sh(["xcrun", "simctl", "create", sim_name, device_type_id, runtime_id])
|
|
||||||
if not udid:
|
|
||||||
print("Failed to create iPhone simulator.", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
print(udid)
|
|
||||||
PY
|
|
||||||
)"
|
|
||||||
echo "Using iOS Simulator id: $DEST_ID"
|
|
||||||
xcodebuild test \
|
|
||||||
-project apps/ios/Clawdis.xcodeproj \
|
|
||||||
-scheme Clawdis \
|
|
||||||
-destination "platform=iOS Simulator,id=$DEST_ID" \
|
|
||||||
-resultBundlePath "$RESULT_BUNDLE_PATH" \
|
|
||||||
-enableCodeCoverage YES
|
|
||||||
|
|
||||||
- name: iOS coverage summary
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
RESULT_BUNDLE_PATH="$RUNNER_TEMP/Clawdis-iOS.xcresult"
|
|
||||||
xcrun xccov view --report --only-targets "$RESULT_BUNDLE_PATH"
|
|
||||||
|
|
||||||
- name: iOS coverage gate (43%)
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
RESULT_BUNDLE_PATH="$RUNNER_TEMP/Clawdis-iOS.xcresult"
|
|
||||||
RESULT_BUNDLE_PATH="$RESULT_BUNDLE_PATH" python3 - <<'PY'
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
|
|
||||||
target_name = "Clawdis.app"
|
|
||||||
minimum = 0.43
|
|
||||||
|
|
||||||
report = json.loads(
|
|
||||||
subprocess.check_output(
|
|
||||||
["xcrun", "xccov", "view", "--report", "--json", os.environ["RESULT_BUNDLE_PATH"]],
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
target_coverage = None
|
|
||||||
for target in report.get("targets", []):
|
|
||||||
if target.get("name") == target_name:
|
|
||||||
target_coverage = float(target["lineCoverage"])
|
|
||||||
break
|
|
||||||
|
|
||||||
if target_coverage is None:
|
|
||||||
print(f"Could not find coverage for target: {target_name}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print(f"{target_name} line coverage: {target_coverage * 100:.2f}% (min {minimum * 100:.2f}%)")
|
|
||||||
if target_coverage + 1e-12 < minimum:
|
|
||||||
sys.exit(1)
|
|
||||||
PY
|
|
||||||
|
|
||||||
android:
|
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- task: test
|
|
||||||
command: ./gradlew --no-daemon :app:testDebugUnitTest
|
|
||||||
- task: build
|
|
||||||
command: ./gradlew --no-daemon :app:assembleDebug
|
|
||||||
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 Java
|
|
||||||
uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: 21
|
|
||||||
|
|
||||||
- name: Setup Android SDK
|
|
||||||
uses: android-actions/setup-android@v3
|
|
||||||
with:
|
|
||||||
accept-android-sdk-licenses: false
|
|
||||||
|
|
||||||
- name: Setup Gradle
|
|
||||||
uses: gradle/actions/setup-gradle@v4
|
|
||||||
with:
|
|
||||||
gradle-version: 8.11.1
|
|
||||||
|
|
||||||
- name: Install Android SDK packages
|
|
||||||
run: |
|
|
||||||
yes | sdkmanager --licenses >/dev/null
|
|
||||||
sdkmanager --install \
|
|
||||||
"platform-tools" \
|
|
||||||
"platforms;android-36" \
|
|
||||||
"build-tools;36.0.0"
|
|
||||||
|
|
||||||
- name: Run Android ${{ matrix.task }}
|
|
||||||
working-directory: apps/android
|
|
||||||
run: ${{ matrix.command }}
|
|
||||||
|
|||||||
143
.github/workflows/docker-release.yml
vendored
143
.github/workflows/docker-release.yml
vendored
@ -1,143 +0,0 @@
|
|||||||
name: Docker Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
tags:
|
|
||||||
- "v*"
|
|
||||||
|
|
||||||
env:
|
|
||||||
REGISTRY: ghcr.io
|
|
||||||
IMAGE_NAME: ${{ github.repository }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
# Build amd64 image
|
|
||||||
build-amd64:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
packages: write
|
|
||||||
contents: read
|
|
||||||
outputs:
|
|
||||||
image-digest: ${{ steps.build.outputs.digest }}
|
|
||||||
image-metadata: ${{ steps.meta.outputs.json }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
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: Extract metadata
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
|
||||||
tags: |
|
|
||||||
type=ref,event=branch
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{version}},suffix=-amd64
|
|
||||||
type=semver,pattern={{version}},suffix=-arm64
|
|
||||||
type=ref,event=branch,suffix=-amd64
|
|
||||||
type=ref,event=branch,suffix=-arm64
|
|
||||||
|
|
||||||
- name: Build and push amd64 image
|
|
||||||
id: build
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
platforms: linux/amd64
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
provenance: false
|
|
||||||
push: true
|
|
||||||
|
|
||||||
# Build arm64 image
|
|
||||||
build-arm64:
|
|
||||||
runs-on: ubuntu-24.04-arm
|
|
||||||
permissions:
|
|
||||||
packages: write
|
|
||||||
contents: read
|
|
||||||
outputs:
|
|
||||||
image-digest: ${{ steps.build.outputs.digest }}
|
|
||||||
image-metadata: ${{ steps.meta.outputs.json }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
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: Extract metadata
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
|
||||||
tags: |
|
|
||||||
type=ref,event=branch
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{version}},suffix=-amd64
|
|
||||||
type=semver,pattern={{version}},suffix=-arm64
|
|
||||||
type=ref,event=branch,suffix=-amd64
|
|
||||||
type=ref,event=branch,suffix=-arm64
|
|
||||||
|
|
||||||
- name: Build and push arm64 image
|
|
||||||
id: build
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
platforms: linux/arm64
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
provenance: false
|
|
||||||
push: true
|
|
||||||
|
|
||||||
# Create multi-platform manifest
|
|
||||||
create-manifest:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
packages: write
|
|
||||||
contents: read
|
|
||||||
needs: [build-amd64, build-arm64]
|
|
||||||
steps:
|
|
||||||
- name: Login to GitHub Container Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.REGISTRY }}
|
|
||||||
username: ${{ github.repository_owner }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Extract metadata for manifest
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
|
||||||
tags: |
|
|
||||||
type=ref,event=branch
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
|
|
||||||
- name: Create and push manifest
|
|
||||||
run: |
|
|
||||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
|
||||||
${{ needs.build-amd64.outputs.image-digest }} \
|
|
||||||
${{ needs.build-arm64.outputs.image-digest }}
|
|
||||||
env:
|
|
||||||
DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta.outputs.json }}
|
|
||||||
41
.github/workflows/install-smoke.yml
vendored
41
.github/workflows/install-smoke.yml
vendored
@ -1,41 +0,0 @@
|
|||||||
name: Install Smoke
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
install-smoke:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout CLI
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup pnpm (corepack retry)
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
corepack enable
|
|
||||||
for attempt in 1 2 3; do
|
|
||||||
if corepack prepare pnpm@10.23.0 --activate; then
|
|
||||||
pnpm -v
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
|
|
||||||
sleep $((attempt * 10))
|
|
||||||
done
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
- 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_NO_ONBOARD: "1"
|
|
||||||
CLAWDBOT_INSTALL_SMOKE_SKIP_CLI: "1"
|
|
||||||
CLAWDBOT_INSTALL_SMOKE_SKIP_NONROOT: ${{ github.event_name == 'pull_request' && '1' || '0' }}
|
|
||||||
CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS: "1"
|
|
||||||
run: pnpm test:install:smoke
|
|
||||||
24
.github/workflows/labeler.yml
vendored
24
.github/workflows/labeler.yml
vendored
@ -1,24 +0,0 @@
|
|||||||
name: Labeler
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request_target:
|
|
||||||
types: [opened, synchronize, reopened]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
label:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/create-github-app-token@v1
|
|
||||||
id: app-token
|
|
||||||
with:
|
|
||||||
app-id: "2729701"
|
|
||||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
|
||||||
- uses: actions/labeler@v5
|
|
||||||
with:
|
|
||||||
configuration-path: .github/labeler.yml
|
|
||||||
repo-token: ${{ steps.app-token.outputs.token }}
|
|
||||||
sync-labels: true
|
|
||||||
37
.github/workflows/workflow-sanity.yml
vendored
37
.github/workflows/workflow-sanity.yml
vendored
@ -1,37 +0,0 @@
|
|||||||
name: Workflow Sanity
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
push:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
no-tabs:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Fail on tabs in workflow files
|
|
||||||
run: |
|
|
||||||
python - <<'PY'
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pathlib
|
|
||||||
import sys
|
|
||||||
|
|
||||||
root = pathlib.Path(".github/workflows")
|
|
||||||
bad: list[str] = []
|
|
||||||
for path in sorted(root.rglob("*.yml")):
|
|
||||||
if b"\t" in path.read_bytes():
|
|
||||||
bad.append(str(path))
|
|
||||||
|
|
||||||
for path in sorted(root.rglob("*.yaml")):
|
|
||||||
if b"\t" in path.read_bytes():
|
|
||||||
bad.append(str(path))
|
|
||||||
|
|
||||||
if bad:
|
|
||||||
print("Tabs found in workflow file(s):")
|
|
||||||
for path in bad:
|
|
||||||
print(f"- {path}")
|
|
||||||
sys.exit(1)
|
|
||||||
PY
|
|
||||||
67
.gitignore
vendored
67
.gitignore
vendored
@ -1,73 +1,6 @@
|
|||||||
node_modules
|
node_modules
|
||||||
**/node_modules/
|
|
||||||
.env
|
.env
|
||||||
docker-compose.extra.yml
|
|
||||||
dist
|
dist
|
||||||
*.bun-build
|
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
bun.lock
|
|
||||||
bun.lockb
|
|
||||||
coverage
|
coverage
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
.worktrees/
|
|
||||||
.DS_Store
|
|
||||||
**/.DS_Store
|
|
||||||
ui/src/ui/__screenshots__/
|
|
||||||
ui/playwright-report/
|
|
||||||
ui/test-results/
|
|
||||||
|
|
||||||
# Bun build artifacts
|
|
||||||
*.bun-build
|
|
||||||
apps/macos/.build/
|
|
||||||
apps/shared/MoltbotKit/.build/
|
|
||||||
**/ModuleCache/
|
|
||||||
bin/
|
|
||||||
bin/clawdbot-mac
|
|
||||||
bin/docs-list
|
|
||||||
apps/macos/.build-local/
|
|
||||||
apps/macos/.swiftpm/
|
|
||||||
apps/shared/MoltbotKit/.swiftpm/
|
|
||||||
Core/
|
|
||||||
apps/ios/*.xcodeproj/
|
|
||||||
apps/ios/*.xcworkspace/
|
|
||||||
apps/ios/.swiftpm/
|
|
||||||
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)
|
|
||||||
apps/ios/fastlane/README.md
|
|
||||||
apps/ios/fastlane/report.xml
|
|
||||||
apps/ios/fastlane/Preview.html
|
|
||||||
apps/ios/fastlane/screenshots/
|
|
||||||
apps/ios/fastlane/test_output/
|
|
||||||
apps/ios/fastlane/logs/
|
|
||||||
apps/ios/fastlane/.env
|
|
||||||
apps/ios/fastlane/report.xml
|
|
||||||
|
|
||||||
# fastlane build artifacts (local)
|
|
||||||
apps/ios/*.ipa
|
|
||||||
apps/ios/*.dSYM.zip
|
|
||||||
|
|
||||||
# provisioning profiles (local)
|
|
||||||
apps/ios/*.mobileprovision
|
|
||||||
.env
|
|
||||||
|
|
||||||
# Local untracked files
|
|
||||||
.local/
|
|
||||||
.vscode/
|
|
||||||
IDENTITY.md
|
|
||||||
USER.md
|
|
||||||
.tgz
|
|
||||||
|
|
||||||
# local tooling
|
|
||||||
.serena/
|
|
||||||
|
|||||||
2
.npmrc
2
.npmrc
@ -1 +1 @@
|
|||||||
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty,@lydell/node-pty,@matrix-org/matrix-sdk-crypto-nodejs
|
allow-build-scripts=@whiskeysockets/baileys,sharp
|
||||||
|
|||||||
@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
|
||||||
"indentWidth": 2,
|
|
||||||
"printWidth": 100
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
|
||||||
"plugins": [
|
|
||||||
"unicorn",
|
|
||||||
"typescript",
|
|
||||||
"oxc"
|
|
||||||
],
|
|
||||||
"categories": {
|
|
||||||
"correctness": "error"
|
|
||||||
},
|
|
||||||
"ignorePatterns": ["src/canvas-host/a2ui/a2ui.bundle.js"]
|
|
||||||
}
|
|
||||||
@ -1,105 +0,0 @@
|
|||||||
# Pre-commit hooks for clawdbot
|
|
||||||
# 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
|
|
||||||
|
|
||||||
# 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
|
|
||||||
- '(^|/)(dist/|vendor/|pnpm-lock\.yaml$|\.detect-secrets\.cfg$)'
|
|
||||||
- --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"'
|
|
||||||
|
|
||||||
# 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/)'
|
|
||||||
|
|
||||||
# Project checks (same commands as CI)
|
|
||||||
- repo: local
|
|
||||||
hooks:
|
|
||||||
# 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]
|
|
||||||
@ -1 +0,0 @@
|
|||||||
src/canvas-host/a2ui/a2ui.bundle.js
|
|
||||||
2191
.secrets.baseline
2191
.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
|
|
||||||
51
.swiftformat
51
.swiftformat
@ -1,51 +0,0 @@
|
|||||||
# SwiftFormat configuration adapted from Peekaboo defaults (Swift 6 friendly)
|
|
||||||
|
|
||||||
--swiftversion 6.2
|
|
||||||
|
|
||||||
# Self handling
|
|
||||||
--self insert
|
|
||||||
--selfrequired
|
|
||||||
|
|
||||||
# Imports / extensions
|
|
||||||
--importgrouping testable-bottom
|
|
||||||
--extensionacl on-declarations
|
|
||||||
|
|
||||||
# Indentation
|
|
||||||
--indent 4
|
|
||||||
--indentcase false
|
|
||||||
--ifdef no-indent
|
|
||||||
--xcodeindentation enabled
|
|
||||||
|
|
||||||
# Line breaks
|
|
||||||
--linebreaks lf
|
|
||||||
--maxwidth 120
|
|
||||||
|
|
||||||
# Whitespace
|
|
||||||
--trimwhitespace always
|
|
||||||
--emptybraces no-space
|
|
||||||
--nospaceoperators ...,..<
|
|
||||||
--ranges no-space
|
|
||||||
--someAny true
|
|
||||||
--voidtype void
|
|
||||||
|
|
||||||
# Wrapping
|
|
||||||
--wraparguments before-first
|
|
||||||
--wrapparameters before-first
|
|
||||||
--wrapcollections before-first
|
|
||||||
--closingparen same-line
|
|
||||||
|
|
||||||
# Organization
|
|
||||||
--organizetypes class,struct,enum,extension
|
|
||||||
--extensionmark "MARK: - %t + %p"
|
|
||||||
--marktypes always
|
|
||||||
--markextensions always
|
|
||||||
--structthreshold 0
|
|
||||||
--enumthreshold 0
|
|
||||||
|
|
||||||
# Other
|
|
||||||
--stripunusedargs closure-only
|
|
||||||
--header ignore
|
|
||||||
--allman false
|
|
||||||
|
|
||||||
# Exclusions
|
|
||||||
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Peekaboo,Swabble,apps/android,apps/ios,apps/shared,apps/macos/Sources/MoltbotProtocol
|
|
||||||
148
.swiftlint.yml
148
.swiftlint.yml
@ -1,148 +0,0 @@
|
|||||||
# SwiftLint configuration adapted from Peekaboo defaults (Swift 6 friendly)
|
|
||||||
|
|
||||||
included:
|
|
||||||
- apps/macos/Sources
|
|
||||||
|
|
||||||
excluded:
|
|
||||||
- .build
|
|
||||||
- DerivedData
|
|
||||||
- "**/.build"
|
|
||||||
- "**/.swiftpm"
|
|
||||||
- "**/DerivedData"
|
|
||||||
- "**/Generated"
|
|
||||||
- "**/Resources"
|
|
||||||
- "**/Package.swift"
|
|
||||||
- "**/Tests/Resources"
|
|
||||||
- node_modules
|
|
||||||
- dist
|
|
||||||
- coverage
|
|
||||||
- "*.playground"
|
|
||||||
# Generated (protocol-gen-swift.ts)
|
|
||||||
- apps/macos/Sources/MoltbotProtocol/GatewayModels.swift
|
|
||||||
|
|
||||||
analyzer_rules:
|
|
||||||
- unused_declaration
|
|
||||||
- unused_import
|
|
||||||
|
|
||||||
opt_in_rules:
|
|
||||||
- array_init
|
|
||||||
- closure_spacing
|
|
||||||
- contains_over_first_not_nil
|
|
||||||
- empty_count
|
|
||||||
- empty_string
|
|
||||||
- explicit_init
|
|
||||||
- fallthrough
|
|
||||||
- fatal_error_message
|
|
||||||
- first_where
|
|
||||||
- joined_default_parameter
|
|
||||||
- last_where
|
|
||||||
- literal_expression_end_indentation
|
|
||||||
- multiline_arguments
|
|
||||||
- multiline_parameters
|
|
||||||
- operator_usage_whitespace
|
|
||||||
- overridden_super_call
|
|
||||||
- pattern_matching_keywords
|
|
||||||
- private_outlet
|
|
||||||
- prohibited_super_call
|
|
||||||
- redundant_nil_coalescing
|
|
||||||
- sorted_first_last
|
|
||||||
- switch_case_alignment
|
|
||||||
- unneeded_parentheses_in_closure_argument
|
|
||||||
- vertical_parameter_alignment_on_call
|
|
||||||
|
|
||||||
disabled_rules:
|
|
||||||
# SwiftFormat handles these
|
|
||||||
- trailing_whitespace
|
|
||||||
- trailing_newline
|
|
||||||
- trailing_comma
|
|
||||||
- vertical_whitespace
|
|
||||||
- indentation_width
|
|
||||||
|
|
||||||
# Style exclusions
|
|
||||||
- explicit_self
|
|
||||||
- identifier_name
|
|
||||||
- file_header
|
|
||||||
- explicit_top_level_acl
|
|
||||||
- explicit_acl
|
|
||||||
- explicit_type_interface
|
|
||||||
- missing_docs
|
|
||||||
- required_deinit
|
|
||||||
- prefer_nimble
|
|
||||||
- quick_discouraged_call
|
|
||||||
- quick_discouraged_focused_test
|
|
||||||
- quick_discouraged_pending_test
|
|
||||||
- anonymous_argument_in_multiline_closure
|
|
||||||
- no_extension_access_modifier
|
|
||||||
- no_grouping_extension
|
|
||||||
- switch_case_on_newline
|
|
||||||
- strict_fileprivate
|
|
||||||
- extension_access_modifier
|
|
||||||
- convenience_type
|
|
||||||
- no_magic_numbers
|
|
||||||
- one_declaration_per_file
|
|
||||||
- vertical_whitespace_between_cases
|
|
||||||
- vertical_whitespace_closing_braces
|
|
||||||
- superfluous_else
|
|
||||||
- number_separator
|
|
||||||
- prefixed_toplevel_constant
|
|
||||||
- opening_brace
|
|
||||||
- trailing_closure
|
|
||||||
- contrasted_opening_brace
|
|
||||||
- sorted_imports
|
|
||||||
- redundant_type_annotation
|
|
||||||
- shorthand_optional_binding
|
|
||||||
- untyped_error_in_catch
|
|
||||||
- file_name
|
|
||||||
- todo
|
|
||||||
|
|
||||||
force_cast: warning
|
|
||||||
force_try: warning
|
|
||||||
|
|
||||||
type_name:
|
|
||||||
min_length:
|
|
||||||
warning: 2
|
|
||||||
error: 1
|
|
||||||
max_length:
|
|
||||||
warning: 60
|
|
||||||
error: 80
|
|
||||||
|
|
||||||
function_body_length:
|
|
||||||
warning: 150
|
|
||||||
error: 300
|
|
||||||
|
|
||||||
function_parameter_count:
|
|
||||||
warning: 7
|
|
||||||
error: 10
|
|
||||||
|
|
||||||
file_length:
|
|
||||||
warning: 1500
|
|
||||||
error: 2500
|
|
||||||
ignore_comment_only_lines: true
|
|
||||||
|
|
||||||
type_body_length:
|
|
||||||
warning: 800
|
|
||||||
error: 1200
|
|
||||||
|
|
||||||
cyclomatic_complexity:
|
|
||||||
warning: 20
|
|
||||||
error: 120
|
|
||||||
|
|
||||||
large_tuple:
|
|
||||||
warning: 4
|
|
||||||
error: 5
|
|
||||||
|
|
||||||
nesting:
|
|
||||||
type_level:
|
|
||||||
warning: 4
|
|
||||||
error: 6
|
|
||||||
function_level:
|
|
||||||
warning: 5
|
|
||||||
error: 7
|
|
||||||
|
|
||||||
line_length:
|
|
||||||
warning: 120
|
|
||||||
error: 250
|
|
||||||
ignores_comments: true
|
|
||||||
ignores_urls: true
|
|
||||||
|
|
||||||
reporter: "xcode"
|
|
||||||
162
AGENTS.md
162
AGENTS.md
@ -1,163 +1,55 @@
|
|||||||
# Repository Guidelines
|
# Repository Guidelines
|
||||||
- Repo: https://github.com/openclaw/openclaw
|
|
||||||
- GitHub issues/comments/PR comments: use literal multiline strings or `-F - <<'EOF'` (or $'...') for real newlines; never embed "\\n".
|
|
||||||
|
|
||||||
## Project Structure & Module Organization
|
## 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`).
|
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, Twilio in `src/twilio`, Web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
|
||||||
- Tests: colocated `*.test.ts`.
|
- Tests: colocated `*.test.ts` plus e2e in `src/cli/relay.e2e.test.ts`.
|
||||||
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
|
- Docs: `docs/` (images, queue, Claude 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 `clawdbot/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, review `.github/labeler.yml` for label coverage.
|
|
||||||
|
|
||||||
## Docs Linking (Mintlify)
|
|
||||||
- Docs are hosted on Mintlify (docs.openclaw.ai).
|
|
||||||
- Internal doc links in `docs/**/*.md`: root-relative, no `.md`/`.mdx` (example: `[Config](/configuration)`).
|
|
||||||
- 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.
|
|
||||||
- Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”.
|
|
||||||
|
|
||||||
## 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
|
## Build, Test, and Development Commands
|
||||||
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
|
|
||||||
- Install deps: `pnpm install`
|
- Install deps: `pnpm install`
|
||||||
- Pre-commit hooks: `prek install` (runs same checks as CI)
|
- Run CLI in dev: `pnpm warelay ...` (tsx entry) or `pnpm dev` for `src/index.ts`.
|
||||||
- Also supported: `bun install` (keep `pnpm-lock.yaml` + Bun patching in sync when touching deps/patches).
|
|
||||||
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
|
|
||||||
- Run CLI in dev: `pnpm openclaw ...` (bun) or `pnpm dev`.
|
|
||||||
- Node remains supported for running built output (`dist/*`) and production installs.
|
|
||||||
- Mac packaging (dev): `scripts/package-mac-app.sh` defaults to current arch. Release checklist: `docs/platforms/mac/release.md`.
|
|
||||||
- Type-check/build: `pnpm build` (tsc)
|
- Type-check/build: `pnpm build` (tsc)
|
||||||
- Lint/format: `pnpm lint` (oxlint), `pnpm format` (oxfmt)
|
- Lint/format: `pnpm lint` (biome check), `pnpm format` (biome format)
|
||||||
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
|
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
|
||||||
|
|
||||||
## Coding Style & Naming Conventions
|
## Coding Style & Naming Conventions
|
||||||
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
|
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
|
||||||
- Formatting/linting via Oxlint and Oxfmt; run `pnpm lint` before commits.
|
- Formatting/linting via Biome; 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`.
|
- 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.
|
|
||||||
|
|
||||||
## 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).
|
|
||||||
- dev: moving head on `main` (no tag; git checkout main).
|
|
||||||
|
|
||||||
## Testing Guidelines
|
## Testing Guidelines
|
||||||
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
|
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
|
||||||
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
|
- 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.
|
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
|
||||||
- Do not set test workers above 16; tried already.
|
|
||||||
- Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (OpenClaw-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`.
|
|
||||||
- Full kit + what’s covered: `docs/testing.md`.
|
|
||||||
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
|
- 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
|
## Commit & Pull Request Guidelines
|
||||||
- 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`).
|
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
|
||||||
- Group related changes; avoid bundling unrelated refactors.
|
- Group related changes; avoid bundling unrelated refactors.
|
||||||
- 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.
|
- 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 review calls: prefer a single `gh pr view --json ...` to batch metadata/comments; run `gh pr diff` only when needed.
|
|
||||||
- Before starting a review when a GH Issue/PR is pasted: run `git pull`; if there are local changes or unpushed commits, stop and alert the user before reviewing.
|
|
||||||
- Goal: merge PRs. Prefer **rebase** when commits are clean; **squash** when history is messy.
|
|
||||||
- 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`: 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`.
|
|
||||||
|
|
||||||
### 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
|
## Security & Configuration Tips
|
||||||
- Web provider stores creds at `~/.clawdbot/credentials/`; rerun `openclaw login` if logged out.
|
- Environment: copy `.env.example`; set Twilio creds and WhatsApp sender (`TWILIO_WHATSAPP_FROM`).
|
||||||
- Pi sessions live under `~/.clawdbot/sessions/` by default; the base directory is not configurable.
|
- Web provider stores creds at `~/.warelay/credentials/`; rerun `warelay login` if logged out.
|
||||||
- Environment variables: see `~/.profile`.
|
- Media hosting relies on Tailscale Funnel when using Twilio; use `warelay webhook --ingress tailscale` or `--serve-media` for local hosting.
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
- Rebrand/migration issues or legacy config/service warnings: run `openclaw doctor` (see `docs/gateway/doctor.md`).
|
|
||||||
|
|
||||||
## Agent-Specific Notes
|
## Agent-Specific Notes
|
||||||
- Vocabulary: "makeup" = "mac app".
|
- Relay is managed by launchctl (label `com.steipete.warelay`). After code changes restart with `launchctl kickstart -k gui/$UID/com.steipete.warelay` and verify via `launchctl list | grep warelay`. Use tmux only if you spin up a temporary relay yourself and clean it up afterward.
|
||||||
- Never edit `node_modules` (global/Homebrew/npm/git installs too). Updates overwrite. Skill notes go in `tools.md` or `AGENTS.md`.
|
- Also read the shared guardrails at `~/Projects/oracle/AGENTS.md` and `~/Projects/agent-scripts/AGENTS.MD` before making changes; align with any cross-repo rules noted there.
|
||||||
- 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 asked to open a “session” file, open the Pi/Tau session logs under `~/.pi/agent/sessions/warelay/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`.
|
||||||
- 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`.
|
|
||||||
- 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).
|
|
||||||
- **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`.
|
|
||||||
- A2UI bundle hash: `src/canvas-host/a2ui/.bundle.hash` is auto-generated; ignore unexpected changes, and only regenerate via `pnpm canvas:a2ui:bundle` (or `scripts/bundle-a2ui.sh`) when needed. Commit the hash as a separate commit.
|
|
||||||
- Release signing/notary keys are managed outside the repo; follow internal release docs.
|
|
||||||
- Notary auth env vars (`APP_STORE_CONNECT_ISSUER_ID`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_API_KEY_P8`) are expected in your environment (per internal release docs).
|
|
||||||
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless explicitly requested (this includes `git pull --rebase --autostash`). Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
|
|
||||||
- **Multi-agent safety:** when the user says "push", you may `git pull --rebase` to integrate latest changes (never discard other agents' work). When the user says "commit", scope to your changes only. When the user says "commit all", commit everything in grouped chunks.
|
|
||||||
- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless explicitly requested.
|
|
||||||
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless explicitly requested.
|
|
||||||
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.
|
|
||||||
- **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 `~/.clawdbot/agents/<agentId>/sessions/*.jsonl` (use the `agent=<id>` value in the Runtime line of the system prompt; newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
|
|
||||||
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
|
|
||||||
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
|
|
||||||
- Voice wake forwarding tips:
|
|
||||||
- Command template should stay `openclaw-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes.
|
|
||||||
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`openclaw` binaries resolve when invoked via `openclaw-mac`.
|
|
||||||
- For manual `openclaw message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
|
|
||||||
- Release guardrails: do not change version numbers without operator’s explicit consent; always ask permission before running any npm publish/release step.
|
|
||||||
|
|
||||||
## NPM + 1Password (publish/verify)
|
## Exclamation Mark Escaping Workaround
|
||||||
- Use the 1password skill; all `op` commands must run inside a fresh tmux session.
|
The Claude Code Bash tool escapes `!` to `\!` in command arguments. When using `warelay send` with messages containing exclamation marks, use heredoc syntax:
|
||||||
- Sign in: `eval "$(op signin --account my.1password.com)"` (app unlocked + integration on).
|
|
||||||
- OTP: `op read 'op://Private/Npmjs/one-time password?attribute=otp'`.
|
```bash
|
||||||
- Publish: `npm publish --access public --otp="<otp>"` (run from the package dir).
|
# WRONG - will send "Hello\!" with backslash
|
||||||
- Verify without local npmrc side effects: `npm view <pkg> version --userconfig "$(mktemp)"`.
|
warelay send --provider web --to "+1234" --message 'Hello!'
|
||||||
- Kill the tmux session after publish.
|
|
||||||
|
# CORRECT - use heredoc to avoid escaping
|
||||||
|
warelay send --provider web --to "+1234" --message "$(cat <<'EOF'
|
||||||
|
Hello!
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a Claude Code quirk, not a warelay bug.
|
||||||
|
|||||||
1274
CHANGELOG.md
1274
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@ -1,52 +0,0 @@
|
|||||||
# Contributing to OpenClaw
|
|
||||||
|
|
||||||
Welcome to the lobster tank! 🦞
|
|
||||||
|
|
||||||
## Quick Links
|
|
||||||
- **GitHub:** https://github.com/openclaw/openclaw
|
|
||||||
- **Discord:** https://discord.gg/qkhbAGHRBT
|
|
||||||
- **X/Twitter:** [@steipete](https://x.com/steipete) / [@openclaw](https://x.com/openclaw)
|
|
||||||
|
|
||||||
## Maintainers
|
|
||||||
|
|
||||||
- **Peter Steinberger** - Benevolent Dictator
|
|
||||||
- GitHub: [@steipete](https://github.com/steipete) · X: [@steipete](https://x.com/steipete)
|
|
||||||
|
|
||||||
- **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)
|
|
||||||
|
|
||||||
## 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 #setup-help
|
|
||||||
|
|
||||||
## Before You PR
|
|
||||||
- Test locally with your OpenClaw instance
|
|
||||||
- Run linter: `npm run lint`
|
|
||||||
- Keep PRs focused (one thing per PR)
|
|
||||||
- Describe what & why
|
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
AI PRs are first-class citizens here. We just want transparency so reviewers know what to look for.
|
|
||||||
|
|
||||||
## 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**: Expanding the library of bundled skills and improving the Skill Creation developer experience.
|
|
||||||
- **Performance**: Optimizing token usage and compaction logic.
|
|
||||||
|
|
||||||
Check the [GitHub Issues](https://github.com/openclaw/openclaw/issues) for "good first issue" labels!
|
|
||||||
39
Dockerfile
39
Dockerfile
@ -1,39 +0,0 @@
|
|||||||
FROM node:22-bookworm
|
|
||||||
|
|
||||||
# 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 OPENCLAW_DOCKER_APT_PACKAGES=""
|
|
||||||
RUN if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \
|
|
||||||
apt-get update && \
|
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $OPENCLAW_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
|
|
||||||
|
|
||||||
RUN pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
RUN OPENCLAW_A2UI_SKIP_MISSING=1 pnpm build
|
|
||||||
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
|
|
||||||
ENV OPENCLAW_PREFER_PNPM=1
|
|
||||||
RUN pnpm ui:build
|
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
|
|
||||||
# Security hardening: Run as non-root user
|
|
||||||
# The node:22-bookworm image includes a 'node' user (uid 1000)
|
|
||||||
# This reduces the attack surface by preventing container escape via root privileges
|
|
||||||
USER node
|
|
||||||
|
|
||||||
CMD ["node", "dist/index.js"]
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
FROM debian:bookworm-slim
|
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
|
||||||
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends \
|
|
||||||
bash \
|
|
||||||
ca-certificates \
|
|
||||||
curl \
|
|
||||||
git \
|
|
||||||
jq \
|
|
||||||
python3 \
|
|
||||||
ripgrep \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
CMD ["sleep", "infinity"]
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
FROM debian:bookworm-slim
|
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
|
||||||
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends \
|
|
||||||
bash \
|
|
||||||
ca-certificates \
|
|
||||||
chromium \
|
|
||||||
curl \
|
|
||||||
fonts-liberation \
|
|
||||||
fonts-noto-color-emoji \
|
|
||||||
git \
|
|
||||||
jq \
|
|
||||||
novnc \
|
|
||||||
python3 \
|
|
||||||
socat \
|
|
||||||
websockify \
|
|
||||||
x11vnc \
|
|
||||||
xvfb \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
COPY scripts/sandbox-browser-entrypoint.sh /usr/local/bin/openclaw-sandbox-browser
|
|
||||||
RUN chmod +x /usr/local/bin/openclaw-sandbox-browser
|
|
||||||
|
|
||||||
EXPOSE 9222 5900 6080
|
|
||||||
|
|
||||||
CMD ["openclaw-sandbox-browser"]
|
|
||||||
553
README.md
553
README.md
@ -1,10 +1,7 @@
|
|||||||
# 🦞 OpenClaw — Personal AI Assistant
|
# 🦞 CLAWDIS — WhatsApp Gateway for AI Agents
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<picture>
|
<img src="docs/whatsapp-clawd.jpg" alt="CLAWDIS" width="400">
|
||||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text-dark.png">
|
|
||||||
<img src="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text.png" alt="OpenClaw" width="500">
|
|
||||||
</picture>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@ -12,508 +9,132 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<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/steipete/warelay/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/steipete/warelay/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://www.npmjs.com/package/warelay"><img src="https://img.shields.io/npm/v/warelay.svg?style=for-the-badge" alt="npm version"></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>
|
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**OpenClaw** is a *personal AI assistant* you run on your own devices.
|
**CLAWDIS** (formerly Warelay) is a WhatsApp-to-AI gateway. Send a message, get an AI response. It's like having a genius lobster in your pocket 24/7.
|
||||||
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, Microsoft Teams, WebChat), plus extension channels like BlueBubbles, Matrix, Zalo, and Zalo Personal. It can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
|
||||||
|
|
||||||
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
|
```
|
||||||
|
┌─────────────┐ ┌──────────┐ ┌─────────────┐
|
||||||
[Website](https://openclaw.ai) · [Docs](https://docs.openclaw.ai) · [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/start/faq) · [Wizard](https://docs.openclaw.ai/start/wizard) · [Nix](https://github.com/openclaw/nix-clawdbot) · [Docker](https://docs.openclaw.ai/install/docker) · [Discord](https://discord.gg/clawd)
|
│ WhatsApp │ ───▶ │ CLAWDIS │ ───▶ │ AI Agent │
|
||||||
|
│ (You) │ ◀─── │ 🦞⏱️💙 │ ◀─── │ (Tau/Claude)│
|
||||||
Preferred setup: run the onboarding wizard (`openclaw 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)
|
|
||||||
|
|
||||||
**Subscriptions (OAuth):**
|
|
||||||
- **[Anthropic](https://www.anthropic.com/)** (Claude Pro/Max)
|
|
||||||
- **[OpenAI](https://openai.com/)** (ChatGPT/Codex)
|
|
||||||
|
|
||||||
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.openclaw.ai/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)
|
|
||||||
|
|
||||||
## Install (recommended)
|
|
||||||
|
|
||||||
Runtime: **Node ≥22**.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install -g openclaw@latest
|
|
||||||
# or: pnpm add -g openclaw@latest
|
|
||||||
|
|
||||||
openclaw onboard --install-daemon
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The wizard installs the Gateway daemon (launchd/systemd user service) so it stays running.
|
## Why "CLAWDIS"?
|
||||||
|
|
||||||
## Quick start (TL;DR)
|
**CLAWDIS** = CLAW + TARDIS
|
||||||
|
|
||||||
Runtime: **Node ≥22**.
|
Because every space lobster needs a time-and-space machine. The Doctor has a TARDIS. [Clawd](https://clawd.me) has a CLAWDIS. Both are blue. Both are chaotic. Both are loved.
|
||||||
|
|
||||||
Full beginner guide (auth, pairing, channels): [Getting started](https://docs.openclaw.ai/start/getting-started)
|
## Features
|
||||||
|
|
||||||
|
- 📱 **WhatsApp Integration** — Personal WhatsApp Web or Twilio
|
||||||
|
- 🤖 **AI Agent Gateway** — Works with Tau/Pi, Claude CLI, Codex, Gemini
|
||||||
|
- 💬 **Session Management** — Per-sender conversation context
|
||||||
|
- 🔔 **Heartbeats** — Periodic check-ins for proactive AI
|
||||||
|
- 👥 **Group Chat Support** — Mention-based triggering
|
||||||
|
- 📎 **Media Support** — Images, audio, documents, voice notes
|
||||||
|
- 🎤 **Voice Transcription** — Whisper integration
|
||||||
|
- 🔧 **Tool Streaming** — Real-time display (💻📄✍️📝)
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
openclaw onboard --install-daemon
|
# Install
|
||||||
|
npm install -g warelay # (still warelay on npm for now)
|
||||||
|
|
||||||
openclaw gateway --port 18789 --verbose
|
# Link your WhatsApp
|
||||||
|
clawdis login
|
||||||
|
|
||||||
# Send a message
|
# Send a message
|
||||||
openclaw message send --to +1234567890 --message "Hello from OpenClaw"
|
clawdis send --to +1234567890 --message "Hello from the CLAWDIS!"
|
||||||
|
|
||||||
# Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Google Chat/Signal/iMessage/BlueBubbles/Microsoft Teams/Matrix/Zalo/Zalo Personal/WebChat)
|
# Start the relay
|
||||||
openclaw agent --message "Ship checklist" --thinking high
|
clawdis relay --verbose
|
||||||
```
|
```
|
||||||
|
|
||||||
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).
|
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
pnpm install
|
|
||||||
pnpm ui:build # auto-installs UI deps on first run
|
|
||||||
pnpm build
|
|
||||||
|
|
||||||
pnpm openclaw 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.
|
|
||||||
|
|
||||||
## Security defaults (DM access)
|
|
||||||
|
|
||||||
OpenClaw connects to real messaging surfaces. Treat inbound DMs as **untrusted input**.
|
|
||||||
|
|
||||||
Full security guide: [Security](https://docs.openclaw.ai/gateway/security)
|
|
||||||
|
|
||||||
Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/Discord/Google Chat/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: `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.dm.allowFrom` / `channels.slack.dm.allowFrom`).
|
|
||||||
|
|
||||||
Run `openclaw doctor` to surface risky/misconfigured DM policies.
|
|
||||||
|
|
||||||
## Highlights
|
|
||||||
|
|
||||||
- **[Local-first Gateway](https://docs.openclaw.ai/gateway)** — single control plane for sessions, channels, tools, and events.
|
|
||||||
- **[Multi-channel inbox](https://docs.openclaw.ai/channels)** — WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, BlueBubbles, Microsoft Teams, Matrix, 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)** — always-on speech for macOS/iOS/Android with ElevenLabs.
|
|
||||||
- **[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.
|
|
||||||
|
|
||||||
## Star History
|
|
||||||
|
|
||||||
[](https://www.star-history.com/#openclaw/openclaw&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/concepts/groups).
|
|
||||||
- [Media pipeline](https://docs.openclaw.ai/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.openclaw.ai/nodes/audio).
|
|
||||||
|
|
||||||
### Channels
|
|
||||||
- [Channels](https://docs.openclaw.ai/channels): [WhatsApp](https://docs.openclaw.ai/channels/whatsapp) (Baileys), [Telegram](https://docs.openclaw.ai/channels/telegram) (grammY), [Slack](https://docs.openclaw.ai/channels/slack) (Bolt), [Discord](https://docs.openclaw.ai/channels/discord) (discord.js), [Google Chat](https://docs.openclaw.ai/channels/googlechat) (Chat API), [Signal](https://docs.openclaw.ai/channels/signal) (signal-cli), [iMessage](https://docs.openclaw.ai/channels/imessage) (imsg), [BlueBubbles](https://docs.openclaw.ai/channels/bluebubbles) (extension), [Microsoft Teams](https://docs.openclaw.ai/channels/msteams) (extension), [Matrix](https://docs.openclaw.ai/channels/matrix) (extension), [Zalo](https://docs.openclaw.ai/channels/zalo) (extension), [Zalo Personal](https://docs.openclaw.ai/channels/zalouser) (extension), [WebChat](https://docs.openclaw.ai/web/webchat).
|
|
||||||
- [Group routing](https://docs.openclaw.ai/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.openclaw.ai/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 pairing.
|
|
||||||
- [Android node](https://docs.openclaw.ai/platforms/android): [Canvas](https://docs.openclaw.ai/platforms/mac/canvas), [Talk Mode](https://docs.openclaw.ai/nodes/talk), camera, screen recording, optional SMS.
|
|
||||||
- [macOS node mode](https://docs.openclaw.ai/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.
|
|
||||||
|
|
||||||
### Runtime + safety
|
|
||||||
- [Channel routing](https://docs.openclaw.ai/concepts/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).
|
|
||||||
|
|
||||||
### 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).
|
|
||||||
|
|
||||||
## How it works (short)
|
|
||||||
|
|
||||||
```
|
|
||||||
WhatsApp / Telegram / Slack / Discord / Google Chat / Signal / iMessage / BlueBubbles / Microsoft Teams / Matrix / Zalo / Zalo Personal / WebChat
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌───────────────────────────────┐
|
|
||||||
│ Gateway │
|
|
||||||
│ (control plane) │
|
|
||||||
│ ws://127.0.0.1:18789 │
|
|
||||||
└──────────────┬────────────────┘
|
|
||||||
│
|
|
||||||
├─ Pi agent (RPC)
|
|
||||||
├─ CLI (openclaw …)
|
|
||||||
├─ WebChat UI
|
|
||||||
├─ macOS app
|
|
||||||
└─ iOS / Android nodes
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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)** — always‑on speech and continuous conversation.
|
|
||||||
- **[Nodes](https://docs.openclaw.ai/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`:
|
|
||||||
|
|
||||||
- `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).
|
|
||||||
- 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)
|
|
||||||
|
|
||||||
## Remote Gateway (Linux is great)
|
|
||||||
|
|
||||||
It’s perfectly fine to run the Gateway on a small Linux instance. Clients (macOS app, CLI, WebChat) can connect over **Tailscale Serve/Funnel** or **SSH tunnels**, and you can still pair device nodes (macOS/iOS/Android) to execute device‑local actions when needed.
|
|
||||||
|
|
||||||
- **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.
|
|
||||||
|
|
||||||
Details: [Remote access](https://docs.openclaw.ai/gateway/remote) · [Nodes](https://docs.openclaw.ai/nodes) · [Security](https://docs.openclaw.ai/gateway/security)
|
|
||||||
|
|
||||||
## macOS permissions via the Gateway protocol
|
|
||||||
|
|
||||||
The macOS app can run in **node mode** and advertises its capabilities + permission map over the Gateway WebSocket (`node.list` / `node.describe`). Clients can then execute local actions via `node.invoke`:
|
|
||||||
|
|
||||||
- `system.run` runs a local command and returns stdout/stderr/exit code; set `needsScreenRecording: true` to require screen-recording permission (otherwise you’ll get `PERMISSION_MISSING`).
|
|
||||||
- `system.notify` posts a user notification and fails if notifications are denied.
|
|
||||||
- `canvas.*`, `camera.*`, `screen.record`, and `location.get` are also routed via `node.invoke` and follow TCC permission status.
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
## 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)
|
|
||||||
|
|
||||||
## Skills registry (ClawdHub)
|
|
||||||
|
|
||||||
ClawdHub is a minimal skill registry. With ClawdHub enabled, the agent can search for skills automatically and pull in new ones as needed.
|
|
||||||
|
|
||||||
[ClawdHub](https://ClawdHub.com)
|
|
||||||
|
|
||||||
## Chat commands
|
|
||||||
|
|
||||||
Send these in WhatsApp/Telegram/Slack/Google Chat/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
|
|
||||||
- `/restart` — restart the gateway (owner-only in groups)
|
|
||||||
- `/activation mention|always` — group activation toggle (groups only)
|
|
||||||
|
|
||||||
## Apps (optional)
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
### macOS (OpenClaw.app) (optional)
|
|
||||||
|
|
||||||
- Menu bar control for the Gateway and health.
|
|
||||||
- Voice Wake + push-to-talk overlay.
|
|
||||||
- WebChat + debug tools.
|
|
||||||
- Remote gateway control over SSH.
|
|
||||||
|
|
||||||
Note: signed builds required for macOS permissions to stick across rebuilds (see `docs/mac/permissions.md`).
|
|
||||||
|
|
||||||
### iOS node (optional)
|
|
||||||
|
|
||||||
- Pairs as a node via the Bridge.
|
|
||||||
- Voice trigger forwarding + Canvas surface.
|
|
||||||
- Controlled via `openclaw nodes …`.
|
|
||||||
|
|
||||||
Runbook: [iOS connect](https://docs.openclaw.ai/platforms/ios).
|
|
||||||
|
|
||||||
### Android node (optional)
|
|
||||||
|
|
||||||
- Pairs via the same Bridge + pairing flow as iOS.
|
|
||||||
- Exposes Canvas, Camera, and Screen capture commands.
|
|
||||||
- Runbook: [Android connect](https://docs.openclaw.ai/platforms/android).
|
|
||||||
|
|
||||||
## Agent workspace + skills
|
|
||||||
|
|
||||||
- Workspace root: `~/.openclaw/workspace` (configurable via `agents.defaults.workspace`).
|
|
||||||
- Injected prompt files: `AGENTS.md`, `SOUL.md`, `TOOLS.md`.
|
|
||||||
- Skills: `~/.openclaw/workspace/skills/<skill>/SKILL.md`.
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Minimal `~/.openclaw/openclaw.json` (model + defaults):
|
Create `~/.clawdis/clawdis.json`:
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
agent: {
|
inbound: {
|
||||||
model: "anthropic/claude-opus-4-5"
|
allowFrom: ["+1234567890"],
|
||||||
}
|
reply: {
|
||||||
}
|
mode: "command",
|
||||||
```
|
command: ["tau", "--mode", "json", "{{BodyStripped}}"],
|
||||||
|
session: {
|
||||||
[Full configuration reference (all keys + examples).](https://docs.openclaw.ai/gateway/configuration)
|
scope: "per-sender",
|
||||||
|
idleMinutes: 1440
|
||||||
## Security model (important)
|
},
|
||||||
|
heartbeatMinutes: 10
|
||||||
- **Default:** tools run on the host for the **main** session, so the agent has full access when it’s just you.
|
|
||||||
- **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)
|
|
||||||
|
|
||||||
### [WhatsApp](https://docs.openclaw.ai/channels/whatsapp)
|
|
||||||
|
|
||||||
- Link the device: `pnpm openclaw channels login` (stores creds in `~/.openclaw/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)
|
|
||||||
|
|
||||||
- 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` as needed.
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
channels: {
|
|
||||||
telegram: {
|
|
||||||
botToken: "123456:ABCDEF"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### [Slack](https://docs.openclaw.ai/channels/slack)
|
## Documentation
|
||||||
|
|
||||||
- Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` (or `channels.slack.botToken` + `channels.slack.appToken`).
|
- [Configuration Guide](./docs/configuration.md)
|
||||||
|
- [Agent Integration](./docs/agents.md)
|
||||||
|
- [Group Chats](./docs/group-messages.md)
|
||||||
|
- [Security](./docs/security.md)
|
||||||
|
- [Troubleshooting](./docs/troubleshooting.md)
|
||||||
|
- [The Lore](./docs/lore.md) 🦞
|
||||||
|
|
||||||
### [Discord](https://docs.openclaw.ai/channels/discord)
|
## Clawd
|
||||||
|
|
||||||
- Set `DISCORD_BOT_TOKEN` or `channels.discord.token` (env wins).
|
CLAWDIS was built for **Clawd**, a space lobster AI assistant. See the full setup in [`docs/clawd.md`](./docs/clawd.md).
|
||||||
- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `channels.discord.dm.allowFrom`, `channels.discord.guilds`, or `channels.discord.mediaMaxMb` as needed.
|
|
||||||
|
|
||||||
```json5
|
- 🦞 **Clawd's Home:** [clawd.me](https://clawd.me)
|
||||||
{
|
- 📜 **Clawd's Soul:** [soul.md](https://soul.md)
|
||||||
channels: {
|
- 👨💻 **Peter's Blog:** [steipete.me](https://steipete.me)
|
||||||
discord: {
|
- 🐦 **Twitter:** [@steipete](https://twitter.com/steipete)
|
||||||
token: "1234abcd"
|
|
||||||
}
|
## Providers
|
||||||
}
|
|
||||||
}
|
### WhatsApp Web (Recommended)
|
||||||
|
```bash
|
||||||
|
clawdis login # Scan QR code
|
||||||
|
clawdis relay # Start listening
|
||||||
```
|
```
|
||||||
|
|
||||||
### [Signal](https://docs.openclaw.ai/channels/signal)
|
### Twilio
|
||||||
|
```bash
|
||||||
|
# Set environment variables
|
||||||
|
export TWILIO_ACCOUNT_SID=...
|
||||||
|
export TWILIO_AUTH_TOKEN=...
|
||||||
|
export TWILIO_WHATSAPP_FROM=whatsapp:+1234567890
|
||||||
|
|
||||||
- Requires `signal-cli` and a `channels.signal` config section.
|
clawdis relay --provider twilio
|
||||||
|
|
||||||
### [iMessage](https://docs.openclaw.ai/channels/imessage)
|
|
||||||
|
|
||||||
- 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)
|
|
||||||
|
|
||||||
- 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)
|
|
||||||
|
|
||||||
- Uses the Gateway WebSocket; no separate WebChat port/config.
|
|
||||||
|
|
||||||
Browser control (optional):
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
browser: {
|
|
||||||
enabled: true,
|
|
||||||
color: "#FF4500"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Docs
|
## Commands
|
||||||
|
|
||||||
Use these when you’re past the onboarding flow and want the deeper reference.
|
| Command | Description |
|
||||||
- [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)
|
| `clawdis login` | Link WhatsApp Web via QR |
|
||||||
- [Use the full configuration reference when you need every key and example.](https://docs.openclaw.ai/gateway/configuration)
|
| `clawdis send` | Send a message |
|
||||||
- [Run the Gateway by the book with the operational runbook.](https://docs.openclaw.ai/gateway)
|
| `clawdis relay` | Start auto-reply loop |
|
||||||
- [Learn how the Control UI/Web surfaces work and how to expose them safely.](https://docs.openclaw.ai/web)
|
| `clawdis status` | Show recent messages |
|
||||||
- [Understand remote access over SSH tunnels or tailnets.](https://docs.openclaw.ai/gateway/remote)
|
| `clawdis heartbeat` | Trigger a heartbeat |
|
||||||
- [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)
|
|
||||||
|
|
||||||
## Advanced docs (discovery + control)
|
## Credits
|
||||||
|
|
||||||
- [Discovery + transports](https://docs.openclaw.ai/gateway/discovery)
|
- **Peter Steinberger** ([@steipete](https://twitter.com/steipete)) — Creator
|
||||||
- [Bonjour/mDNS](https://docs.openclaw.ai/gateway/bonjour)
|
- **Mario Zechner** ([@badlogicgames](https://twitter.com/badlogicgames)) — Tau/Pi, security testing
|
||||||
- [Gateway pairing](https://docs.openclaw.ai/gateway/pairing)
|
- **Clawd** 🦞 — The space lobster who demanded a better name
|
||||||
- [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)
|
|
||||||
|
|
||||||
## Operations & troubleshooting
|
## License
|
||||||
|
|
||||||
- [Health checks](https://docs.openclaw.ai/gateway/health)
|
MIT — Free as a lobster in the ocean.
|
||||||
- [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)
|
|
||||||
|
|
||||||
## Deep dives
|
---
|
||||||
|
|
||||||
- [Agent loop](https://docs.openclaw.ai/concepts/agent-loop)
|
*"We're all just playing with our own prompts."*
|
||||||
- [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)
|
|
||||||
|
|
||||||
## 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)
|
|
||||||
|
|
||||||
## 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)
|
|
||||||
|
|
||||||
## Email hooks (Gmail)
|
|
||||||
|
|
||||||
- [docs.openclaw.ai/gmail-pubsub](https://docs.openclaw.ai/automation/gmail-pubsub)
|
|
||||||
|
|
||||||
## Molty
|
|
||||||
|
|
||||||
OpenClaw was built for **Molty**, a space lobster AI assistant. 🦞
|
|
||||||
by Peter Steinberger and the community.
|
|
||||||
|
|
||||||
- [openclaw.ai](https://openclaw.ai)
|
|
||||||
- [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.
|
|
||||||
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.
|
|
||||||
|
|
||||||
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/plum-dawg"><img src="https://avatars.githubusercontent.com/u/5909950?v=4&s=48" width="48" height="48" alt="plum-dawg" title="plum-dawg"/></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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="patelhiren" title="patelhiren"/></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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/hirefrank"><img src="https://avatars.githubusercontent.com/u/183158?v=4&s=48" width="48" height="48" alt="hirefrank" title="hirefrank"/></a> <a href="https://github.com/joeynyc"><img src="https://avatars.githubusercontent.com/u/17919866?v=4&s=48" width="48" height="48" alt="joeynyc" title="joeynyc"/></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/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/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/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/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/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/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/rohannagpal"><img src="https://avatars.githubusercontent.com/u/4009239?v=4&s=48" width="48" height="48" alt="rohannagpal" title="rohannagpal"/></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/f-trycua"><img src="https://avatars.githubusercontent.com/u/195596869?v=4&s=48" width="48" height="48" alt="f-trycua" title="f-trycua"/></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/elliotsecops"><img src="https://avatars.githubusercontent.com/u/141947839?v=4&s=48" width="48" height="48" alt="elliotsecops" title="elliotsecops"/></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/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></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/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></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/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/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/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/denysvitali"><img src="https://avatars.githubusercontent.com/u/4939519?v=4&s=48" width="48" height="48" alt="denysvitali" title="denysvitali"/></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/nonggialiang"><img src="https://avatars.githubusercontent.com/u/14367839?v=4&s=48" width="48" height="48" alt="nonggialiang" title="nonggialiang"/></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/dominicnunez"><img src="https://avatars.githubusercontent.com/u/43616264?v=4&s=48" width="48" height="48" alt="dominicnunez" title="dominicnunez"/></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/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/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/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/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/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/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/papago2355"><img src="https://avatars.githubusercontent.com/u/68721273?v=4&s=48" width="48" height="48" alt="papago2355" title="papago2355"/></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/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/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/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/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/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/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/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a>
|
|
||||||
<a href="https://github.com/ryancontent"><img src="https://avatars.githubusercontent.com/u/39743613?v=4&s=48" width="48" height="48" alt="ryancontent" title="ryancontent"/></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/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/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/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a> <a href="https://github.com/HirokiKobayashi-R"><img src="https://avatars.githubusercontent.com/u/37167840?v=4&s=48" width="48" height="48" alt="HirokiKobayashi-R" title="HirokiKobayashi-R"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></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/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/manikv12"><img src="https://avatars.githubusercontent.com/u/49544491?v=4&s=48" width="48" height="48" alt="manikv12" title="manikv12"/></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/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></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/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/kyleok"><img src="https://avatars.githubusercontent.com/u/58307870?v=4&s=48" width="48" height="48" alt="kyleok" title="kyleok"/></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/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></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/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/uos-status"><img src="https://avatars.githubusercontent.com/u/255712580?v=4&s=48" width="48" height="48" alt="uos-status" title="uos-status"/></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/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/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/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/JonUleis"><img src="https://avatars.githubusercontent.com/u/7644941?v=4&s=48" width="48" height="48" alt="JonUleis" title="JonUleis"/></a> <a href="https://github.com/shivamraut101"><img src="https://avatars.githubusercontent.com/u/110457469?v=4&s=48" width="48" height="48" alt="shivamraut101" title="shivamraut101"/></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/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></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/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/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/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/pookNast"><img src="https://avatars.githubusercontent.com/u/14242552?v=4&s=48" width="48" height="48" alt="pookNast" title="pookNast"/></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/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></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/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/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/kennyklee"><img src="https://avatars.githubusercontent.com/u/1432489?v=4&s=48" width="48" height="48" alt="kennyklee" title="kennyklee"/></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/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/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/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/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/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/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/robhparker"><img src="https://avatars.githubusercontent.com/u/7404740?v=4&s=48" width="48" height="48" alt="robhparker" title="robhparker"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></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/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/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/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></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/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/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/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/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/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/search?q=Joshua%20Mitchell"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Joshua Mitchell" title="Joshua Mitchell"/></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/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/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></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/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/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/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/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/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/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/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/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/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/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/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/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/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/search?q=Roopak%20Nijhara"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Roopak Nijhara" title="Roopak Nijhara"/></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/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/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/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/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></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/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></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/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/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/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/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/ivancasco"><img src="https://avatars.githubusercontent.com/u/2452858?v=4&s=48" width="48" height="48" alt="ivancasco" title="ivancasco"/></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/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></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/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/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/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/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/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/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/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/search?q=Pocket%20Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Pocket Clawd" title="Pocket Clawd"/></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/Suksham-sharma"><img src="https://avatars.githubusercontent.com/u/94667656?v=4&s=48" width="48" height="48" alt="Suksham-sharma" title="Suksham-sharma"/></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/tewatia"><img src="https://avatars.githubusercontent.com/u/22875334?v=4&s=48" width="48" height="48" alt="tewatia" title="tewatia"/></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/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></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/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/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/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/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/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/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/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></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/arthyn"><img src="https://avatars.githubusercontent.com/u/5466421?v=4&s=48" width="48" height="48" alt="arthyn" title="arthyn"/></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/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/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/chenyuan99"><img src="https://avatars.githubusercontent.com/u/25518100?v=4&s=48" width="48" height="48" alt="chenyuan99" title="chenyuan99"/></a> <a href="https://github.com/Chloe-VP"><img src="https://avatars.githubusercontent.com/u/257371598?v=4&s=48" width="48" height="48" alt="Chloe-VP" title="Chloe-VP"/></a> <a href="https://github.com/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/dasilva333"><img src="https://avatars.githubusercontent.com/u/947827?v=4&s=48" width="48" height="48" alt="dasilva333" title="dasilva333"/></a>
|
|
||||||
<a href="https://github.com/David-Marsh-Photo"><img src="https://avatars.githubusercontent.com/u/228404527?v=4&s=48" width="48" height="48" alt="David-Marsh-Photo" title="David-Marsh-Photo"/></a> <a href="https://github.com/search?q=Developer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Developer" title="Developer"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></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/dylanneve1"><img src="https://avatars.githubusercontent.com/u/31746704?v=4&s=48" width="48" height="48" alt="dylanneve1" title="dylanneve1"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></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/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/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/grrowl"><img src="https://avatars.githubusercontent.com/u/907140?v=4&s=48" width="48" height="48" alt="grrowl" title="grrowl"/></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=Jane"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jane" title="Jane"/></a> <a href="https://github.com/search?q=Jarvis%20Deploy"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis Deploy" title="Jarvis Deploy"/></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/jogi47"><img src="https://avatars.githubusercontent.com/u/1710139?v=4&s=48" width="48" height="48" alt="jogi47" title="jogi47"/></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/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kira-ariaki"><img src="https://avatars.githubusercontent.com/u/257352493?v=4&s=48" width="48" height="48" alt="kira-ariaki" title="kira-ariaki"/></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/Kiwitwitter"><img src="https://avatars.githubusercontent.com/u/25277769?v=4&s=48" width="48" height="48" alt="Kiwitwitter" title="Kiwitwitter"/></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/longjos"><img src="https://avatars.githubusercontent.com/u/740160?v=4&s=48" width="48" height="48" alt="longjos" title="longjos"/></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/louzhixian"><img src="https://avatars.githubusercontent.com/u/7994361?v=4&s=48" width="48" height="48" alt="louzhixian" title="louzhixian"/></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=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a> <a href="https://github.com/mertcicekci0"><img src="https://avatars.githubusercontent.com/u/179321902?v=4&s=48" width="48" height="48" alt="mertcicekci0" title="mertcicekci0"/></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/mylukin"><img src="https://avatars.githubusercontent.com/u/1021019?v=4&s=48" width="48" height="48" alt="mylukin" title="mylukin"/></a> <a href="https://github.com/nathanbosse"><img src="https://avatars.githubusercontent.com/u/4040669?v=4&s=48" width="48" height="48" alt="nathanbosse" title="nathanbosse"/></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/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/ppamment"><img src="https://avatars.githubusercontent.com/u/2122919?v=4&s=48" width="48" height="48" alt="ppamment" title="ppamment"/></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/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></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/senoldogann"><img src="https://avatars.githubusercontent.com/u/45736551?v=4&s=48" width="48" height="48" alt="senoldogann" title="senoldogann"/></a>
|
|
||||||
<a href="https://github.com/Seredeep"><img src="https://avatars.githubusercontent.com/u/22802816?v=4&s=48" width="48" height="48" alt="Seredeep" title="Seredeep"/></a> <a href="https://github.com/sergical"><img src="https://avatars.githubusercontent.com/u/3760543?v=4&s=48" width="48" height="48" alt="sergical" title="sergical"/></a> <a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></a> <a href="https://github.com/shiyuanhai"><img src="https://avatars.githubusercontent.com/u/1187370?v=4&s=48" width="48" height="48" alt="shiyuanhai" title="shiyuanhai"/></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/spiceoogway"><img src="https://avatars.githubusercontent.com/u/105812383?v=4&s=48" width="48" height="48" alt="spiceoogway" title="spiceoogway"/></a> <a href="https://github.com/search?q=techboss"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="techboss" title="techboss"/></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/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/search?q=Vibe%20Kanban"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vibe Kanban" title="Vibe Kanban"/></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/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/search?q=wolfred"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="wolfred" title="wolfred"/></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/YangHuang2280"><img src="https://avatars.githubusercontent.com/u/201681634?v=4&s=48" width="48" height="48" alt="YangHuang2280" title="YangHuang2280"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a>
|
|
||||||
<a href="https://github.com/YiWang24"><img src="https://avatars.githubusercontent.com/u/176262341?v=4&s=48" width="48" height="48" alt="YiWang24" title="YiWang24"/></a> <a href="https://github.com/search?q=ymat19"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ymat19" title="ymat19"/></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/zackerthescar"><img src="https://avatars.githubusercontent.com/u/38077284?v=4&s=48" width="48" height="48" alt="zackerthescar" title="zackerthescar"/></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/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/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/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/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/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/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/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
|
||||||
</p>
|
|
||||||
|
|||||||
61
SECURITY.md
61
SECURITY.md
@ -1,61 +0,0 @@
|
|||||||
# Security Policy
|
|
||||||
|
|
||||||
If you believe you've found a security issue in OpenClaw, please report it privately.
|
|
||||||
|
|
||||||
## Reporting
|
|
||||||
|
|
||||||
- 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:
|
|
||||||
|
|
||||||
- `https://docs.openclaw.ai/gateway/security`
|
|
||||||
|
|
||||||
### Web Interface Safety
|
|
||||||
|
|
||||||
OpenClaw's web interface is intended for local use only. Do **not** bind it to the public internet; it is not hardened for public exposure.
|
|
||||||
|
|
||||||
## 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
|
|
||||||
```
|
|
||||||
54
Swabble/.github/workflows/ci.yml
vendored
54
Swabble/.github/workflows/ci.yml
vendored
@ -1,54 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-test:
|
|
||||||
runs-on: macos-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: bash
|
|
||||||
working-directory: swabble
|
|
||||||
steps:
|
|
||||||
- name: Checkout swabble
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
path: swabble
|
|
||||||
|
|
||||||
- name: Select Xcode 26.1 (prefer 26.1.1)
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
# pick the newest installed 26.1.x, fallback to newest 26.x
|
|
||||||
CANDIDATE="$(ls -d /Applications/Xcode_26.1*.app 2>/dev/null | sort -V | tail -1 || true)"
|
|
||||||
if [[ -z "$CANDIDATE" ]]; then
|
|
||||||
CANDIDATE="$(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -V | tail -1 || true)"
|
|
||||||
fi
|
|
||||||
if [[ -z "$CANDIDATE" ]]; then
|
|
||||||
echo "No Xcode 26.x found on runner" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "Selecting $CANDIDATE"
|
|
||||||
sudo xcode-select -s "$CANDIDATE"
|
|
||||||
xcodebuild -version
|
|
||||||
|
|
||||||
- name: Show Swift version
|
|
||||||
run: swift --version
|
|
||||||
|
|
||||||
- name: Install tooling
|
|
||||||
run: |
|
|
||||||
brew update
|
|
||||||
brew install swiftlint swiftformat
|
|
||||||
|
|
||||||
- name: Format check
|
|
||||||
run: |
|
|
||||||
./scripts/format.sh
|
|
||||||
git diff --exit-code
|
|
||||||
|
|
||||||
- name: Lint
|
|
||||||
run: ./scripts/lint.sh
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
run: swift test --parallel
|
|
||||||
33
Swabble/.gitignore
vendored
33
Swabble/.gitignore
vendored
@ -1,33 +0,0 @@
|
|||||||
# macOS
|
|
||||||
.DS_Store
|
|
||||||
|
|
||||||
# SwiftPM / Build
|
|
||||||
/.build
|
|
||||||
/.swiftpm
|
|
||||||
/DerivedData
|
|
||||||
xcuserdata/
|
|
||||||
*.xcuserstate
|
|
||||||
|
|
||||||
# Editors
|
|
||||||
/.vscode
|
|
||||||
.idea/
|
|
||||||
|
|
||||||
# Xcode artifacts
|
|
||||||
*.hmap
|
|
||||||
*.ipa
|
|
||||||
*.dSYM.zip
|
|
||||||
*.dSYM
|
|
||||||
|
|
||||||
# Playgrounds
|
|
||||||
*.xcplayground
|
|
||||||
playground.xcworkspace
|
|
||||||
timeline.xctimeline
|
|
||||||
|
|
||||||
# Carthage
|
|
||||||
Carthage/Build/
|
|
||||||
|
|
||||||
# fastlane
|
|
||||||
fastlane/report.xml
|
|
||||||
fastlane/Preview.html
|
|
||||||
fastlane/screenshots/**/*.png
|
|
||||||
fastlane/test_output
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
--swiftversion 6.2
|
|
||||||
--indent 4
|
|
||||||
--maxwidth 120
|
|
||||||
--wraparguments before-first
|
|
||||||
--wrapcollections before-first
|
|
||||||
--stripunusedargs closure-only
|
|
||||||
--self remove
|
|
||||||
--header ""
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
# SwiftLint for swabble
|
|
||||||
included:
|
|
||||||
- Sources
|
|
||||||
excluded:
|
|
||||||
- .build
|
|
||||||
- DerivedData
|
|
||||||
- "**/.swiftpm"
|
|
||||||
- "**/.build"
|
|
||||||
- "**/DerivedData"
|
|
||||||
- "**/.DS_Store"
|
|
||||||
opt_in_rules:
|
|
||||||
- array_init
|
|
||||||
- closure_spacing
|
|
||||||
- explicit_init
|
|
||||||
- fatal_error_message
|
|
||||||
- first_where
|
|
||||||
- joined_default_parameter
|
|
||||||
- last_where
|
|
||||||
- literal_expression_end_indentation
|
|
||||||
- multiline_arguments
|
|
||||||
- multiline_parameters
|
|
||||||
- operator_usage_whitespace
|
|
||||||
- redundant_nil_coalescing
|
|
||||||
- sorted_first_last
|
|
||||||
- switch_case_alignment
|
|
||||||
- vertical_parameter_alignment_on_call
|
|
||||||
- vertical_whitespace_opening_braces
|
|
||||||
- vertical_whitespace_closing_braces
|
|
||||||
|
|
||||||
disabled_rules:
|
|
||||||
- trailing_whitespace
|
|
||||||
- trailing_newline
|
|
||||||
- indentation_width
|
|
||||||
- identifier_name
|
|
||||||
- explicit_self
|
|
||||||
- file_header
|
|
||||||
- todo
|
|
||||||
|
|
||||||
line_length:
|
|
||||||
warning: 140
|
|
||||||
error: 180
|
|
||||||
|
|
||||||
reporter: "xcode"
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
## 0.2.0 — 2025-12-23
|
|
||||||
|
|
||||||
### Highlights
|
|
||||||
- Added `SwabbleKit` (multi-platform wake-word gate utilities with segment-aware gap detection).
|
|
||||||
- Swabble package now supports iOS + macOS consumers; CLI remains macOS 26-only.
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- CLI wake-word matching/stripping routed through `SwabbleKit` helpers.
|
|
||||||
- Speech pipeline types now explicitly gated to macOS 26 / iOS 26 availability.
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2025 Peter Steinberger
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
{
|
|
||||||
"originHash" : "24a723309d7a0039d3df3051106f77ac1ed7068a02508e3a6804e41d757e6c72",
|
|
||||||
"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",
|
|
||||||
"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",
|
|
||||||
"location" : "https://github.com/swiftlang/swift-syntax.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "0687f71944021d616d34d922343dcef086855920",
|
|
||||||
"version" : "600.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "swift-testing",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/apple/swift-testing",
|
|
||||||
"state" : {
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
// swift-tools-version: 6.2
|
|
||||||
import PackageDescription
|
|
||||||
|
|
||||||
let package = Package(
|
|
||||||
name: "swabble",
|
|
||||||
platforms: [
|
|
||||||
.macOS(.v15),
|
|
||||||
.iOS(.v17),
|
|
||||||
],
|
|
||||||
products: [
|
|
||||||
.library(name: "Swabble", targets: ["Swabble"]),
|
|
||||||
.library(name: "SwabbleKit", targets: ["SwabbleKit"]),
|
|
||||||
.executable(name: "swabble", targets: ["SwabbleCLI"]),
|
|
||||||
],
|
|
||||||
dependencies: [
|
|
||||||
.package(url: "https://github.com/steipete/Commander.git", exact: "0.2.1"),
|
|
||||||
.package(url: "https://github.com/apple/swift-testing", from: "0.99.0"),
|
|
||||||
],
|
|
||||||
targets: [
|
|
||||||
.target(
|
|
||||||
name: "Swabble",
|
|
||||||
path: "Sources/SwabbleCore",
|
|
||||||
swiftSettings: []),
|
|
||||||
.target(
|
|
||||||
name: "SwabbleKit",
|
|
||||||
path: "Sources/SwabbleKit",
|
|
||||||
swiftSettings: [
|
|
||||||
.enableUpcomingFeature("StrictConcurrency"),
|
|
||||||
]),
|
|
||||||
.executableTarget(
|
|
||||||
name: "SwabbleCLI",
|
|
||||||
dependencies: [
|
|
||||||
"Swabble",
|
|
||||||
"SwabbleKit",
|
|
||||||
.product(name: "Commander", package: "Commander"),
|
|
||||||
],
|
|
||||||
path: "Sources/swabble"),
|
|
||||||
.testTarget(
|
|
||||||
name: "SwabbleKitTests",
|
|
||||||
dependencies: [
|
|
||||||
"SwabbleKit",
|
|
||||||
.product(name: "Testing", package: "swift-testing"),
|
|
||||||
],
|
|
||||||
swiftSettings: [
|
|
||||||
.enableUpcomingFeature("StrictConcurrency"),
|
|
||||||
.enableExperimentalFeature("SwiftTesting"),
|
|
||||||
]),
|
|
||||||
.testTarget(
|
|
||||||
name: "swabbleTests",
|
|
||||||
dependencies: [
|
|
||||||
"Swabble",
|
|
||||||
.product(name: "Testing", package: "swift-testing"),
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
swiftLanguageModes: [.v6])
|
|
||||||
@ -1,111 +0,0 @@
|
|||||||
# 🎙️ swabble — Speech.framework wake-word hook daemon (macOS 26)
|
|
||||||
|
|
||||||
swabble is a Swift 6.2 wake-word hook daemon. The CLI targets macOS 26 (SpeechAnalyzer + SpeechTranscriber). The shared `SwabbleKit` target is multi-platform and exposes wake-word gating utilities for iOS/macOS apps.
|
|
||||||
|
|
||||||
- **Local-only**: Speech.framework on-device models; zero network usage.
|
|
||||||
- **Wake word**: Default `clawd` (aliases `claude`), optional `--no-wake` bypass.
|
|
||||||
- **SwabbleKit**: Shared wake gate utilities (gap-based gating when you provide speech segments).
|
|
||||||
- **Hooks**: Run any command with prefix/env, cooldown, min_chars, timeout.
|
|
||||||
- **Services**: launchd helper stubs for start/stop/install.
|
|
||||||
- **File transcribe**: TXT or SRT with time ranges (using AttributedString splits).
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
```bash
|
|
||||||
# Install deps
|
|
||||||
brew install swiftformat swiftlint
|
|
||||||
|
|
||||||
# Build
|
|
||||||
swift build
|
|
||||||
|
|
||||||
# Write default config (~/.config/swabble/config.json)
|
|
||||||
swift run swabble setup
|
|
||||||
|
|
||||||
# Run foreground daemon
|
|
||||||
swift run swabble serve
|
|
||||||
|
|
||||||
# Test your hook
|
|
||||||
swift run swabble test-hook "hello world"
|
|
||||||
|
|
||||||
# Transcribe a file to SRT
|
|
||||||
swift run swabble transcribe /path/to/audio.m4a --format srt --output out.srt
|
|
||||||
```
|
|
||||||
|
|
||||||
## Use as a library
|
|
||||||
Add swabble as a SwiftPM dependency and import the `Swabble` or `SwabbleKit` product:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// Package.swift
|
|
||||||
dependencies: [
|
|
||||||
.package(url: "https://github.com/steipete/swabble.git", branch: "main"),
|
|
||||||
],
|
|
||||||
targets: [
|
|
||||||
.target(name: "MyApp", dependencies: [
|
|
||||||
.product(name: "Swabble", package: "swabble"), // Speech pipeline (macOS 26+ / iOS 26+)
|
|
||||||
.product(name: "SwabbleKit", package: "swabble"), // Wake-word gate utilities (iOS 17+ / macOS 15+)
|
|
||||||
]),
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
## CLI
|
|
||||||
- `serve` — foreground loop (mic → wake → hook)
|
|
||||||
- `transcribe <file>` — offline transcription (txt|srt)
|
|
||||||
- `test-hook "text"` — invoke configured hook
|
|
||||||
- `mic list|set <index>` — enumerate/select input device
|
|
||||||
- `setup` — write default config JSON
|
|
||||||
- `doctor` — check Speech auth & device availability
|
|
||||||
- `health` — prints `ok`
|
|
||||||
- `tail-log` — last 10 transcripts
|
|
||||||
- `status` — show wake state + recent transcripts
|
|
||||||
- `service install|uninstall|status` — user launchd plist (stub: prints launchctl commands)
|
|
||||||
- `start|stop|restart` — placeholders until full launchd wiring
|
|
||||||
|
|
||||||
All commands accept Commander runtime flags (`-v/--verbose`, `--json-output`, `--log-level`), plus `--config` where applicable.
|
|
||||||
|
|
||||||
## Config
|
|
||||||
`~/.config/swabble/config.json` (auto-created by `setup`):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"audio": {"deviceName": "", "deviceIndex": -1, "sampleRate": 16000, "channels": 1},
|
|
||||||
"wake": {"enabled": true, "word": "clawd", "aliases": ["claude"]},
|
|
||||||
"hook": {
|
|
||||||
"command": "",
|
|
||||||
"args": [],
|
|
||||||
"prefix": "Voice swabble from ${hostname}: ",
|
|
||||||
"cooldownSeconds": 1,
|
|
||||||
"minCharacters": 24,
|
|
||||||
"timeoutSeconds": 5,
|
|
||||||
"env": {}
|
|
||||||
},
|
|
||||||
"logging": {"level": "info", "format": "text"},
|
|
||||||
"transcripts": {"enabled": true, "maxEntries": 50},
|
|
||||||
"speech": {"localeIdentifier": "en_US", "etiquetteReplacements": false}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- Config path override: `--config /path/to/config.json` on relevant commands.
|
|
||||||
- Transcripts persist to `~/Library/Application Support/swabble/transcripts.log`.
|
|
||||||
|
|
||||||
## Hook protocol
|
|
||||||
When a wake-gated transcript passes min_chars & cooldown, swabble runs:
|
|
||||||
```
|
|
||||||
<command> <args...> "<prefix><text>"
|
|
||||||
```
|
|
||||||
Environment variables:
|
|
||||||
- `SWABBLE_TEXT` — stripped transcript (wake word removed)
|
|
||||||
- `SWABBLE_PREFIX` — rendered prefix (hostname substituted)
|
|
||||||
- plus any `hook.env` key/values
|
|
||||||
|
|
||||||
## Speech pipeline
|
|
||||||
- `AVAudioEngine` tap → `BufferConverter` → `AnalyzerInput` → `SpeechAnalyzer` with a `SpeechTranscriber` module.
|
|
||||||
- Requests volatile + final results; the CLI uses text-only wake gating today.
|
|
||||||
- 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`)
|
|
||||||
- Tests: `swift test` (uses swift-testing package)
|
|
||||||
|
|
||||||
## Roadmap
|
|
||||||
- launchd control (load/bootout, PID + status socket)
|
|
||||||
- JSON logging + PII redaction toggle
|
|
||||||
- Stronger wake-word detection and control socket status/health
|
|
||||||
@ -1,77 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
public struct SwabbleConfig: Codable, Sendable {
|
|
||||||
public struct Audio: Codable, Sendable {
|
|
||||||
public var deviceName: String = ""
|
|
||||||
public var deviceIndex: Int = -1
|
|
||||||
public var sampleRate: Double = 16000
|
|
||||||
public var channels: Int = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct Wake: Codable, Sendable {
|
|
||||||
public var enabled: Bool = true
|
|
||||||
public var word: String = "clawd"
|
|
||||||
public var aliases: [String] = ["claude"]
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct Hook: Codable, Sendable {
|
|
||||||
public var command: String = ""
|
|
||||||
public var args: [String] = []
|
|
||||||
public var prefix: String = "Voice swabble from ${hostname}: "
|
|
||||||
public var cooldownSeconds: Double = 1
|
|
||||||
public var minCharacters: Int = 24
|
|
||||||
public var timeoutSeconds: Double = 5
|
|
||||||
public var env: [String: String] = [:]
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct Logging: Codable, Sendable {
|
|
||||||
public var level: String = "info"
|
|
||||||
public var format: String = "text" // text|json placeholder
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct Transcripts: Codable, Sendable {
|
|
||||||
public var enabled: Bool = true
|
|
||||||
public var maxEntries: Int = 50
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct Speech: Codable, Sendable {
|
|
||||||
public var localeIdentifier: String = Locale.current.identifier
|
|
||||||
public var etiquetteReplacements: Bool = false
|
|
||||||
}
|
|
||||||
|
|
||||||
public var audio = Audio()
|
|
||||||
public var wake = Wake()
|
|
||||||
public var hook = Hook()
|
|
||||||
public var logging = Logging()
|
|
||||||
public var transcripts = Transcripts()
|
|
||||||
public var speech = Speech()
|
|
||||||
|
|
||||||
public static let defaultPath = FileManager.default
|
|
||||||
.homeDirectoryForCurrentUser
|
|
||||||
.appendingPathComponent(".config/swabble/config.json")
|
|
||||||
|
|
||||||
public init() {}
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum ConfigError: Error {
|
|
||||||
case missingConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum ConfigLoader {
|
|
||||||
public static func load(at path: URL?) throws -> SwabbleConfig {
|
|
||||||
let url = path ?? SwabbleConfig.defaultPath
|
|
||||||
if !FileManager.default.fileExists(atPath: url.path) {
|
|
||||||
throw ConfigError.missingConfig
|
|
||||||
}
|
|
||||||
let data = try Data(contentsOf: url)
|
|
||||||
return try JSONDecoder().decode(SwabbleConfig.self, from: data)
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func save(_ config: SwabbleConfig, at path: URL?) throws {
|
|
||||||
let url = path ?? SwabbleConfig.defaultPath
|
|
||||||
let dir = url.deletingLastPathComponent()
|
|
||||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
||||||
let data = try JSONEncoder().encode(config)
|
|
||||||
try data.write(to: url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,75 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
public struct HookJob: Sendable {
|
|
||||||
public let text: String
|
|
||||||
public let timestamp: Date
|
|
||||||
|
|
||||||
public init(text: String, timestamp: Date) {
|
|
||||||
self.text = text
|
|
||||||
self.timestamp = timestamp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public actor HookExecutor {
|
|
||||||
private let config: SwabbleConfig
|
|
||||||
private var lastRun: Date?
|
|
||||||
private let hostname: String
|
|
||||||
|
|
||||||
public init(config: SwabbleConfig) {
|
|
||||||
self.config = config
|
|
||||||
hostname = Host.current().localizedName ?? "host"
|
|
||||||
}
|
|
||||||
|
|
||||||
public func shouldRun() -> Bool {
|
|
||||||
guard config.hook.cooldownSeconds > 0 else { return true }
|
|
||||||
if let lastRun, Date().timeIntervalSince(lastRun) < config.hook.cooldownSeconds {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
public func run(job: HookJob) async throws {
|
|
||||||
guard shouldRun() else { return }
|
|
||||||
guard !config.hook.command.isEmpty else { throw NSError(
|
|
||||||
domain: "Hook",
|
|
||||||
code: 1,
|
|
||||||
userInfo: [NSLocalizedDescriptionKey: "hook command not set"]) }
|
|
||||||
|
|
||||||
let prefix = config.hook.prefix.replacingOccurrences(of: "${hostname}", with: hostname)
|
|
||||||
let payload = prefix + job.text
|
|
||||||
|
|
||||||
let process = Process()
|
|
||||||
process.executableURL = URL(fileURLWithPath: config.hook.command)
|
|
||||||
process.arguments = config.hook.args + [payload]
|
|
||||||
|
|
||||||
var env = ProcessInfo.processInfo.environment
|
|
||||||
env["SWABBLE_TEXT"] = job.text
|
|
||||||
env["SWABBLE_PREFIX"] = prefix
|
|
||||||
for (k, v) in config.hook.env {
|
|
||||||
env[k] = v
|
|
||||||
}
|
|
||||||
process.environment = env
|
|
||||||
|
|
||||||
let pipe = Pipe()
|
|
||||||
process.standardOutput = pipe
|
|
||||||
process.standardError = pipe
|
|
||||||
|
|
||||||
try process.run()
|
|
||||||
|
|
||||||
let timeoutNanos = UInt64(max(config.hook.timeoutSeconds, 0.1) * 1_000_000_000)
|
|
||||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
|
||||||
group.addTask {
|
|
||||||
process.waitUntilExit()
|
|
||||||
}
|
|
||||||
group.addTask {
|
|
||||||
try await Task.sleep(nanoseconds: timeoutNanos)
|
|
||||||
if process.isRunning {
|
|
||||||
process.terminate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try await group.next()
|
|
||||||
group.cancelAll()
|
|
||||||
}
|
|
||||||
lastRun = Date()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
@preconcurrency import AVFoundation
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
final class BufferConverter {
|
|
||||||
private final class Box<T>: @unchecked Sendable { var value: T; init(_ value: T) { self.value = value } }
|
|
||||||
enum ConverterError: Swift.Error {
|
|
||||||
case failedToCreateConverter
|
|
||||||
case failedToCreateConversionBuffer
|
|
||||||
case conversionFailed(NSError?)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var converter: AVAudioConverter?
|
|
||||||
|
|
||||||
func convert(_ buffer: AVAudioPCMBuffer, to format: AVAudioFormat) throws -> AVAudioPCMBuffer {
|
|
||||||
let inputFormat = buffer.format
|
|
||||||
if inputFormat == format {
|
|
||||||
return buffer
|
|
||||||
}
|
|
||||||
if converter == nil || converter?.outputFormat != format {
|
|
||||||
converter = AVAudioConverter(from: inputFormat, to: format)
|
|
||||||
converter?.primeMethod = .none
|
|
||||||
}
|
|
||||||
guard let converter else { throw ConverterError.failedToCreateConverter }
|
|
||||||
|
|
||||||
let sampleRateRatio = converter.outputFormat.sampleRate / converter.inputFormat.sampleRate
|
|
||||||
let scaledInputFrameLength = Double(buffer.frameLength) * sampleRateRatio
|
|
||||||
let frameCapacity = AVAudioFrameCount(scaledInputFrameLength.rounded(.up))
|
|
||||||
guard let conversionBuffer = AVAudioPCMBuffer(pcmFormat: converter.outputFormat, frameCapacity: frameCapacity)
|
|
||||||
else {
|
|
||||||
throw ConverterError.failedToCreateConversionBuffer
|
|
||||||
}
|
|
||||||
|
|
||||||
var nsError: NSError?
|
|
||||||
let consumed = Box(false)
|
|
||||||
let inputBuffer = buffer
|
|
||||||
let status = converter.convert(to: conversionBuffer, error: &nsError) { _, statusPtr in
|
|
||||||
if consumed.value {
|
|
||||||
statusPtr.pointee = .noDataNow
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
consumed.value = true
|
|
||||||
statusPtr.pointee = .haveData
|
|
||||||
return inputBuffer
|
|
||||||
}
|
|
||||||
if status == .error {
|
|
||||||
throw ConverterError.conversionFailed(nsError)
|
|
||||||
}
|
|
||||||
return conversionBuffer
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,114 +0,0 @@
|
|||||||
import AVFoundation
|
|
||||||
import Foundation
|
|
||||||
import Speech
|
|
||||||
|
|
||||||
@available(macOS 26.0, iOS 26.0, *)
|
|
||||||
public struct SpeechSegment: Sendable {
|
|
||||||
public let text: String
|
|
||||||
public let isFinal: Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(macOS 26.0, iOS 26.0, *)
|
|
||||||
public enum SpeechPipelineError: Error {
|
|
||||||
case authorizationDenied
|
|
||||||
case analyzerFormatUnavailable
|
|
||||||
case transcriberUnavailable
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Live microphone → SpeechAnalyzer → SpeechTranscriber pipeline.
|
|
||||||
@available(macOS 26.0, iOS 26.0, *)
|
|
||||||
public actor SpeechPipeline {
|
|
||||||
private struct UnsafeBuffer: @unchecked Sendable { let buffer: AVAudioPCMBuffer }
|
|
||||||
|
|
||||||
private var engine = AVAudioEngine()
|
|
||||||
private var transcriber: SpeechTranscriber?
|
|
||||||
private var analyzer: SpeechAnalyzer?
|
|
||||||
private var inputContinuation: AsyncStream<AnalyzerInput>.Continuation?
|
|
||||||
private var resultTask: Task<Void, Never>?
|
|
||||||
private let converter = BufferConverter()
|
|
||||||
|
|
||||||
public init() {}
|
|
||||||
|
|
||||||
public func start(localeIdentifier: String, etiquette: Bool) async throws -> AsyncStream<SpeechSegment> {
|
|
||||||
let auth = await requestAuthorizationIfNeeded()
|
|
||||||
guard auth == .authorized else { throw SpeechPipelineError.authorizationDenied }
|
|
||||||
|
|
||||||
let transcriberModule = SpeechTranscriber(
|
|
||||||
locale: Locale(identifier: localeIdentifier),
|
|
||||||
transcriptionOptions: etiquette ? [.etiquetteReplacements] : [],
|
|
||||||
reportingOptions: [.volatileResults],
|
|
||||||
attributeOptions: [])
|
|
||||||
transcriber = transcriberModule
|
|
||||||
|
|
||||||
guard let analyzerFormat = await SpeechAnalyzer.bestAvailableAudioFormat(compatibleWith: [transcriberModule])
|
|
||||||
else {
|
|
||||||
throw SpeechPipelineError.analyzerFormatUnavailable
|
|
||||||
}
|
|
||||||
|
|
||||||
analyzer = SpeechAnalyzer(modules: [transcriberModule])
|
|
||||||
let (stream, continuation) = AsyncStream<AnalyzerInput>.makeStream()
|
|
||||||
inputContinuation = continuation
|
|
||||||
|
|
||||||
let inputNode = engine.inputNode
|
|
||||||
let inputFormat = inputNode.outputFormat(forBus: 0)
|
|
||||||
inputNode.removeTap(onBus: 0)
|
|
||||||
inputNode.installTap(onBus: 0, bufferSize: 2048, format: inputFormat) { [weak self] buffer, _ in
|
|
||||||
guard let self else { return }
|
|
||||||
let boxed = UnsafeBuffer(buffer: buffer)
|
|
||||||
Task { await self.handleBuffer(boxed.buffer, targetFormat: analyzerFormat) }
|
|
||||||
}
|
|
||||||
|
|
||||||
engine.prepare()
|
|
||||||
try engine.start()
|
|
||||||
try await analyzer?.start(inputSequence: stream)
|
|
||||||
|
|
||||||
guard let transcriberForStream = transcriber else {
|
|
||||||
throw SpeechPipelineError.transcriberUnavailable
|
|
||||||
}
|
|
||||||
|
|
||||||
return AsyncStream { continuation in
|
|
||||||
self.resultTask = Task {
|
|
||||||
do {
|
|
||||||
for try await result in transcriberForStream.results {
|
|
||||||
let seg = SpeechSegment(text: String(result.text.characters), isFinal: result.isFinal)
|
|
||||||
continuation.yield(seg)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// swallow errors and finish
|
|
||||||
}
|
|
||||||
continuation.finish()
|
|
||||||
}
|
|
||||||
continuation.onTermination = { _ in
|
|
||||||
Task { await self.stop() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func stop() async {
|
|
||||||
resultTask?.cancel()
|
|
||||||
inputContinuation?.finish()
|
|
||||||
engine.inputNode.removeTap(onBus: 0)
|
|
||||||
engine.stop()
|
|
||||||
try? await analyzer?.finalizeAndFinishThroughEndOfInput()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleBuffer(_ buffer: AVAudioPCMBuffer, targetFormat: AVAudioFormat) async {
|
|
||||||
do {
|
|
||||||
let converted = try converter.convert(buffer, to: targetFormat)
|
|
||||||
let input = AnalyzerInput(buffer: converted)
|
|
||||||
inputContinuation?.yield(input)
|
|
||||||
} catch {
|
|
||||||
// drop on conversion failure
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func requestAuthorizationIfNeeded() async -> SFSpeechRecognizerAuthorizationStatus {
|
|
||||||
let current = SFSpeechRecognizer.authorizationStatus()
|
|
||||||
guard current == .notDetermined else { return current }
|
|
||||||
return await withCheckedContinuation { continuation in
|
|
||||||
SFSpeechRecognizer.requestAuthorization { status in
|
|
||||||
continuation.resume(returning: status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
import CoreMedia
|
|
||||||
import Foundation
|
|
||||||
import NaturalLanguage
|
|
||||||
|
|
||||||
extension AttributedString {
|
|
||||||
public func sentences(maxLength: Int? = nil) -> [AttributedString] {
|
|
||||||
let tokenizer = NLTokenizer(unit: .sentence)
|
|
||||||
let string = String(characters)
|
|
||||||
tokenizer.string = string
|
|
||||||
let sentenceRanges = tokenizer.tokens(for: string.startIndex..<string.endIndex).map {
|
|
||||||
(
|
|
||||||
$0,
|
|
||||||
AttributedString.Index($0.lowerBound, within: self)!
|
|
||||||
..<
|
|
||||||
AttributedString.Index($0.upperBound, within: self)!)
|
|
||||||
}
|
|
||||||
let ranges = sentenceRanges.flatMap { sentenceStringRange, sentenceRange in
|
|
||||||
let sentence = self[sentenceRange]
|
|
||||||
guard let maxLength, sentence.characters.count > maxLength else {
|
|
||||||
return [sentenceRange]
|
|
||||||
}
|
|
||||||
|
|
||||||
let wordTokenizer = NLTokenizer(unit: .word)
|
|
||||||
wordTokenizer.string = string
|
|
||||||
var wordRanges = wordTokenizer.tokens(for: sentenceStringRange).map {
|
|
||||||
AttributedString.Index($0.lowerBound, within: self)!
|
|
||||||
..<
|
|
||||||
AttributedString.Index($0.upperBound, within: self)!
|
|
||||||
}
|
|
||||||
guard !wordRanges.isEmpty else { return [sentenceRange] }
|
|
||||||
wordRanges[0] = sentenceRange.lowerBound..<wordRanges[0].upperBound
|
|
||||||
wordRanges[wordRanges.count - 1] = wordRanges[wordRanges.count - 1].lowerBound..<sentenceRange.upperBound
|
|
||||||
|
|
||||||
var ranges: [Range<AttributedString.Index>] = []
|
|
||||||
for wordRange in wordRanges {
|
|
||||||
if let lastRange = ranges.last,
|
|
||||||
self[lastRange].characters.count + self[wordRange].characters.count <= maxLength {
|
|
||||||
ranges[ranges.count - 1] = lastRange.lowerBound..<wordRange.upperBound
|
|
||||||
} else {
|
|
||||||
ranges.append(wordRange)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ranges
|
|
||||||
}
|
|
||||||
|
|
||||||
return ranges.compactMap { range in
|
|
||||||
let audioTimeRanges = self[range].runs.filter {
|
|
||||||
!String(self[$0.range].characters)
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
||||||
}.compactMap(\.audioTimeRange)
|
|
||||||
guard !audioTimeRanges.isEmpty else { return nil }
|
|
||||||
let start = audioTimeRanges.first!.start
|
|
||||||
let end = audioTimeRanges.last!.end
|
|
||||||
var attributes = AttributeContainer()
|
|
||||||
attributes[AttributeScopes.SpeechAttributes.TimeRangeAttribute.self] = CMTimeRange(
|
|
||||||
start: start,
|
|
||||||
end: end)
|
|
||||||
return AttributedString(self[range].characters, attributes: attributes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
public enum LogLevel: String, Comparable, CaseIterable, Sendable {
|
|
||||||
case trace, debug, info, warn, error
|
|
||||||
|
|
||||||
var rank: Int {
|
|
||||||
switch self {
|
|
||||||
case .trace: 0
|
|
||||||
case .debug: 1
|
|
||||||
case .info: 2
|
|
||||||
case .warn: 3
|
|
||||||
case .error: 4
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func < (lhs: LogLevel, rhs: LogLevel) -> Bool { lhs.rank < rhs.rank }
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct Logger: Sendable {
|
|
||||||
public let level: LogLevel
|
|
||||||
|
|
||||||
public init(level: LogLevel) { self.level = level }
|
|
||||||
|
|
||||||
public func log(_ level: LogLevel, _ message: String) {
|
|
||||||
guard level >= self.level else { return }
|
|
||||||
let ts = ISO8601DateFormatter().string(from: Date())
|
|
||||||
print("[\(level.rawValue.uppercased())] \(ts) | \(message)")
|
|
||||||
}
|
|
||||||
|
|
||||||
public func trace(_ msg: String) { log(.trace, msg) }
|
|
||||||
public func debug(_ msg: String) { log(.debug, msg) }
|
|
||||||
public func info(_ msg: String) { log(.info, msg) }
|
|
||||||
public func warn(_ msg: String) { log(.warn, msg) }
|
|
||||||
public func error(_ msg: String) { log(.error, msg) }
|
|
||||||
}
|
|
||||||
|
|
||||||
extension LogLevel {
|
|
||||||
public init?(configValue: String) {
|
|
||||||
self.init(rawValue: configValue.lowercased())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
import CoreMedia
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public enum OutputFormat: String {
|
|
||||||
case txt
|
|
||||||
case srt
|
|
||||||
|
|
||||||
public var needsAudioTimeRange: Bool {
|
|
||||||
switch self {
|
|
||||||
case .srt: true
|
|
||||||
default: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func text(for transcript: AttributedString, maxLength: Int) -> String {
|
|
||||||
switch self {
|
|
||||||
case .txt:
|
|
||||||
return String(transcript.characters)
|
|
||||||
case .srt:
|
|
||||||
func format(_ timeInterval: TimeInterval) -> String {
|
|
||||||
let ms = Int(timeInterval.truncatingRemainder(dividingBy: 1) * 1000)
|
|
||||||
let s = Int(timeInterval) % 60
|
|
||||||
let m = (Int(timeInterval) / 60) % 60
|
|
||||||
let h = Int(timeInterval) / 60 / 60
|
|
||||||
return String(format: "%0.2d:%0.2d:%0.2d,%0.3d", h, m, s, ms)
|
|
||||||
}
|
|
||||||
|
|
||||||
return transcript.sentences(maxLength: maxLength).compactMap { (sentence: AttributedString) -> (
|
|
||||||
CMTimeRange,
|
|
||||||
String)? in
|
|
||||||
guard let timeRange = sentence.audioTimeRange else { return nil }
|
|
||||||
return (timeRange, String(sentence.characters))
|
|
||||||
}.enumerated().map { index, run in
|
|
||||||
let (timeRange, text) = run
|
|
||||||
return """
|
|
||||||
|
|
||||||
\(index + 1)
|
|
||||||
\(format(timeRange.start.seconds)) --> \(format(timeRange.end.seconds))
|
|
||||||
\(text.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
||||||
|
|
||||||
"""
|
|
||||||
}.joined().trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
public actor TranscriptsStore {
|
|
||||||
public static let shared = TranscriptsStore()
|
|
||||||
|
|
||||||
private var entries: [String] = []
|
|
||||||
private let limit = 100
|
|
||||||
private let fileURL: URL
|
|
||||||
|
|
||||||
public init() {
|
|
||||||
let dir = FileManager.default.homeDirectoryForCurrentUser
|
|
||||||
.appendingPathComponent("Library/Application Support/swabble", isDirectory: true)
|
|
||||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
||||||
fileURL = dir.appendingPathComponent("transcripts.log")
|
|
||||||
if let data = try? Data(contentsOf: fileURL),
|
|
||||||
let text = String(data: data, encoding: .utf8) {
|
|
||||||
entries = text.split(separator: "\n").map(String.init).suffix(limit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func append(text: String) {
|
|
||||||
entries.append(text)
|
|
||||||
if entries.count > limit {
|
|
||||||
entries.removeFirst(entries.count - limit)
|
|
||||||
}
|
|
||||||
let body = entries.joined(separator: "\n")
|
|
||||||
try? body.write(to: fileURL, atomically: false, encoding: .utf8)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func latest() -> [String] { entries }
|
|
||||||
}
|
|
||||||
|
|
||||||
extension String {
|
|
||||||
private func appendLine(to url: URL) throws {
|
|
||||||
let data = (self + "\n").data(using: .utf8) ?? Data()
|
|
||||||
if FileManager.default.fileExists(atPath: url.path) {
|
|
||||||
let handle = try FileHandle(forWritingTo: url)
|
|
||||||
try handle.seekToEnd()
|
|
||||||
try handle.write(contentsOf: data)
|
|
||||||
try handle.close()
|
|
||||||
} else {
|
|
||||||
try data.write(to: url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,197 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
public struct WakeWordSegment: Sendable, Equatable {
|
|
||||||
public let text: String
|
|
||||||
public let start: TimeInterval
|
|
||||||
public let duration: TimeInterval
|
|
||||||
public let range: Range<String.Index>?
|
|
||||||
|
|
||||||
public init(text: String, start: TimeInterval, duration: TimeInterval, range: Range<String.Index>? = nil) {
|
|
||||||
self.text = text
|
|
||||||
self.start = start
|
|
||||||
self.duration = duration
|
|
||||||
self.range = range
|
|
||||||
}
|
|
||||||
|
|
||||||
public var end: TimeInterval { start + duration }
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct WakeWordGateConfig: Sendable, Equatable {
|
|
||||||
public var triggers: [String]
|
|
||||||
public var minPostTriggerGap: TimeInterval
|
|
||||||
public var minCommandLength: Int
|
|
||||||
|
|
||||||
public init(
|
|
||||||
triggers: [String],
|
|
||||||
minPostTriggerGap: TimeInterval = 0.45,
|
|
||||||
minCommandLength: Int = 1) {
|
|
||||||
self.triggers = triggers
|
|
||||||
self.minPostTriggerGap = minPostTriggerGap
|
|
||||||
self.minCommandLength = minCommandLength
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct WakeWordGateMatch: Sendable, Equatable {
|
|
||||||
public let triggerEndTime: TimeInterval
|
|
||||||
public let postGap: TimeInterval
|
|
||||||
public let command: String
|
|
||||||
|
|
||||||
public init(triggerEndTime: TimeInterval, postGap: TimeInterval, command: String) {
|
|
||||||
self.triggerEndTime = triggerEndTime
|
|
||||||
self.postGap = postGap
|
|
||||||
self.command = command
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum WakeWordGate {
|
|
||||||
private struct Token {
|
|
||||||
let normalized: String
|
|
||||||
let start: TimeInterval
|
|
||||||
let end: TimeInterval
|
|
||||||
let range: Range<String.Index>?
|
|
||||||
let text: String
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct TriggerTokens {
|
|
||||||
let tokens: [String]
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct MatchCandidate {
|
|
||||||
let index: Int
|
|
||||||
let triggerEnd: TimeInterval
|
|
||||||
let gap: TimeInterval
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func match(
|
|
||||||
transcript: String,
|
|
||||||
segments: [WakeWordSegment],
|
|
||||||
config: WakeWordGateConfig)
|
|
||||||
-> WakeWordGateMatch? {
|
|
||||||
let triggerTokens = normalizeTriggers(config.triggers)
|
|
||||||
guard !triggerTokens.isEmpty else { return nil }
|
|
||||||
|
|
||||||
let tokens = normalizeSegments(segments)
|
|
||||||
guard !tokens.isEmpty else { return nil }
|
|
||||||
|
|
||||||
var best: MatchCandidate?
|
|
||||||
|
|
||||||
for trigger in triggerTokens {
|
|
||||||
let count = trigger.tokens.count
|
|
||||||
guard count > 0, tokens.count > count else { continue }
|
|
||||||
for i in 0...(tokens.count - count - 1) {
|
|
||||||
let matched = (0..<count).allSatisfy { tokens[i + $0].normalized == trigger.tokens[$0] }
|
|
||||||
if !matched { continue }
|
|
||||||
|
|
||||||
let triggerEnd = tokens[i + count - 1].end
|
|
||||||
let nextToken = tokens[i + count]
|
|
||||||
let gap = nextToken.start - triggerEnd
|
|
||||||
if gap < config.minPostTriggerGap { continue }
|
|
||||||
|
|
||||||
if let best, i <= best.index { continue }
|
|
||||||
|
|
||||||
best = MatchCandidate(index: i, triggerEnd: triggerEnd, gap: gap)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let best else { return nil }
|
|
||||||
let command = commandText(transcript: transcript, segments: segments, triggerEndTime: best.triggerEnd)
|
|
||||||
.trimmingCharacters(in: Self.whitespaceAndPunctuation)
|
|
||||||
guard command.count >= config.minCommandLength else { return nil }
|
|
||||||
return WakeWordGateMatch(triggerEndTime: best.triggerEnd, postGap: best.gap, command: command)
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func commandText(
|
|
||||||
transcript: String,
|
|
||||||
segments: [WakeWordSegment],
|
|
||||||
triggerEndTime: TimeInterval)
|
|
||||||
-> String {
|
|
||||||
let threshold = triggerEndTime + 0.001
|
|
||||||
for segment in segments where segment.start >= threshold {
|
|
||||||
if normalizeToken(segment.text).isEmpty { continue }
|
|
||||||
if let range = segment.range {
|
|
||||||
let slice = transcript[range.lowerBound...]
|
|
||||||
return String(slice).trimmingCharacters(in: Self.whitespaceAndPunctuation)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
let text = segments
|
|
||||||
.filter { $0.start >= threshold && !normalizeToken($0.text).isEmpty }
|
|
||||||
.map(\.text)
|
|
||||||
.joined(separator: " ")
|
|
||||||
return text.trimmingCharacters(in: Self.whitespaceAndPunctuation)
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func matchesTextOnly(text: String, triggers: [String]) -> Bool {
|
|
||||||
guard !text.isEmpty else { return false }
|
|
||||||
let normalized = text.lowercased()
|
|
||||||
for trigger in triggers {
|
|
||||||
let token = trigger.trimmingCharacters(in: whitespaceAndPunctuation).lowercased()
|
|
||||||
if token.isEmpty { continue }
|
|
||||||
if normalized.contains(token) { return true }
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func stripWake(text: String, triggers: [String]) -> String {
|
|
||||||
var out = text
|
|
||||||
for trigger in triggers {
|
|
||||||
let token = trigger.trimmingCharacters(in: whitespaceAndPunctuation)
|
|
||||||
guard !token.isEmpty else { continue }
|
|
||||||
out = out.replacingOccurrences(of: token, with: "", options: [.caseInsensitive])
|
|
||||||
}
|
|
||||||
return out.trimmingCharacters(in: whitespaceAndPunctuation)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func normalizeTriggers(_ triggers: [String]) -> [TriggerTokens] {
|
|
||||||
var output: [TriggerTokens] = []
|
|
||||||
for trigger in triggers {
|
|
||||||
let tokens = trigger
|
|
||||||
.split(whereSeparator: { $0.isWhitespace })
|
|
||||||
.map { normalizeToken(String($0)) }
|
|
||||||
.filter { !$0.isEmpty }
|
|
||||||
if tokens.isEmpty { continue }
|
|
||||||
output.append(TriggerTokens(tokens: tokens))
|
|
||||||
}
|
|
||||||
return output
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func normalizeSegments(_ segments: [WakeWordSegment]) -> [Token] {
|
|
||||||
segments.compactMap { segment in
|
|
||||||
let normalized = normalizeToken(segment.text)
|
|
||||||
guard !normalized.isEmpty else { return nil }
|
|
||||||
return Token(
|
|
||||||
normalized: normalized,
|
|
||||||
start: segment.start,
|
|
||||||
end: segment.end,
|
|
||||||
range: segment.range,
|
|
||||||
text: segment.text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func normalizeToken(_ token: String) -> String {
|
|
||||||
token
|
|
||||||
.trimmingCharacters(in: whitespaceAndPunctuation)
|
|
||||||
.lowercased()
|
|
||||||
}
|
|
||||||
|
|
||||||
private static let whitespaceAndPunctuation = CharacterSet.whitespacesAndNewlines
|
|
||||||
.union(.punctuationCharacters)
|
|
||||||
}
|
|
||||||
|
|
||||||
#if canImport(Speech)
|
|
||||||
import Speech
|
|
||||||
|
|
||||||
public enum WakeWordSpeechSegments {
|
|
||||||
public static func from(transcription: SFTranscription, transcript: String) -> [WakeWordSegment] {
|
|
||||||
transcription.segments.map { segment in
|
|
||||||
let range = Range(segment.substringRange, in: transcript)
|
|
||||||
return WakeWordSegment(
|
|
||||||
text: segment.substring,
|
|
||||||
start: segment.timestamp,
|
|
||||||
duration: segment.duration,
|
|
||||||
range: range)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
import Commander
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
@available(macOS 26.0, *)
|
|
||||||
@MainActor
|
|
||||||
enum CLIRegistry {
|
|
||||||
static var descriptors: [CommandDescriptor] {
|
|
||||||
let serveDesc = descriptor(for: ServeCommand.self)
|
|
||||||
let transcribeDesc = descriptor(for: TranscribeCommand.self)
|
|
||||||
let testHookDesc = descriptor(for: TestHookCommand.self)
|
|
||||||
let micList = descriptor(for: MicList.self)
|
|
||||||
let micSet = descriptor(for: MicSet.self)
|
|
||||||
let micRoot = CommandDescriptor(
|
|
||||||
name: "mic",
|
|
||||||
abstract: "Microphone management",
|
|
||||||
discussion: nil,
|
|
||||||
signature: CommandSignature(),
|
|
||||||
subcommands: [micList, micSet])
|
|
||||||
let serviceRoot = CommandDescriptor(
|
|
||||||
name: "service",
|
|
||||||
abstract: "launchd helper",
|
|
||||||
discussion: nil,
|
|
||||||
signature: CommandSignature(),
|
|
||||||
subcommands: [
|
|
||||||
descriptor(for: ServiceInstall.self),
|
|
||||||
descriptor(for: ServiceUninstall.self),
|
|
||||||
descriptor(for: ServiceStatus.self)
|
|
||||||
])
|
|
||||||
let doctorDesc = descriptor(for: DoctorCommand.self)
|
|
||||||
let setupDesc = descriptor(for: SetupCommand.self)
|
|
||||||
let healthDesc = descriptor(for: HealthCommand.self)
|
|
||||||
let tailLogDesc = descriptor(for: TailLogCommand.self)
|
|
||||||
let startDesc = descriptor(for: StartCommand.self)
|
|
||||||
let stopDesc = descriptor(for: StopCommand.self)
|
|
||||||
let restartDesc = descriptor(for: RestartCommand.self)
|
|
||||||
let statusDesc = descriptor(for: StatusCommand.self)
|
|
||||||
|
|
||||||
let rootSignature = CommandSignature().withStandardRuntimeFlags()
|
|
||||||
let root = CommandDescriptor(
|
|
||||||
name: "swabble",
|
|
||||||
abstract: "Speech hook daemon",
|
|
||||||
discussion: "Local wake-word → SpeechTranscriber → hook",
|
|
||||||
signature: rootSignature,
|
|
||||||
subcommands: [
|
|
||||||
serveDesc,
|
|
||||||
transcribeDesc,
|
|
||||||
testHookDesc,
|
|
||||||
micRoot,
|
|
||||||
serviceRoot,
|
|
||||||
doctorDesc,
|
|
||||||
setupDesc,
|
|
||||||
healthDesc,
|
|
||||||
tailLogDesc,
|
|
||||||
startDesc,
|
|
||||||
stopDesc,
|
|
||||||
restartDesc,
|
|
||||||
statusDesc
|
|
||||||
])
|
|
||||||
return [root]
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func descriptor(for type: any ParsableCommand.Type) -> CommandDescriptor {
|
|
||||||
let sig = CommandSignature.describe(type.init()).withStandardRuntimeFlags()
|
|
||||||
return CommandDescriptor(
|
|
||||||
name: type.commandDescription.commandName ?? "",
|
|
||||||
abstract: type.commandDescription.abstract,
|
|
||||||
discussion: type.commandDescription.discussion,
|
|
||||||
signature: sig,
|
|
||||||
subcommands: [])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
import Commander
|
|
||||||
import Foundation
|
|
||||||
import Speech
|
|
||||||
import Swabble
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
struct DoctorCommand: ParsableCommand {
|
|
||||||
static var commandDescription: CommandDescription {
|
|
||||||
CommandDescription(commandName: "doctor", abstract: "Check Speech permission and config")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
|
|
||||||
|
|
||||||
init() {}
|
|
||||||
init(parsed: ParsedValues) {
|
|
||||||
self.init()
|
|
||||||
if let cfg = parsed.options["config"]?.last { configPath = cfg }
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func run() async throws {
|
|
||||||
let auth = await SFSpeechRecognizer.authorizationStatus()
|
|
||||||
print("Speech auth: \(auth)")
|
|
||||||
do {
|
|
||||||
_ = try ConfigLoader.load(at: configURL)
|
|
||||||
print("Config: OK")
|
|
||||||
} catch {
|
|
||||||
print("Config missing or invalid; run setup")
|
|
||||||
}
|
|
||||||
let session = AVCaptureDevice.DiscoverySession(
|
|
||||||
deviceTypes: [.microphone, .external],
|
|
||||||
mediaType: .audio,
|
|
||||||
position: .unspecified)
|
|
||||||
print("Mics found: \(session.devices.count)")
|
|
||||||
}
|
|
||||||
|
|
||||||
private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } }
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
import Commander
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
struct HealthCommand: ParsableCommand {
|
|
||||||
static var commandDescription: CommandDescription {
|
|
||||||
CommandDescription(commandName: "health", abstract: "Health probe")
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {}
|
|
||||||
init(parsed: ParsedValues) {}
|
|
||||||
|
|
||||||
mutating func run() async throws {
|
|
||||||
print("ok")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
import AVFoundation
|
|
||||||
import Commander
|
|
||||||
import Foundation
|
|
||||||
import Swabble
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
struct MicCommand: ParsableCommand {
|
|
||||||
static var commandDescription: CommandDescription {
|
|
||||||
CommandDescription(
|
|
||||||
commandName: "mic",
|
|
||||||
abstract: "Microphone management",
|
|
||||||
subcommands: [MicList.self, MicSet.self])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
struct MicList: ParsableCommand {
|
|
||||||
static var commandDescription: CommandDescription {
|
|
||||||
CommandDescription(commandName: "list", abstract: "List input devices")
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {}
|
|
||||||
init(parsed: ParsedValues) {}
|
|
||||||
|
|
||||||
mutating func run() async throws {
|
|
||||||
let session = AVCaptureDevice.DiscoverySession(
|
|
||||||
deviceTypes: [.microphone, .external],
|
|
||||||
mediaType: .audio,
|
|
||||||
position: .unspecified)
|
|
||||||
let devices = session.devices
|
|
||||||
if devices.isEmpty { print("no audio inputs found"); return }
|
|
||||||
for (idx, device) in devices.enumerated() {
|
|
||||||
print("[\(idx)] \(device.localizedName)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
struct MicSet: ParsableCommand {
|
|
||||||
@Argument(help: "Device index from list") var index: Int = 0
|
|
||||||
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
|
|
||||||
|
|
||||||
static var commandDescription: CommandDescription {
|
|
||||||
CommandDescription(commandName: "set", abstract: "Set default input device index")
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {}
|
|
||||||
init(parsed: ParsedValues) {
|
|
||||||
self.init()
|
|
||||||
if let value = parsed.positional.first, let intVal = Int(value) { index = intVal }
|
|
||||||
if let cfg = parsed.options["config"]?.last { configPath = cfg }
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func run() async throws {
|
|
||||||
var cfg = try ConfigLoader.load(at: configURL)
|
|
||||||
cfg.audio.deviceIndex = index
|
|
||||||
try ConfigLoader.save(cfg, at: configURL)
|
|
||||||
print("saved device index \(index)")
|
|
||||||
}
|
|
||||||
|
|
||||||
private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } }
|
|
||||||
}
|
|
||||||
@ -1,81 +0,0 @@
|
|||||||
import Commander
|
|
||||||
import Foundation
|
|
||||||
import Swabble
|
|
||||||
import SwabbleKit
|
|
||||||
|
|
||||||
@available(macOS 26.0, *)
|
|
||||||
@MainActor
|
|
||||||
struct ServeCommand: ParsableCommand {
|
|
||||||
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
|
|
||||||
@Flag(name: .long("no-wake"), help: "Disable wake word") var noWake: Bool = false
|
|
||||||
|
|
||||||
static var commandDescription: CommandDescription {
|
|
||||||
CommandDescription(
|
|
||||||
commandName: "serve",
|
|
||||||
abstract: "Run swabble in the foreground")
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {}
|
|
||||||
|
|
||||||
init(parsed: ParsedValues) {
|
|
||||||
self.init()
|
|
||||||
if parsed.flags.contains("noWake") { noWake = true }
|
|
||||||
if let cfg = parsed.options["config"]?.last { configPath = cfg }
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func run() async throws {
|
|
||||||
var cfg: SwabbleConfig
|
|
||||||
do {
|
|
||||||
cfg = try ConfigLoader.load(at: configURL)
|
|
||||||
} catch {
|
|
||||||
cfg = SwabbleConfig()
|
|
||||||
try ConfigLoader.save(cfg, at: configURL)
|
|
||||||
}
|
|
||||||
if noWake {
|
|
||||||
cfg.wake.enabled = false
|
|
||||||
}
|
|
||||||
|
|
||||||
let logger = Logger(level: LogLevel(configValue: cfg.logging.level) ?? .info)
|
|
||||||
logger.info("swabble serve starting (wake: \(cfg.wake.enabled ? cfg.wake.word : "disabled"))")
|
|
||||||
let pipeline = SpeechPipeline()
|
|
||||||
do {
|
|
||||||
let stream = try await pipeline.start(
|
|
||||||
localeIdentifier: cfg.speech.localeIdentifier,
|
|
||||||
etiquette: cfg.speech.etiquetteReplacements)
|
|
||||||
for await seg in stream {
|
|
||||||
if cfg.wake.enabled {
|
|
||||||
guard Self.matchesWake(text: seg.text, cfg: cfg) else { continue }
|
|
||||||
}
|
|
||||||
let stripped = Self.stripWake(text: seg.text, cfg: cfg)
|
|
||||||
let job = HookJob(text: stripped, timestamp: Date())
|
|
||||||
let executor = HookExecutor(config: cfg)
|
|
||||||
try await executor.run(job: job)
|
|
||||||
if cfg.transcripts.enabled {
|
|
||||||
await TranscriptsStore.shared.append(text: stripped)
|
|
||||||
}
|
|
||||||
if seg.isFinal {
|
|
||||||
logger.info("final: \(stripped)")
|
|
||||||
} else {
|
|
||||||
logger.debug("partial: \(stripped)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
logger.error("serve error: \(error)")
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var configURL: URL? {
|
|
||||||
configPath.map { URL(fileURLWithPath: $0) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func matchesWake(text: String, cfg: SwabbleConfig) -> Bool {
|
|
||||||
let triggers = [cfg.wake.word] + cfg.wake.aliases
|
|
||||||
return WakeWordGate.matchesTextOnly(text: text, triggers: triggers)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func stripWake(text: String, cfg: SwabbleConfig) -> String {
|
|
||||||
let triggers = [cfg.wake.word] + cfg.wake.aliases
|
|
||||||
return WakeWordGate.stripWake(text: text, triggers: triggers)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,77 +0,0 @@
|
|||||||
import Commander
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
struct ServiceRootCommand: ParsableCommand {
|
|
||||||
static var commandDescription: CommandDescription {
|
|
||||||
CommandDescription(
|
|
||||||
commandName: "service",
|
|
||||||
abstract: "Manage launchd agent",
|
|
||||||
subcommands: [ServiceInstall.self, ServiceUninstall.self, ServiceStatus.self])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum LaunchdHelper {
|
|
||||||
static let label = "com.swabble.agent"
|
|
||||||
|
|
||||||
static var plistURL: URL {
|
|
||||||
FileManager.default
|
|
||||||
.homeDirectoryForCurrentUser
|
|
||||||
.appendingPathComponent("Library/LaunchAgents/\(label).plist")
|
|
||||||
}
|
|
||||||
|
|
||||||
static func writePlist(executable: String) throws {
|
|
||||||
let plist: [String: Any] = [
|
|
||||||
"Label": label,
|
|
||||||
"ProgramArguments": [executable, "serve"],
|
|
||||||
"RunAtLoad": true,
|
|
||||||
"KeepAlive": true
|
|
||||||
]
|
|
||||||
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
|
||||||
try data.write(to: plistURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func removePlist() throws {
|
|
||||||
try? FileManager.default.removeItem(at: plistURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
struct ServiceInstall: ParsableCommand {
|
|
||||||
static var commandDescription: CommandDescription {
|
|
||||||
CommandDescription(commandName: "install", abstract: "Install user launch agent")
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func run() async throws {
|
|
||||||
let exe = CommandLine.arguments.first ?? "/usr/local/bin/swabble"
|
|
||||||
try LaunchdHelper.writePlist(executable: exe)
|
|
||||||
print("launchctl load -w \(LaunchdHelper.plistURL.path)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
struct ServiceUninstall: ParsableCommand {
|
|
||||||
static var commandDescription: CommandDescription {
|
|
||||||
CommandDescription(commandName: "uninstall", abstract: "Remove launch agent")
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func run() async throws {
|
|
||||||
try LaunchdHelper.removePlist()
|
|
||||||
print("launchctl bootout gui/$(id -u)/\(LaunchdHelper.label)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
struct ServiceStatus: ParsableCommand {
|
|
||||||
static var commandDescription: CommandDescription {
|
|
||||||
CommandDescription(commandName: "status", abstract: "Show launch agent status")
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func run() async throws {
|
|
||||||
if FileManager.default.fileExists(atPath: LaunchdHelper.plistURL.path) {
|
|
||||||
print("plist present at \(LaunchdHelper.plistURL.path)")
|
|
||||||
} else {
|
|
||||||
print("launchd plist not installed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
import Commander
|
|
||||||
import Foundation
|
|
||||||
import Swabble
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
struct SetupCommand: ParsableCommand {
|
|
||||||
static var commandDescription: CommandDescription {
|
|
||||||
CommandDescription(commandName: "setup", abstract: "Write default config")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
|
|
||||||
|
|
||||||
init() {}
|
|
||||||
init(parsed: ParsedValues) {
|
|
||||||
self.init()
|
|
||||||
if let cfg = parsed.options["config"]?.last { configPath = cfg }
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func run() async throws {
|
|
||||||
let cfg = SwabbleConfig()
|
|
||||||
try ConfigLoader.save(cfg, at: configURL)
|
|
||||||
print("wrote config to \(configURL?.path ?? SwabbleConfig.defaultPath.path)")
|
|
||||||
}
|
|
||||||
|
|
||||||
private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } }
|
|
||||||
}
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
import Commander
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
struct StartCommand: ParsableCommand {
|
|
||||||
static var commandDescription: CommandDescription {
|
|
||||||
CommandDescription(commandName: "start", abstract: "Start swabble (foreground placeholder)")
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func run() async throws {
|
|
||||||
print("start: launchd helper not implemented; run 'swabble serve' instead")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
struct StopCommand: ParsableCommand {
|
|
||||||
static var commandDescription: CommandDescription {
|
|
||||||
CommandDescription(commandName: "stop", abstract: "Stop swabble (placeholder)")
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func run() async throws {
|
|
||||||
print("stop: launchd helper not implemented yet")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
struct RestartCommand: ParsableCommand {
|
|
||||||
static var commandDescription: CommandDescription {
|
|
||||||
CommandDescription(commandName: "restart", abstract: "Restart swabble (placeholder)")
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func run() async throws {
|
|
||||||
print("restart: launchd helper not implemented yet")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
import Commander
|
|
||||||
import Foundation
|
|
||||||
import Swabble
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
struct StatusCommand: ParsableCommand {
|
|
||||||
static var commandDescription: CommandDescription {
|
|
||||||
CommandDescription(commandName: "status", abstract: "Show daemon state")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
|
|
||||||
|
|
||||||
init() {}
|
|
||||||
init(parsed: ParsedValues) {
|
|
||||||
self.init()
|
|
||||||
if let cfg = parsed.options["config"]?.last { configPath = cfg }
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func run() async throws {
|
|
||||||
let cfg = try? ConfigLoader.load(at: configURL)
|
|
||||||
let wake = cfg?.wake.word ?? "clawd"
|
|
||||||
let wakeEnabled = cfg?.wake.enabled ?? false
|
|
||||||
let latest = await TranscriptsStore.shared.latest().suffix(3)
|
|
||||||
print("wake: \(wakeEnabled ? wake : "disabled")")
|
|
||||||
if latest.isEmpty {
|
|
||||||
print("transcripts: (none yet)")
|
|
||||||
} else {
|
|
||||||
print("last transcripts:")
|
|
||||||
latest.forEach { print("- \($0)") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } }
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
import Commander
|
|
||||||
import Foundation
|
|
||||||
import Swabble
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
struct TailLogCommand: ParsableCommand {
|
|
||||||
static var commandDescription: CommandDescription {
|
|
||||||
CommandDescription(commandName: "tail-log", abstract: "Tail recent transcripts")
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {}
|
|
||||||
init(parsed: ParsedValues) {}
|
|
||||||
|
|
||||||
mutating func run() async throws {
|
|
||||||
let latest = await TranscriptsStore.shared.latest()
|
|
||||||
for line in latest.suffix(10) {
|
|
||||||
print(line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
import Commander
|
|
||||||
import Foundation
|
|
||||||
import Swabble
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
struct TestHookCommand: ParsableCommand {
|
|
||||||
@Argument(help: "Text to send to hook") var text: String
|
|
||||||
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
|
|
||||||
|
|
||||||
static var commandDescription: CommandDescription {
|
|
||||||
CommandDescription(commandName: "test-hook", abstract: "Invoke the configured hook with text")
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {}
|
|
||||||
|
|
||||||
init(parsed: ParsedValues) {
|
|
||||||
self.init()
|
|
||||||
if let positional = parsed.positional.first { text = positional }
|
|
||||||
if let cfg = parsed.options["config"]?.last { configPath = cfg }
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func run() async throws {
|
|
||||||
let cfg = try ConfigLoader.load(at: configURL)
|
|
||||||
let executor = HookExecutor(config: cfg)
|
|
||||||
try await executor.run(job: HookJob(text: text, timestamp: Date()))
|
|
||||||
print("hook invoked")
|
|
||||||
}
|
|
||||||
|
|
||||||
private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } }
|
|
||||||
}
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
import AVFoundation
|
|
||||||
import Commander
|
|
||||||
import Foundation
|
|
||||||
import Speech
|
|
||||||
import Swabble
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
struct TranscribeCommand: ParsableCommand {
|
|
||||||
@Argument(help: "Path to audio/video file") var inputFile: String = ""
|
|
||||||
@Option(name: .long("locale"), help: "Locale identifier", parsing: .singleValue) var locale: String = Locale.current
|
|
||||||
.identifier
|
|
||||||
@Flag(help: "Censor etiquette-sensitive content") var censor: Bool = false
|
|
||||||
@Option(name: .long("output"), help: "Output file path") var outputFile: String?
|
|
||||||
@Option(name: .long("format"), help: "Output format txt|srt") var format: String = "txt"
|
|
||||||
@Option(name: .long("max-length"), help: "Max sentence length for srt") var maxLength: Int = 40
|
|
||||||
|
|
||||||
static var commandDescription: CommandDescription {
|
|
||||||
CommandDescription(
|
|
||||||
commandName: "transcribe",
|
|
||||||
abstract: "Transcribe a media file locally")
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {}
|
|
||||||
|
|
||||||
init(parsed: ParsedValues) {
|
|
||||||
self.init()
|
|
||||||
if let positional = parsed.positional.first { inputFile = positional }
|
|
||||||
if let loc = parsed.options["locale"]?.last { locale = loc }
|
|
||||||
if parsed.flags.contains("censor") { censor = true }
|
|
||||||
if let out = parsed.options["output"]?.last { outputFile = out }
|
|
||||||
if let fmt = parsed.options["format"]?.last { format = fmt }
|
|
||||||
if let len = parsed.options["maxLength"]?.last, let intVal = Int(len) { maxLength = intVal }
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func run() async throws {
|
|
||||||
let fileURL = URL(fileURLWithPath: inputFile)
|
|
||||||
let audioFile = try AVAudioFile(forReading: fileURL)
|
|
||||||
|
|
||||||
let outputFormat = OutputFormat(rawValue: format) ?? .txt
|
|
||||||
|
|
||||||
let transcriber = SpeechTranscriber(
|
|
||||||
locale: Locale(identifier: locale),
|
|
||||||
transcriptionOptions: censor ? [.etiquetteReplacements] : [],
|
|
||||||
reportingOptions: [],
|
|
||||||
attributeOptions: outputFormat.needsAudioTimeRange ? [.audioTimeRange] : [])
|
|
||||||
let analyzer = SpeechAnalyzer(modules: [transcriber])
|
|
||||||
try await analyzer.start(inputAudioFile: audioFile, finishAfterFile: true)
|
|
||||||
|
|
||||||
var transcript: AttributedString = ""
|
|
||||||
for try await result in transcriber.results {
|
|
||||||
transcript += result.text
|
|
||||||
}
|
|
||||||
|
|
||||||
let output = outputFormat.text(for: transcript, maxLength: maxLength)
|
|
||||||
if let path = outputFile {
|
|
||||||
try output.write(to: URL(fileURLWithPath: path), atomically: false, encoding: .utf8)
|
|
||||||
} else {
|
|
||||||
print(output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,151 +0,0 @@
|
|||||||
import Commander
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
@available(macOS 26.0, *)
|
|
||||||
@MainActor
|
|
||||||
private func runCLI() async -> Int32 {
|
|
||||||
do {
|
|
||||||
let descriptors = CLIRegistry.descriptors
|
|
||||||
let program = Program(descriptors: descriptors)
|
|
||||||
let invocation = try program.resolve(argv: CommandLine.arguments)
|
|
||||||
try await dispatch(invocation: invocation)
|
|
||||||
return 0
|
|
||||||
} catch {
|
|
||||||
fputs("error: \(error)\n", stderr)
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(macOS 26.0, *)
|
|
||||||
@MainActor
|
|
||||||
private func dispatch(invocation: CommandInvocation) async throws {
|
|
||||||
let parsed = invocation.parsedValues
|
|
||||||
let path = invocation.path
|
|
||||||
guard let first = path.first else { throw CommanderProgramError.missingCommand }
|
|
||||||
|
|
||||||
switch first {
|
|
||||||
case "swabble":
|
|
||||||
try await dispatchSwabble(parsed: parsed, path: path)
|
|
||||||
default:
|
|
||||||
throw CommanderProgramError.unknownCommand(first)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(macOS 26.0, *)
|
|
||||||
@MainActor
|
|
||||||
private func dispatchSwabble(parsed: ParsedValues, path: [String]) async throws {
|
|
||||||
let sub = try subcommand(path, index: 1, command: "swabble")
|
|
||||||
switch sub {
|
|
||||||
case "mic":
|
|
||||||
try await dispatchMic(parsed: parsed, path: path)
|
|
||||||
case "service":
|
|
||||||
try await dispatchService(path: path)
|
|
||||||
default:
|
|
||||||
let handlers = swabbleHandlers(parsed: parsed)
|
|
||||||
guard let handler = handlers[sub] else {
|
|
||||||
throw CommanderProgramError.unknownSubcommand(command: "swabble", name: sub)
|
|
||||||
}
|
|
||||||
try await handler()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(macOS 26.0, *)
|
|
||||||
@MainActor
|
|
||||||
private func swabbleHandlers(parsed: ParsedValues) -> [String: () async throws -> Void] {
|
|
||||||
[
|
|
||||||
"serve": {
|
|
||||||
var cmd = ServeCommand(parsed: parsed)
|
|
||||||
try await cmd.run()
|
|
||||||
},
|
|
||||||
"transcribe": {
|
|
||||||
var cmd = TranscribeCommand(parsed: parsed)
|
|
||||||
try await cmd.run()
|
|
||||||
},
|
|
||||||
"test-hook": {
|
|
||||||
var cmd = TestHookCommand(parsed: parsed)
|
|
||||||
try await cmd.run()
|
|
||||||
},
|
|
||||||
"doctor": {
|
|
||||||
var cmd = DoctorCommand(parsed: parsed)
|
|
||||||
try await cmd.run()
|
|
||||||
},
|
|
||||||
"setup": {
|
|
||||||
var cmd = SetupCommand(parsed: parsed)
|
|
||||||
try await cmd.run()
|
|
||||||
},
|
|
||||||
"health": {
|
|
||||||
var cmd = HealthCommand(parsed: parsed)
|
|
||||||
try await cmd.run()
|
|
||||||
},
|
|
||||||
"tail-log": {
|
|
||||||
var cmd = TailLogCommand(parsed: parsed)
|
|
||||||
try await cmd.run()
|
|
||||||
},
|
|
||||||
"start": {
|
|
||||||
var cmd = StartCommand()
|
|
||||||
try await cmd.run()
|
|
||||||
},
|
|
||||||
"stop": {
|
|
||||||
var cmd = StopCommand()
|
|
||||||
try await cmd.run()
|
|
||||||
},
|
|
||||||
"restart": {
|
|
||||||
var cmd = RestartCommand()
|
|
||||||
try await cmd.run()
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
var cmd = StatusCommand()
|
|
||||||
try await cmd.run()
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(macOS 26.0, *)
|
|
||||||
@MainActor
|
|
||||||
private func dispatchMic(parsed: ParsedValues, path: [String]) async throws {
|
|
||||||
let micSub = try subcommand(path, index: 2, command: "mic")
|
|
||||||
switch micSub {
|
|
||||||
case "list":
|
|
||||||
var cmd = MicList(parsed: parsed)
|
|
||||||
try await cmd.run()
|
|
||||||
case "set":
|
|
||||||
var cmd = MicSet(parsed: parsed)
|
|
||||||
try await cmd.run()
|
|
||||||
default:
|
|
||||||
throw CommanderProgramError.unknownSubcommand(command: "mic", name: micSub)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(macOS 26.0, *)
|
|
||||||
@MainActor
|
|
||||||
private func dispatchService(path: [String]) async throws {
|
|
||||||
let svcSub = try subcommand(path, index: 2, command: "service")
|
|
||||||
switch svcSub {
|
|
||||||
case "install":
|
|
||||||
var cmd = ServiceInstall()
|
|
||||||
try await cmd.run()
|
|
||||||
case "uninstall":
|
|
||||||
var cmd = ServiceUninstall()
|
|
||||||
try await cmd.run()
|
|
||||||
case "status":
|
|
||||||
var cmd = ServiceStatus()
|
|
||||||
try await cmd.run()
|
|
||||||
default:
|
|
||||||
throw CommanderProgramError.unknownSubcommand(command: "service", name: svcSub)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func subcommand(_ path: [String], index: Int, command: String) throws -> String {
|
|
||||||
guard path.count > index else {
|
|
||||||
throw CommanderProgramError.missingSubcommand(command: command)
|
|
||||||
}
|
|
||||||
return path[index]
|
|
||||||
}
|
|
||||||
|
|
||||||
if #available(macOS 26.0, *) {
|
|
||||||
let exitCode = await runCLI()
|
|
||||||
exit(exitCode)
|
|
||||||
} else {
|
|
||||||
fputs("error: swabble requires macOS 26 or newer\n", stderr)
|
|
||||||
exit(1)
|
|
||||||
}
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import SwabbleKit
|
|
||||||
import Testing
|
|
||||||
|
|
||||||
@Suite struct WakeWordGateTests {
|
|
||||||
@Test func matchRequiresGapAfterTrigger() {
|
|
||||||
let transcript = "hey clawd do thing"
|
|
||||||
let segments = makeSegments(
|
|
||||||
transcript: transcript,
|
|
||||||
words: [
|
|
||||||
("hey", 0.0, 0.1),
|
|
||||||
("clawd", 0.2, 0.1),
|
|
||||||
("do", 0.35, 0.1),
|
|
||||||
("thing", 0.5, 0.1),
|
|
||||||
])
|
|
||||||
let config = WakeWordGateConfig(triggers: ["clawd"], minPostTriggerGap: 0.3)
|
|
||||||
#expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config) == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func matchAllowsGapAndExtractsCommand() {
|
|
||||||
let transcript = "hey clawd do thing"
|
|
||||||
let segments = makeSegments(
|
|
||||||
transcript: transcript,
|
|
||||||
words: [
|
|
||||||
("hey", 0.0, 0.1),
|
|
||||||
("clawd", 0.2, 0.1),
|
|
||||||
("do", 0.9, 0.1),
|
|
||||||
("thing", 1.1, 0.1),
|
|
||||||
])
|
|
||||||
let config = WakeWordGateConfig(triggers: ["clawd"], minPostTriggerGap: 0.3)
|
|
||||||
let match = WakeWordGate.match(transcript: transcript, segments: segments, config: config)
|
|
||||||
#expect(match?.command == "do thing")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func matchHandlesMultiWordTriggers() {
|
|
||||||
let transcript = "hey clawd do it"
|
|
||||||
let segments = makeSegments(
|
|
||||||
transcript: transcript,
|
|
||||||
words: [
|
|
||||||
("hey", 0.0, 0.1),
|
|
||||||
("clawd", 0.2, 0.1),
|
|
||||||
("do", 0.8, 0.1),
|
|
||||||
("it", 1.0, 0.1),
|
|
||||||
])
|
|
||||||
let config = WakeWordGateConfig(triggers: ["hey clawd"], minPostTriggerGap: 0.3)
|
|
||||||
let match = WakeWordGate.match(transcript: transcript, segments: segments, config: config)
|
|
||||||
#expect(match?.command == "do it")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makeSegments(
|
|
||||||
transcript: String,
|
|
||||||
words: [(String, TimeInterval, TimeInterval)])
|
|
||||||
-> [WakeWordSegment] {
|
|
||||||
var searchStart = transcript.startIndex
|
|
||||||
var output: [WakeWordSegment] = []
|
|
||||||
for (word, start, duration) in words {
|
|
||||||
let range = transcript.range(of: word, range: searchStart..<transcript.endIndex)
|
|
||||||
output.append(WakeWordSegment(text: word, start: start, duration: duration, range: range))
|
|
||||||
if let range { searchStart = range.upperBound }
|
|
||||||
}
|
|
||||||
return output
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Testing
|
|
||||||
@testable import Swabble
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func configRoundTrip() throws {
|
|
||||||
var cfg = SwabbleConfig()
|
|
||||||
cfg.wake.word = "robot"
|
|
||||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".json")
|
|
||||||
defer { try? FileManager.default.removeItem(at: url) }
|
|
||||||
|
|
||||||
try ConfigLoader.save(cfg, at: url)
|
|
||||||
let loaded = try ConfigLoader.load(at: url)
|
|
||||||
#expect(loaded.wake.word == "robot")
|
|
||||||
#expect(loaded.hook.prefix.contains("Voice swabble"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func configMissingThrows() {
|
|
||||||
#expect(throws: ConfigError.missingConfig) {
|
|
||||||
_ = try ConfigLoader.load(at: FileManager.default.temporaryDirectory.appendingPathComponent("nope.json"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
# swabble — macOS 26 speech hook daemon (Swift 6.2)
|
|
||||||
|
|
||||||
Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framework (SpeechAnalyzer + SpeechTranscriber) instead of whisper.cpp. Local-only, wake word gated, dispatches a shell hook with the transcript. Shared wake-gate utilities live in `SwabbleKit` for reuse by other apps (iOS/macOS).
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
- macOS 26+, Swift 6.2, Speech.framework with on-device assets.
|
|
||||||
- Local only; no network calls during transcription.
|
|
||||||
- Wake word gating (default "clawd" plus aliases) with bypass flag `--no-wake`.
|
|
||||||
- `SwabbleKit` target (multi-platform) providing wake-word gating helpers that can use speech segment timing to require a post-trigger gap.
|
|
||||||
- Hook execution with cooldown, min_chars, timeout, prefix, env vars.
|
|
||||||
- Simple config at `~/.config/swabble/config.json` (JSON, Codable) — no TOML.
|
|
||||||
- CLI implemented with Commander (SwiftPM package `steipete/Commander`); core types are available via the SwiftPM library product `Swabble` for embedding.
|
|
||||||
- Foreground `serve`; later launchd helper for start/stop/restart.
|
|
||||||
- File transcription command emitting txt or srt.
|
|
||||||
- Basic status/health surfaces and mic selection stubs.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
- **CLI layer (Commander)**: Root command `swabble` with subcommands `serve`, `transcribe`, `test-hook`, `mic list|set`, `doctor`, `health`, `tail-log`. Runtime flags from Commander (`-v/--verbose`, `--json-output`, `--log-level`). Custom `--config` path applies everywhere.
|
|
||||||
- **Config**: `SwabbleConfig` Codable. Fields: audio device name/index, wake (enabled/word/aliases/sensitivity placeholder), hook (command/args/prefix/cooldown/min_chars/timeout/env), logging (level, format), transcripts (enabled, max kept), speech (locale, enableEtiquetteReplacements flag). Stored JSON; default written by `setup`.
|
|
||||||
- **Audio + Speech pipeline**: `SpeechPipeline` wraps `AVAudioEngine` input → `SpeechAnalyzer` with `SpeechTranscriber` module. Emits partial/final transcripts via async stream. Requests `.audioTimeRange` when transcripts enabled. Handles Speech permission and asset download prompts ahead of capture.
|
|
||||||
- **Wake gate**: CLI currently uses text-only keyword match; shared `SwabbleKit` gate can enforce a minimum pause between the wake word and the next token when speech segments are available. `--no-wake` disables gating.
|
|
||||||
- **Hook executor**: async `HookExecutor` spawns `Process` with configured args, prefix substitution `${hostname}`. Enforces cooldown + timeout; injects env `SWABBLE_TEXT`, `SWABBLE_PREFIX` plus user env map.
|
|
||||||
- **Transcripts store**: in-memory ring buffer; optional persisted JSON lines under `~/Library/Application Support/swabble/transcripts.log`.
|
|
||||||
- **Logging**: simple structured logger to stderr; respects log level.
|
|
||||||
|
|
||||||
## Out of scope (initial cut)
|
|
||||||
- Model management (Speech handles assets).
|
|
||||||
- Launchd helper (planned follow-up).
|
|
||||||
- Advanced wake-word detector (segment-aware gate now lives in `SwabbleKit`; CLI still text-only until segment timing is plumbed through).
|
|
||||||
|
|
||||||
## Open decisions
|
|
||||||
- Whether to expose a UNIX control socket for `status`/`health` (currently planned as stdin/out direct calls).
|
|
||||||
- Hook redaction (PII) parity with brabble — placeholder boolean, no implementation yet.
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -euo pipefail
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
||||||
CONFIG="${ROOT}/.swiftformat"
|
|
||||||
swiftformat --config "$CONFIG" "$ROOT/Sources"
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -euo pipefail
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
||||||
CONFIG="${ROOT}/.swiftlint.yml"
|
|
||||||
if ! command -v swiftlint >/dev/null; then
|
|
||||||
echo "swiftlint not installed" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
swiftlint --config "$CONFIG"
|
|
||||||
233
appcast.xml
233
appcast.xml
@ -1,233 +0,0 @@
|
|||||||
<?xml version="1.0" standalone="yes"?>
|
|
||||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
|
||||||
<channel>
|
|
||||||
<title>OpenClaw</title>
|
|
||||||
<item>
|
|
||||||
<title>2026.1.29</title>
|
|
||||||
<pubDate>Fri, 30 Jan 2026 06:24:15 +0100</pubDate>
|
|
||||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
|
||||||
<sparkle:version>8345</sparkle:version>
|
|
||||||
<sparkle:shortVersionString>2026.1.29</sparkle:shortVersionString>
|
|
||||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
|
||||||
<description><![CDATA[<h2>OpenClaw 2026.1.29</h2>
|
|
||||||
Status: stable.
|
|
||||||
<h3>Changes</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Rebrand: rename the npm package/CLI to <code>openclaw</code>, add a <code>openclaw</code> compatibility shim, and move extensions to the <code>@openclaw/*</code> scope.</li>
|
|
||||||
<li>Onboarding: strengthen security warning copy for beta + access control expectations.</li>
|
|
||||||
<li>Onboarding: add Venice API key to non-interactive flow. (#1893) Thanks @jonisjongithub.</li>
|
|
||||||
<li>Config: auto-migrate legacy state/config paths and keep config resolution consistent across legacy filenames.</li>
|
|
||||||
<li>Gateway: warn on hook tokens via query params; document header auth preference. (#2200) Thanks @YuriNachos.</li>
|
|
||||||
<li>Gateway: add dangerous Control UI device auth bypass flag + audit warnings. (#2248)</li>
|
|
||||||
<li>Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz.</li>
|
|
||||||
<li>Web UI: keep sub-agent announce replies visible in WebChat. (#1977) Thanks @andrescardonas7.</li>
|
|
||||||
<li>Browser: route browser control via gateway/node; remove standalone browser control command and control URL config.</li>
|
|
||||||
<li>Browser: route <code>browser.request</code> via node proxies when available; honor proxy timeouts; derive browser ports from <code>gateway.port</code>.</li>
|
|
||||||
<li>Browser: fall back to URL matching for extension relay target resolution. (#1999) Thanks @jonit-dev.</li>
|
|
||||||
<li>Telegram: allow caption param for media sends. (#1888) Thanks @mguellsegarra.</li>
|
|
||||||
<li>Telegram: support plugin sendPayload channelData (media/buttons) and validate plugin commands. (#1917) Thanks @JoshuaLelon.</li>
|
|
||||||
<li>Telegram: avoid block replies when streaming is disabled. (#1885) Thanks @ivancasco.</li>
|
|
||||||
<li>Telegram: add optional silent send flag (disable notifications). (#2382) Thanks @Suksham-sharma.</li>
|
|
||||||
<li>Telegram: support editing sent messages via message(action="edit"). (#2394) Thanks @marcelomar21.</li>
|
|
||||||
<li>Telegram: support quote replies for message tool and inbound context. (#2900) Thanks @aduk059.</li>
|
|
||||||
<li>Telegram: add sticker receive/send with vision caching. (#2629) Thanks @longjos.</li>
|
|
||||||
<li>Telegram: send sticker pixels to vision models. (#2650)</li>
|
|
||||||
<li>Telegram: keep topic IDs in restart sentinel notifications. (#1807) Thanks @hsrvc.</li>
|
|
||||||
<li>Discord: add configurable privileged gateway intents for presences/members. (#2266) Thanks @kentaro.</li>
|
|
||||||
<li>Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999.</li>
|
|
||||||
<li>Matrix: switch plugin SDK to @vector-im/matrix-bot-sdk.</li>
|
|
||||||
<li>Tlon: format thread reply IDs as @ud. (#1837) Thanks @wca4a.</li>
|
|
||||||
<li>Tools: add per-sender group tool policies and fix precedence. (#1757) Thanks @adam91holt.</li>
|
|
||||||
<li>Agents: summarize dropped messages during compaction safeguard pruning. (#2509) Thanks @jogi47.</li>
|
|
||||||
<li>Agents: expand cron tool description with full schema docs. (#1988) Thanks @tomascupr.</li>
|
|
||||||
<li>Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281)</li>
|
|
||||||
<li>Memory Search: allow extra paths for memory indexing (ignores symlinks). (#3600) Thanks @kira-ariaki.</li>
|
|
||||||
<li>Skills: add multi-image input support to Nano Banana Pro skill. (#1958) Thanks @tyler6204.</li>
|
|
||||||
<li>Skills: add missing dependency metadata for GitHub, Notion, Slack, Discord. (#1995) Thanks @jackheuberger.</li>
|
|
||||||
<li>Commands: group /help and /commands output with Telegram paging. (#2504) Thanks @hougangdev.</li>
|
|
||||||
<li>Routing: add per-account DM session scope and document multi-account isolation. (#3095) Thanks @jarvis-sam.</li>
|
|
||||||
<li>Routing: precompile session key regexes. (#1697) Thanks @Ray0907.</li>
|
|
||||||
<li>CLI: use Node's module compile cache for faster startup. (#2808) Thanks @pi0.</li>
|
|
||||||
<li>Auth: show copyable Google auth URL after ASCII prompt. (#1787) Thanks @robbyczgw-cla.</li>
|
|
||||||
<li>TUI: avoid width overflow when rendering selection lists. (#1686) Thanks @mossein.</li>
|
|
||||||
<li>macOS: finish OpenClaw app rename for macOS sources, bundle identifiers, and shared kit paths. (#2844) Thanks @fal3.</li>
|
|
||||||
<li>Branding: update launchd labels, mobile bundle IDs, and logging subsystems to bot.molt (legacy com.clawdbot migrations). Thanks @thewilloftheshadow.</li>
|
|
||||||
<li>macOS: limit project-local <code>node_modules/.bin</code> PATH preference to debug builds (reduce PATH hijacking risk).</li>
|
|
||||||
<li>macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal.</li>
|
|
||||||
<li>macOS: avoid crash when rendering code blocks by bumping Textual to 0.3.1. (#2033) Thanks @garricn.</li>
|
|
||||||
<li>Update: ignore dist/control-ui for dirty checks and restore after ui builds. (#1976) Thanks @Glucksberg.</li>
|
|
||||||
<li>Build: bundle A2UI assets during build and stop tracking generated bundles. (#2455) Thanks @0oAstro.</li>
|
|
||||||
<li>CI: increase Node heap size for macOS checks. (#1890) Thanks @realZachi.</li>
|
|
||||||
<li>Config: apply config.env before ${VAR} substitution. (#1813) Thanks @spanishflu-est1918.</li>
|
|
||||||
<li>Gateway: prefer newest session metadata when combining stores. (#1823) Thanks @emanuelst.</li>
|
|
||||||
<li>Docs: tighten Fly private deployment steps. (#2289) Thanks @dguido.</li>
|
|
||||||
<li>Docs: add migration guide for moving to a new machine. (#2381)</li>
|
|
||||||
<li>Docs: add Northflank one-click deployment guide. (#2167) Thanks @AdeboyeDN.</li>
|
|
||||||
<li>Docs: add Vercel AI Gateway to providers sidebar. (#1901) Thanks @jerilynzheng.</li>
|
|
||||||
<li>Docs: add Render deployment guide. (#1975) Thanks @anurag.</li>
|
|
||||||
<li>Docs: add Claude Max API Proxy guide. (#1875) Thanks @atalovesyou.</li>
|
|
||||||
<li>Docs: add DigitalOcean deployment guide. (#1870) Thanks @0xJonHoldsCrypto.</li>
|
|
||||||
<li>Docs: add Oracle Cloud (OCI) platform guide + cross-links. (#2333) Thanks @hirefrank.</li>
|
|
||||||
<li>Docs: add Raspberry Pi install guide. (#1871) Thanks @0xJonHoldsCrypto.</li>
|
|
||||||
<li>Docs: add GCP Compute Engine deployment guide. (#1848) Thanks @hougangdev.</li>
|
|
||||||
<li>Docs: add LINE channel guide. Thanks @thewilloftheshadow.</li>
|
|
||||||
<li>Docs: credit both contributors for Control UI refresh. (#1852) Thanks @EnzeD.</li>
|
|
||||||
<li>Docs: keep docs header sticky so navbar stays visible while scrolling. (#2445) Thanks @chenyuan99.</li>
|
|
||||||
<li>Docs: update exe.dev install instructions. (#https://github.com/openclaw/openclaw/pull/3047) Thanks @zackerthescar.</li>
|
|
||||||
</ul>
|
|
||||||
<h3>Breaking</h3>
|
|
||||||
<ul>
|
|
||||||
<li><strong>BREAKING:</strong> Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).</li>
|
|
||||||
</ul>
|
|
||||||
<h3>Fixes</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Telegram: avoid silent empty replies by tracking normalization skips before fallback. (#3796)</li>
|
|
||||||
<li>Mentions: honor mentionPatterns even when explicit mentions are present. (#3303) Thanks @HirokiKobayashi-R.</li>
|
|
||||||
<li>Discord: restore username directory lookup in target resolution. (#3131) Thanks @bonald.</li>
|
|
||||||
<li>Agents: align MiniMax base URL test expectation with default provider config. (#3131) Thanks @bonald.</li>
|
|
||||||
<li>Agents: prevent retries on oversized image errors and surface size limits. (#2871) Thanks @Suksham-sharma.</li>
|
|
||||||
<li>Agents: inherit provider baseUrl/api for inline models. (#2740) Thanks @lploc94.</li>
|
|
||||||
<li>Memory Search: keep auto provider model defaults and only include remote when configured. (#2576) Thanks @papago2355.</li>
|
|
||||||
<li>Telegram: include AccountId in native command context for multi-agent routing. (#2942) Thanks @Chloe-VP.</li>
|
|
||||||
<li>Telegram: handle video note attachments in media extraction. (#2905) Thanks @mylukin.</li>
|
|
||||||
<li>TTS: read OPENAI_TTS_BASE_URL at runtime instead of module load to honor config.env. (#3341) Thanks @hclsys.</li>
|
|
||||||
<li>macOS: auto-scroll to bottom when sending a new message while scrolled up. (#2471) Thanks @kennyklee.</li>
|
|
||||||
<li>Web UI: auto-expand the chat compose textarea while typing (with sensible max height). (#2950) Thanks @shivamraut101.</li>
|
|
||||||
<li>Gateway: prevent crashes on transient network errors (fetch failures, timeouts, DNS). Added fatal error detection to only exit on truly critical errors. Fixes #2895, #2879, #2873. (#2980) Thanks @elliotsecops.</li>
|
|
||||||
<li>Agents: guard channel tool listActions to avoid plugin crashes. (#2859) Thanks @mbelinky.</li>
|
|
||||||
<li>Discord: stop resolveDiscordTarget from passing directory params into messaging target parsers. Fixes #3167. Thanks @thewilloftheshadow.</li>
|
|
||||||
<li>Discord: avoid resolving bare channel names to user DMs when a username matches. Thanks @thewilloftheshadow.</li>
|
|
||||||
<li>Discord: fix directory config type import for target resolution. Thanks @thewilloftheshadow.</li>
|
|
||||||
<li>Providers: update MiniMax API endpoint and compatibility mode. (#3064) Thanks @hlbbbbbbb.</li>
|
|
||||||
<li>Telegram: treat more network errors as recoverable in polling. (#3013) Thanks @ryancontent.</li>
|
|
||||||
<li>Discord: resolve usernames to user IDs for outbound messages. (#2649) Thanks @nonggialiang.</li>
|
|
||||||
<li>Providers: update Moonshot Kimi model references to kimi-k2.5. (#2762) Thanks @MarvinCui.</li>
|
|
||||||
<li>Gateway: suppress AbortError and transient network errors in unhandled rejections. (#2451) Thanks @Glucksberg.</li>
|
|
||||||
<li>TTS: keep /tts status replies on text-only commands and avoid duplicate block-stream audio. (#2451) Thanks @Glucksberg.</li>
|
|
||||||
<li>Security: pin npm overrides to keep tar@7.5.4 for install toolchains.</li>
|
|
||||||
<li>Security: properly test Windows ACL audit for config includes. (#2403) Thanks @dominicnunez.</li>
|
|
||||||
<li>CLI: recognize versioned Node executables when parsing argv. (#2490) Thanks @David-Marsh-Photo.</li>
|
|
||||||
<li>CLI: avoid prompting for gateway runtime under the spinner. (#2874)</li>
|
|
||||||
<li>BlueBubbles: coalesce inbound URL link preview messages. (#1981) Thanks @tyler6204.</li>
|
|
||||||
<li>Cron: allow payloads containing "heartbeat" in event filter. (#2219) Thanks @dwfinkelstein.</li>
|
|
||||||
<li>CLI: avoid loading config for global help/version while registering plugin commands. (#2212) Thanks @dial481.</li>
|
|
||||||
<li>Agents: include memory.md when bootstrapping memory context. (#2318) Thanks @czekaj.</li>
|
|
||||||
<li>Agents: release session locks on process termination and cover more signals. (#2483) Thanks @janeexai.</li>
|
|
||||||
<li>Agents: skip cooldowned providers during model failover. (#2143) Thanks @YiWang24.</li>
|
|
||||||
<li>Telegram: harden polling + retry behavior for transient network errors and Node 22 transport issues. (#2420) Thanks @techboss.</li>
|
|
||||||
<li>Telegram: ignore non-forum group message_thread_id while preserving DM thread sessions. (#2731) Thanks @dylanneve1.</li>
|
|
||||||
<li>Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos.</li>
|
|
||||||
<li>Telegram: centralize API error logging for delivery and bot calls. (#2492) Thanks @altryne.</li>
|
|
||||||
<li>Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default.</li>
|
|
||||||
<li>Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers.</li>
|
|
||||||
<li>Media: fix text attachment MIME misclassification with CSV/TSV inference and UTF-16 detection; add XML attribute escaping for file output. (#3628) Thanks @frankekn.</li>
|
|
||||||
<li>Build: align memory-core peer dependency with lockfile.</li>
|
|
||||||
<li>Security: add mDNS discovery mode with minimal default to reduce information disclosure. (#1882) Thanks @orlyjamie.</li>
|
|
||||||
<li>Security: harden URL fetches with DNS pinning to reduce rebinding risk. Thanks Chris Zheng.</li>
|
|
||||||
<li>Web UI: improve WebChat image paste previews and allow image-only sends. (#1925) Thanks @smartprogrammer93.</li>
|
|
||||||
<li>Security: wrap external hook content by default with a per-hook opt-out. (#1827) Thanks @mertcicekci0.</li>
|
|
||||||
<li>Gateway: default auth now fail-closed (token/password required; Tailscale Serve identity remains allowed).</li>
|
|
||||||
<li>Gateway: treat loopback + non-local Host connections as remote unless trusted proxy headers are present.</li>
|
|
||||||
<li>Onboarding: remove unsupported gateway auth "off" choice from onboarding/configure flows and CLI flags.</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.1.29/OpenClaw-2026.1.29.zip" length="22458204" type="application/octet-stream" sparkle:edSignature="HqHwZHQyG/CEfBuQnQ/RffJQPKpSbCVrho9C6rgt93S5ek4AH6hUhB3BBKY8sbX1IVFATKK5QZZNE0YPAf7eBw=="/>
|
|
||||||
</item>
|
|
||||||
<item>
|
|
||||||
<title>2026.1.24-1</title>
|
|
||||||
<pubDate>Sun, 25 Jan 2026 14:05:25 +0000</pubDate>
|
|
||||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
|
||||||
<sparkle:version>7952</sparkle:version>
|
|
||||||
<sparkle:shortVersionString>2026.1.24-1</sparkle:shortVersionString>
|
|
||||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
|
||||||
<description><![CDATA[<h2>OpenClaw 2026.1.24-1</h2>
|
|
||||||
<h3>Fixes</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Packaging: include dist/shared output in npm tarball (fixes missing reasoning-tags import on install).</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.1.24-1/OpenClaw-2026.1.24-1.zip" length="12396699" type="application/octet-stream" sparkle:edSignature="VaEdWIgEJBrZLIp2UmigoQ6vaq4P/jNFXpHYXvXHD5MsATS0CqBl6ugyyxRq+/GbpUqmdgdlht4dTUVbLRw6BA=="/>
|
|
||||||
</item>
|
|
||||||
<item>
|
|
||||||
<title>2026.1.24</title>
|
|
||||||
<pubDate>Sun, 25 Jan 2026 13:31:05 +0000</pubDate>
|
|
||||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
|
||||||
<sparkle:version>7944</sparkle:version>
|
|
||||||
<sparkle:shortVersionString>2026.1.24</sparkle:shortVersionString>
|
|
||||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
|
||||||
<description><![CDATA[<h2>OpenClaw 2026.1.24</h2>
|
|
||||||
<h3>Highlights</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Providers: Ollama discovery + docs; Venice guide upgrades + cross-links. (#1606) Thanks @abhaymundhara. https://docs.openclaw.ai/providers/ollama https://docs.openclaw.ai/providers/venice</li>
|
|
||||||
<li>Channels: LINE plugin (Messaging API) with rich replies + quick replies. (#1630) Thanks @plum-dawg.</li>
|
|
||||||
<li>TTS: Edge fallback (keyless) + <code>/tts</code> auto modes. (#1668, #1667) Thanks @steipete, @sebslight. https://docs.openclaw.ai/tts</li>
|
|
||||||
<li>Exec approvals: approve in-chat via <code>/approve</code> across all channels (including plugins). (#1621) Thanks @czekaj. https://docs.openclaw.ai/tools/exec-approvals https://docs.openclaw.ai/tools/slash-commands</li>
|
|
||||||
<li>Telegram: DM topics as separate sessions + outbound link preview toggle. (#1597, #1700) Thanks @rohannagpal, @zerone0x. https://docs.openclaw.ai/channels/telegram</li>
|
|
||||||
</ul>
|
|
||||||
<h3>Changes</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Channels: add LINE plugin (Messaging API) with rich replies, quick replies, and plugin HTTP registry. (#1630) Thanks @plum-dawg.</li>
|
|
||||||
<li>TTS: add Edge TTS provider fallback, defaulting to keyless Edge with MP3 retry on format failures. (#1668) Thanks @steipete. https://docs.openclaw.ai/tts</li>
|
|
||||||
<li>TTS: add auto mode enum (off/always/inbound/tagged) with per-session <code>/tts</code> override. (#1667) Thanks @sebslight. https://docs.openclaw.ai/tts</li>
|
|
||||||
<li>Telegram: treat DM topics as separate sessions and keep DM history limits stable with thread suffixes. (#1597) Thanks @rohannagpal.</li>
|
|
||||||
<li>Telegram: add <code>channels.telegram.linkPreview</code> to toggle outbound link previews. (#1700) Thanks @zerone0x. https://docs.openclaw.ai/channels/telegram</li>
|
|
||||||
<li>Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.openclaw.ai/tools/web</li>
|
|
||||||
<li>UI: refresh Control UI dashboard design system (typography, colors, spacing). (#1786) Thanks @mousberg.</li>
|
|
||||||
<li>Exec approvals: forward approval prompts to chat with <code>/approve</code> for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.openclaw.ai/tools/exec-approvals https://docs.openclaw.ai/tools/slash-commands</li>
|
|
||||||
<li>Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg.</li>
|
|
||||||
<li>Diagnostics: add diagnostic flags for targeted debug logs (config + env override). https://docs.openclaw.ai/diagnostics/flags</li>
|
|
||||||
<li>Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround).</li>
|
|
||||||
<li>Docs: add verbose installer troubleshooting guidance.</li>
|
|
||||||
<li>Docs: add macOS VM guide with local/hosted options + VPS/nodes guidance. (#1693) Thanks @f-trycua.</li>
|
|
||||||
<li>Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.openclaw.ai/bedrock</li>
|
|
||||||
<li>Docs: update Fly.io guide notes.</li>
|
|
||||||
<li>Dev: add prek pre-commit hooks + dependabot config for weekly updates. (#1720) Thanks @dguido.</li>
|
|
||||||
</ul>
|
|
||||||
<h3>Fixes</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Web UI: fix config/debug layout overflow, scrolling, and code block sizing. (#1715) Thanks @saipreetham589.</li>
|
|
||||||
<li>Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.</li>
|
|
||||||
<li>Web UI: clear stale disconnect banners on reconnect; allow form saves with unsupported schema paths but block missing schema. (#1707) Thanks @Glucksberg.</li>
|
|
||||||
<li>Web UI: hide internal <code>message_id</code> hints in chat bubbles.</li>
|
|
||||||
<li>Gateway: allow Control UI token-only auth to skip device pairing even when device identity is present (<code>gateway.controlUi.allowInsecureAuth</code>). (#1679) Thanks @steipete.</li>
|
|
||||||
<li>Matrix: decrypt E2EE media attachments with preflight size guard. (#1744) Thanks @araa47.</li>
|
|
||||||
<li>BlueBubbles: route phone-number targets to DMs, avoid leaking routing IDs, and auto-create missing DMs (Private API required). (#1751) Thanks @tyler6204. https://docs.openclaw.ai/channels/bluebubbles</li>
|
|
||||||
<li>BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.</li>
|
|
||||||
<li>Signal: repair reaction sends (group/UUID targets + CLI author flags). (#1651) Thanks @vilkasdev.</li>
|
|
||||||
<li>Signal: add configurable signal-cli startup timeout + external daemon mode docs. (#1677) https://docs.openclaw.ai/channels/signal</li>
|
|
||||||
<li>Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338.</li>
|
|
||||||
<li>Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)</li>
|
|
||||||
<li>Telegram: honor per-account proxy for outbound API calls. (#1774) Thanks @radek-paclt.</li>
|
|
||||||
<li>Telegram: fall back to text when voice notes are blocked by privacy settings. (#1725) Thanks @foeken.</li>
|
|
||||||
<li>Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)</li>
|
|
||||||
<li>Voice Call: serialize Twilio TTS playback and cancel on barge-in to prevent overlap. (#1713) Thanks @dguido.</li>
|
|
||||||
<li>Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy.</li>
|
|
||||||
<li>Google Chat: normalize space targets without double <code>spaces/</code> prefix.</li>
|
|
||||||
<li>Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.</li>
|
|
||||||
<li>Agents: use the active auth profile for auto-compaction recovery.</li>
|
|
||||||
<li>Media understanding: skip image understanding when the primary model already supports vision. (#1747) Thanks @tyler6204.</li>
|
|
||||||
<li>Models: default missing custom provider fields so minimal configs are accepted.</li>
|
|
||||||
<li>Messaging: keep newline chunking safe for fenced markdown blocks across channels.</li>
|
|
||||||
<li>TUI: reload history after gateway reconnect to restore session state. (#1663)</li>
|
|
||||||
<li>Heartbeat: normalize target identifiers for consistent routing.</li>
|
|
||||||
<li>Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.</li>
|
|
||||||
<li>Exec: treat Windows platform labels as Windows for node shell selection. (#1760) Thanks @ymat19.</li>
|
|
||||||
<li>Gateway: include inline config env vars in service install environments. (#1735) Thanks @Seredeep.</li>
|
|
||||||
<li>Gateway: skip Tailscale DNS probing when tailscale.mode is off. (#1671)</li>
|
|
||||||
<li>Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.</li>
|
|
||||||
<li>Gateway: clarify Control UI/WebChat auth error hints for missing tokens. (#1690)</li>
|
|
||||||
<li>Gateway: listen on IPv6 loopback when bound to 127.0.0.1 so localhost webhooks work.</li>
|
|
||||||
<li>Gateway: store lock files in the temp directory to avoid stale locks on persistent volumes. (#1676)</li>
|
|
||||||
<li>macOS: default direct-transport <code>ws://</code> URLs to port 18789; document <code>gateway.remote.transport</code>. (#1603) Thanks @ngutman.</li>
|
|
||||||
<li>Tests: cap Vitest workers on CI macOS to reduce timeouts. (#1597) Thanks @rohannagpal.</li>
|
|
||||||
<li>Tests: avoid fake-timer dependency in embedded runner stream mock to reduce CI flakes. (#1597) Thanks @rohannagpal.</li>
|
|
||||||
<li>Tests: increase embedded runner ordering test timeout to reduce CI flakes. (#1597) Thanks @rohannagpal.</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.1.24/OpenClaw-2026.1.24.zip" length="12396700" type="application/octet-stream" sparkle:edSignature="u+XzKD3YwV8s79gIr7LK4OtDCcmp/b+cjNC6SHav3/1CVJegh02SsBKatrampox32XGx8P2+8c/+fHV+qpkHCA=="/>
|
|
||||||
</item>
|
|
||||||
</channel>
|
|
||||||
</rss>
|
|
||||||
5
apps/android/.gitignore
vendored
5
apps/android/.gitignore
vendored
@ -1,5 +0,0 @@
|
|||||||
.gradle/
|
|
||||||
**/build/
|
|
||||||
local.properties
|
|
||||||
.idea/
|
|
||||||
**/*.iml
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
## OpenClaw Node (Android) (internal)
|
|
||||||
|
|
||||||
Modern Android node app: connects to the **Gateway WebSocket** (`_openclaw-gw._tcp`) and exposes **Canvas + Chat + Camera**.
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd apps/android
|
|
||||||
./gradlew :app:assembleDebug
|
|
||||||
./gradlew :app:installDebug
|
|
||||||
./gradlew :app:testDebugUnitTest
|
|
||||||
```
|
|
||||||
|
|
||||||
`gradlew` auto-detects the Android SDK at `~/Library/Android/sdk` (macOS default) if `ANDROID_SDK_ROOT` / `ANDROID_HOME` are unset.
|
|
||||||
|
|
||||||
## Connect / Pair
|
|
||||||
|
|
||||||
1) Start the gateway (on your “master” machine):
|
|
||||||
```bash
|
|
||||||
pnpm openclaw gateway --port 18789 --verbose
|
|
||||||
```
|
|
||||||
|
|
||||||
2) In the Android app:
|
|
||||||
- Open **Settings**
|
|
||||||
- Either select a discovered gateway under **Discovered Gateways**, or use **Advanced → Manual Gateway** (host + port).
|
|
||||||
|
|
||||||
3) Approve pairing (on the gateway machine):
|
|
||||||
```bash
|
|
||||||
openclaw nodes pending
|
|
||||||
openclaw nodes approve <requestId>
|
|
||||||
```
|
|
||||||
|
|
||||||
More details: `docs/platforms/android.md`.
|
|
||||||
|
|
||||||
## Permissions
|
|
||||||
|
|
||||||
- Discovery:
|
|
||||||
- Android 13+ (`API 33+`): `NEARBY_WIFI_DEVICES`
|
|
||||||
- Android 12 and below: `ACCESS_FINE_LOCATION` (required for NSD scanning)
|
|
||||||
- Foreground service notification (Android 13+): `POST_NOTIFICATIONS`
|
|
||||||
- Camera:
|
|
||||||
- `CAMERA` for `camera.snap` and `camera.clip`
|
|
||||||
- `RECORD_AUDIO` for `camera.clip` when `includeAudio=true`
|
|
||||||
@ -1,128 +0,0 @@
|
|||||||
import com.android.build.api.variant.impl.VariantOutputImpl
|
|
||||||
|
|
||||||
plugins {
|
|
||||||
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.android"
|
|
||||||
compileSdk = 36
|
|
||||||
|
|
||||||
sourceSets {
|
|
||||||
getByName("main") {
|
|
||||||
assets.srcDir(file("../../shared/OpenClawKit/Sources/OpenClawKit/Resources"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
defaultConfig {
|
|
||||||
applicationId = "ai.openclaw.android"
|
|
||||||
minSdk = 31
|
|
||||||
targetSdk = 36
|
|
||||||
versionCode = 202601290
|
|
||||||
versionName = "2026.1.29"
|
|
||||||
}
|
|
||||||
|
|
||||||
buildTypes {
|
|
||||||
release {
|
|
||||||
isMinifyEnabled = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buildFeatures {
|
|
||||||
compose = true
|
|
||||||
buildConfig = true
|
|
||||||
}
|
|
||||||
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
|
||||||
}
|
|
||||||
|
|
||||||
packaging {
|
|
||||||
resources {
|
|
||||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lint {
|
|
||||||
disable += setOf("IconLauncherShape")
|
|
||||||
warningsAsErrors = true
|
|
||||||
}
|
|
||||||
|
|
||||||
testOptions {
|
|
||||||
unitTests.isIncludeAndroidResources = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
androidComponents {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
kotlin {
|
|
||||||
compilerOptions {
|
|
||||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
|
||||||
allWarningsAsErrors.set(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
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.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")
|
|
||||||
|
|
||||||
// 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.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")
|
|
||||||
|
|
||||||
// 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")
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
|
||||||
<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-feature
|
|
||||||
android:name="android.hardware.camera"
|
|
||||||
android:required="false" />
|
|
||||||
<uses-feature
|
|
||||||
android:name="android.hardware.telephony"
|
|
||||||
android:required="false" />
|
|
||||||
|
|
||||||
<application
|
|
||||||
android:name=".NodeApp"
|
|
||||||
android:allowBackup="true"
|
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
|
||||||
android:icon="@mipmap/ic_launcher"
|
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
|
||||||
android:label="@string/app_name"
|
|
||||||
android:supportsRtl="true"
|
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
|
||||||
android:theme="@style/Theme.OpenClawNode">
|
|
||||||
<service
|
|
||||||
android:name=".NodeForegroundService"
|
|
||||||
android:exported="false"
|
|
||||||
android:foregroundServiceType="dataSync|microphone|mediaProjection" />
|
|
||||||
<activity
|
|
||||||
android:name=".MainActivity"
|
|
||||||
android:exported="true">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.MAIN" />
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
|
||||||
</application>
|
|
||||||
</manifest>
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
package ai.openclaw.android
|
|
||||||
|
|
||||||
enum class CameraHudKind {
|
|
||||||
Photo,
|
|
||||||
Recording,
|
|
||||||
Success,
|
|
||||||
Error,
|
|
||||||
}
|
|
||||||
|
|
||||||
data class CameraHudState(
|
|
||||||
val token: Long,
|
|
||||||
val kind: CameraHudKind,
|
|
||||||
val message: String,
|
|
||||||
)
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
package ai.openclaw.android
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Build
|
|
||||||
import android.provider.Settings
|
|
||||||
|
|
||||||
object DeviceNames {
|
|
||||||
fun bestDefaultNodeName(context: Context): String {
|
|
||||||
val deviceName =
|
|
||||||
runCatching {
|
|
||||||
Settings.Global.getString(context.contentResolver, "device_name")
|
|
||||||
}
|
|
||||||
.getOrNull()
|
|
||||||
?.trim()
|
|
||||||
.orEmpty()
|
|
||||||
|
|
||||||
if (deviceName.isNotEmpty()) return deviceName
|
|
||||||
|
|
||||||
val model =
|
|
||||||
listOfNotNull(Build.MANUFACTURER?.takeIf { it.isNotBlank() }, Build.MODEL?.takeIf { it.isNotBlank() })
|
|
||||||
.joinToString(" ")
|
|
||||||
.trim()
|
|
||||||
|
|
||||||
return model.ifEmpty { "Android Node" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
package ai.openclaw.android
|
|
||||||
|
|
||||||
enum class LocationMode(val rawValue: String) {
|
|
||||||
Off("off"),
|
|
||||||
WhileUsing("whileUsing"),
|
|
||||||
Always("always"),
|
|
||||||
;
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun fromRawValue(raw: String?): LocationMode {
|
|
||||||
val normalized = raw?.trim()?.lowercase()
|
|
||||||
return entries.firstOrNull { it.rawValue.lowercase() == normalized } ?: Off
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,130 +0,0 @@
|
|||||||
package ai.openclaw.android
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.pm.ApplicationInfo
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.os.Build
|
|
||||||
import android.view.WindowManager
|
|
||||||
import android.webkit.WebView
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.compose.setContent
|
|
||||||
import androidx.activity.viewModels
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.core.view.WindowCompat
|
|
||||||
import androidx.core.view.WindowInsetsCompat
|
|
||||||
import androidx.core.view.WindowInsetsControllerCompat
|
|
||||||
import androidx.lifecycle.Lifecycle
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
|
||||||
import ai.openclaw.android.ui.RootScreen
|
|
||||||
import ai.openclaw.android.ui.OpenClawTheme
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
|
||||||
private val viewModel: MainViewModel by viewModels()
|
|
||||||
private lateinit var permissionRequester: PermissionRequester
|
|
||||||
private lateinit var screenCaptureRequester: ScreenCaptureRequester
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
val isDebuggable = (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
|
|
||||||
WebView.setWebContentsDebuggingEnabled(isDebuggable)
|
|
||||||
applyImmersiveMode()
|
|
||||||
requestDiscoveryPermissionsIfNeeded()
|
|
||||||
requestNotificationPermissionIfNeeded()
|
|
||||||
NodeForegroundService.start(this)
|
|
||||||
permissionRequester = PermissionRequester(this)
|
|
||||||
screenCaptureRequester = ScreenCaptureRequester(this)
|
|
||||||
viewModel.camera.attachLifecycleOwner(this)
|
|
||||||
viewModel.camera.attachPermissionRequester(permissionRequester)
|
|
||||||
viewModel.sms.attachPermissionRequester(permissionRequester)
|
|
||||||
viewModel.screenRecorder.attachScreenCaptureRequester(screenCaptureRequester)
|
|
||||||
viewModel.screenRecorder.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
applyImmersiveMode()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onWindowFocusChanged(hasFocus: Boolean) {
|
|
||||||
super.onWindowFocusChanged(hasFocus)
|
|
||||||
if (hasFocus) {
|
|
||||||
applyImmersiveMode()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStart() {
|
|
||||||
super.onStart()
|
|
||||||
viewModel.setForeground(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStop() {
|
|
||||||
viewModel.setForeground(false)
|
|
||||||
super.onStop()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun applyImmersiveMode() {
|
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
|
||||||
val controller = WindowInsetsControllerCompat(window, window.decorView)
|
|
||||||
controller.systemBarsBehavior =
|
|
||||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
|
||||||
controller.hide(WindowInsetsCompat.Type.systemBars())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun requestDiscoveryPermissionsIfNeeded() {
|
|
||||||
if (Build.VERSION.SDK_INT >= 33) {
|
|
||||||
val ok =
|
|
||||||
ContextCompat.checkSelfPermission(
|
|
||||||
this,
|
|
||||||
Manifest.permission.NEARBY_WIFI_DEVICES,
|
|
||||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
|
||||||
if (!ok) {
|
|
||||||
requestPermissions(arrayOf(Manifest.permission.NEARBY_WIFI_DEVICES), 100)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val ok =
|
|
||||||
ContextCompat.checkSelfPermission(
|
|
||||||
this,
|
|
||||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
|
||||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
|
||||||
if (!ok) {
|
|
||||||
requestPermissions(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), 101)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun requestNotificationPermissionIfNeeded() {
|
|
||||||
if (Build.VERSION.SDK_INT < 33) return
|
|
||||||
val ok =
|
|
||||||
ContextCompat.checkSelfPermission(
|
|
||||||
this,
|
|
||||||
Manifest.permission.POST_NOTIFICATIONS,
|
|
||||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
|
||||||
if (!ok) {
|
|
||||||
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 102)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,174 +0,0 @@
|
|||||||
package ai.openclaw.android
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import androidx.lifecycle.AndroidViewModel
|
|
||||||
import ai.openclaw.android.gateway.GatewayEndpoint
|
|
||||||
import ai.openclaw.android.chat.OutgoingAttachment
|
|
||||||
import ai.openclaw.android.node.CameraCaptureManager
|
|
||||||
import ai.openclaw.android.node.CanvasController
|
|
||||||
import ai.openclaw.android.node.ScreenRecordManager
|
|
||||||
import ai.openclaw.android.node.SmsManager
|
|
||||||
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 camera: CameraCaptureManager = runtime.camera
|
|
||||||
val screenRecorder: ScreenRecordManager = runtime.screenRecorder
|
|
||||||
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 statusText: StateFlow<String> = runtime.statusText
|
|
||||||
val serverName: StateFlow<String?> = runtime.serverName
|
|
||||||
val remoteAddress: StateFlow<String?> = runtime.remoteAddress
|
|
||||||
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 screenRecordActive: StateFlow<Boolean> = runtime.screenRecordActive
|
|
||||||
|
|
||||||
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 wakeWords: StateFlow<List<String>> = runtime.wakeWords
|
|
||||||
val voiceWakeMode: StateFlow<VoiceWakeMode> = runtime.voiceWakeMode
|
|
||||||
val voiceWakeStatusText: StateFlow<String> = runtime.voiceWakeStatusText
|
|
||||||
val voiceWakeIsListening: StateFlow<Boolean> = runtime.voiceWakeIsListening
|
|
||||||
val talkEnabled: StateFlow<Boolean> = runtime.talkEnabled
|
|
||||||
val talkStatusText: StateFlow<String> = runtime.talkStatusText
|
|
||||||
val talkIsListening: StateFlow<Boolean> = runtime.talkIsListening
|
|
||||||
val talkIsSpeaking: StateFlow<Boolean> = runtime.talkIsSpeaking
|
|
||||||
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 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 setCanvasDebugStatusEnabled(value: Boolean) {
|
|
||||||
runtime.setCanvasDebugStatusEnabled(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setWakeWords(words: List<String>) {
|
|
||||||
runtime.setWakeWords(words)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun resetWakeWordsDefaults() {
|
|
||||||
runtime.resetWakeWordsDefaults()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setVoiceWakeMode(mode: VoiceWakeMode) {
|
|
||||||
runtime.setVoiceWakeMode(mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setTalkEnabled(enabled: Boolean) {
|
|
||||||
runtime.setTalkEnabled(enabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun refreshGatewayConnection() {
|
|
||||||
runtime.refreshGatewayConnection()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun connect(endpoint: GatewayEndpoint) {
|
|
||||||
runtime.connect(endpoint)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun connectManual() {
|
|
||||||
runtime.connectManual()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun disconnect() {
|
|
||||||
runtime.disconnect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
|
|
||||||
runtime.handleCanvasA2UIActionFromWebView(payloadJson)
|
|
||||||
}
|
|
||||||
|
|
||||||
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,26 +0,0 @@
|
|||||||
package ai.openclaw.android
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import android.os.StrictMode
|
|
||||||
|
|
||||||
class NodeApp : Application() {
|
|
||||||
val runtime: NodeRuntime by lazy { NodeRuntime(this) }
|
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
if (BuildConfig.DEBUG) {
|
|
||||||
StrictMode.setThreadPolicy(
|
|
||||||
StrictMode.ThreadPolicy.Builder()
|
|
||||||
.detectAll()
|
|
||||||
.penaltyLog()
|
|
||||||
.build(),
|
|
||||||
)
|
|
||||||
StrictMode.setVmPolicy(
|
|
||||||
StrictMode.VmPolicy.Builder()
|
|
||||||
.detectAll()
|
|
||||||
.penaltyLog()
|
|
||||||
.build(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,180 +0,0 @@
|
|||||||
package ai.openclaw.android
|
|
||||||
|
|
||||||
import android.app.Notification
|
|
||||||
import android.app.NotificationChannel
|
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.app.Service
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.content.pm.ServiceInfo
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
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 lastRequiresMic = false
|
|
||||||
private var didStartForeground = false
|
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
ensureChannel()
|
|
||||||
val initial = buildNotification(title = "OpenClaw Node", text = "Starting…")
|
|
||||||
startForegroundWithTypes(notification = initial, requiresMic = false)
|
|
||||||
|
|
||||||
val runtime = (application as NodeApp).runtime
|
|
||||||
notificationJob =
|
|
||||||
scope.launch {
|
|
||||||
combine(
|
|
||||||
runtime.statusText,
|
|
||||||
runtime.serverName,
|
|
||||||
runtime.isConnected,
|
|
||||||
runtime.voiceWakeMode,
|
|
||||||
runtime.voiceWakeIsListening,
|
|
||||||
) { status, server, connected, voiceMode, voiceListening ->
|
|
||||||
Quint(status, server, connected, voiceMode, voiceListening)
|
|
||||||
}.collect { (status, server, connected, voiceMode, voiceListening) ->
|
|
||||||
val title = if (connected) "OpenClaw Node · Connected" else "OpenClaw Node"
|
|
||||||
val voiceSuffix =
|
|
||||||
if (voiceMode == VoiceWakeMode.Always) {
|
|
||||||
if (voiceListening) " · Voice Wake: Listening" else " · Voice Wake: Paused"
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
val text = (server?.let { "$status · $it" } ?: status) + voiceSuffix
|
|
||||||
|
|
||||||
val requiresMic =
|
|
||||||
voiceMode == VoiceWakeMode.Always && hasRecordAudioPermission()
|
|
||||||
startForegroundWithTypes(
|
|
||||||
notification = buildNotification(title = title, text = text),
|
|
||||||
requiresMic = requiresMic,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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, requiresMic: Boolean) {
|
|
||||||
if (didStartForeground && requiresMic == lastRequiresMic) {
|
|
||||||
updateNotification(notification)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
lastRequiresMic = requiresMic
|
|
||||||
val types =
|
|
||||||
if (requiresMic) {
|
|
||||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
|
|
||||||
} else {
|
|
||||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
|
||||||
}
|
|
||||||
startForeground(NOTIFICATION_ID, notification, types)
|
|
||||||
didStartForeground = true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hasRecordAudioPermission(): Boolean {
|
|
||||||
return (
|
|
||||||
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
|
|
||||||
PackageManager.PERMISSION_GRANTED
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val CHANNEL_ID = "connection"
|
|
||||||
private const val NOTIFICATION_ID = 1
|
|
||||||
|
|
||||||
private const val ACTION_STOP = "ai.openclaw.android.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)
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,133 +0,0 @@
|
|||||||
package ai.openclaw.android
|
|
||||||
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.content.Intent
|
|
||||||
import android.Manifest
|
|
||||||
import android.net.Uri
|
|
||||||
import android.provider.Settings
|
|
||||||
import androidx.appcompat.app.AlertDialog
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.result.ActivityResultLauncher
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.core.app.ActivityCompat
|
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
||||||
import kotlin.coroutines.resume
|
|
||||||
|
|
||||||
class PermissionRequester(private val activity: ComponentActivity) {
|
|
||||||
private val mutex = Mutex()
|
|
||||||
private var pending: CompletableDeferred<Map<String, Boolean>>? = null
|
|
||||||
|
|
||||||
private val launcher: ActivityResultLauncher<Array<String>> =
|
|
||||||
activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
|
|
||||||
val p = pending
|
|
||||||
pending = null
|
|
||||||
p?.complete(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun requestIfMissing(
|
|
||||||
permissions: List<String>,
|
|
||||||
timeoutMs: Long = 20_000,
|
|
||||||
): Map<String, Boolean> =
|
|
||||||
mutex.withLock {
|
|
||||||
val missing =
|
|
||||||
permissions.filter { perm ->
|
|
||||||
ContextCompat.checkSelfPermission(activity, perm) != PackageManager.PERMISSION_GRANTED
|
|
||||||
}
|
|
||||||
if (missing.isEmpty()) {
|
|
||||||
return permissions.associateWith { true }
|
|
||||||
}
|
|
||||||
|
|
||||||
val needsRationale =
|
|
||||||
missing.any { ActivityCompat.shouldShowRequestPermissionRationale(activity, it) }
|
|
||||||
if (needsRationale) {
|
|
||||||
val proceed = showRationaleDialog(missing)
|
|
||||||
if (!proceed) {
|
|
||||||
return permissions.associateWith { perm ->
|
|
||||||
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val deferred = CompletableDeferred<Map<String, Boolean>>()
|
|
||||||
pending = deferred
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
launcher.launch(missing.toTypedArray())
|
|
||||||
}
|
|
||||||
|
|
||||||
val result =
|
|
||||||
withContext(Dispatchers.Default) {
|
|
||||||
kotlinx.coroutines.withTimeout(timeoutMs) { deferred.await() }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge: if something was already granted, treat it as granted even if launcher omitted it.
|
|
||||||
val merged =
|
|
||||||
permissions.associateWith { perm ->
|
|
||||||
val nowGranted =
|
|
||||||
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
|
|
||||||
result[perm] == true || nowGranted
|
|
||||||
}
|
|
||||||
|
|
||||||
val denied =
|
|
||||||
merged.filterValues { !it }.keys.filter {
|
|
||||||
!ActivityCompat.shouldShowRequestPermissionRationale(activity, it)
|
|
||||||
}
|
|
||||||
if (denied.isNotEmpty()) {
|
|
||||||
showSettingsDialog(denied)
|
|
||||||
}
|
|
||||||
|
|
||||||
return merged
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun showRationaleDialog(permissions: List<String>): Boolean =
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
suspendCancellableCoroutine { cont ->
|
|
||||||
AlertDialog.Builder(activity)
|
|
||||||
.setTitle("Permission required")
|
|
||||||
.setMessage(buildRationaleMessage(permissions))
|
|
||||||
.setPositiveButton("Continue") { _, _ -> cont.resume(true) }
|
|
||||||
.setNegativeButton("Not now") { _, _ -> cont.resume(false) }
|
|
||||||
.setOnCancelListener { cont.resume(false) }
|
|
||||||
.show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showSettingsDialog(permissions: List<String>) {
|
|
||||||
AlertDialog.Builder(activity)
|
|
||||||
.setTitle("Enable permission in Settings")
|
|
||||||
.setMessage(buildSettingsMessage(permissions))
|
|
||||||
.setPositiveButton("Open Settings") { _, _ ->
|
|
||||||
val intent =
|
|
||||||
Intent(
|
|
||||||
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
|
||||||
Uri.fromParts("package", activity.packageName, null),
|
|
||||||
)
|
|
||||||
activity.startActivity(intent)
|
|
||||||
}
|
|
||||||
.setNegativeButton("Cancel", null)
|
|
||||||
.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildRationaleMessage(permissions: List<String>): String {
|
|
||||||
val labels = permissions.map { permissionLabel(it) }
|
|
||||||
return "OpenClaw needs ${labels.joinToString(", ")} permissions to continue."
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildSettingsMessage(permissions: List<String>): String {
|
|
||||||
val labels = permissions.map { permissionLabel(it) }
|
|
||||||
return "Please enable ${labels.joinToString(", ")} in Android Settings to continue."
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun permissionLabel(permission: String): String =
|
|
||||||
when (permission) {
|
|
||||||
Manifest.permission.CAMERA -> "Camera"
|
|
||||||
Manifest.permission.RECORD_AUDIO -> "Microphone"
|
|
||||||
Manifest.permission.SEND_SMS -> "SMS"
|
|
||||||
else -> permission
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
package ai.openclaw.android
|
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.media.projection.MediaProjectionManager
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.result.ActivityResultLauncher
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.appcompat.app.AlertDialog
|
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import kotlinx.coroutines.withTimeout
|
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
||||||
import kotlin.coroutines.resume
|
|
||||||
|
|
||||||
class ScreenCaptureRequester(private val activity: ComponentActivity) {
|
|
||||||
data class CaptureResult(val resultCode: Int, val data: Intent)
|
|
||||||
|
|
||||||
private val mutex = Mutex()
|
|
||||||
private var pending: CompletableDeferred<CaptureResult?>? = null
|
|
||||||
|
|
||||||
private val launcher: ActivityResultLauncher<Intent> =
|
|
||||||
activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
|
||||||
val p = pending
|
|
||||||
pending = null
|
|
||||||
val data = result.data
|
|
||||||
if (result.resultCode == Activity.RESULT_OK && data != null) {
|
|
||||||
p?.complete(CaptureResult(result.resultCode, data))
|
|
||||||
} else {
|
|
||||||
p?.complete(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun requestCapture(timeoutMs: Long = 20_000): CaptureResult? =
|
|
||||||
mutex.withLock {
|
|
||||||
val proceed = showRationaleDialog()
|
|
||||||
if (!proceed) return null
|
|
||||||
|
|
||||||
val mgr = activity.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
|
||||||
val intent = mgr.createScreenCaptureIntent()
|
|
||||||
|
|
||||||
val deferred = CompletableDeferred<CaptureResult?>()
|
|
||||||
pending = deferred
|
|
||||||
withContext(Dispatchers.Main) { launcher.launch(intent) }
|
|
||||||
|
|
||||||
withContext(Dispatchers.Default) { withTimeout(timeoutMs) { deferred.await() } }
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun showRationaleDialog(): Boolean =
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
suspendCancellableCoroutine { cont ->
|
|
||||||
AlertDialog.Builder(activity)
|
|
||||||
.setTitle("Screen recording required")
|
|
||||||
.setMessage("OpenClaw needs to record the screen for this command.")
|
|
||||||
.setPositiveButton("Continue") { _, _ -> cont.resume(true) }
|
|
||||||
.setNegativeButton("Not now") { _, _ -> cont.resume(false) }
|
|
||||||
.setOnCancelListener { cont.resume(false) }
|
|
||||||
.show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,274 +0,0 @@
|
|||||||
@file:Suppress("DEPRECATION")
|
|
||||||
|
|
||||||
package ai.openclaw.android
|
|
||||||
|
|
||||||
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) {
|
|
||||||
companion object {
|
|
||||||
val defaultWakeWords: List<String> = listOf("openclaw", "claude")
|
|
||||||
private const val displayNameKey = "node.displayName"
|
|
||||||
private const val voiceWakeModeKey = "voiceWake.mode"
|
|
||||||
}
|
|
||||||
|
|
||||||
private val appContext = context.applicationContext
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
|
||||||
|
|
||||||
private val masterKey =
|
|
||||||
MasterKey.Builder(context)
|
|
||||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
private val prefs: SharedPreferences by lazy {
|
|
||||||
createPrefs(appContext, "openclaw.node.secure")
|
|
||||||
}
|
|
||||||
|
|
||||||
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(prefs.getBoolean("camera.enabled", true))
|
|
||||||
val cameraEnabled: StateFlow<Boolean> = _cameraEnabled
|
|
||||||
|
|
||||||
private val _locationMode =
|
|
||||||
MutableStateFlow(LocationMode.fromRawValue(prefs.getString("location.enabledMode", "off")))
|
|
||||||
val locationMode: StateFlow<LocationMode> = _locationMode
|
|
||||||
|
|
||||||
private val _locationPreciseEnabled =
|
|
||||||
MutableStateFlow(prefs.getBoolean("location.preciseEnabled", true))
|
|
||||||
val locationPreciseEnabled: StateFlow<Boolean> = _locationPreciseEnabled
|
|
||||||
|
|
||||||
private val _preventSleep = MutableStateFlow(prefs.getBoolean("screen.preventSleep", true))
|
|
||||||
val preventSleep: StateFlow<Boolean> = _preventSleep
|
|
||||||
|
|
||||||
private val _manualEnabled =
|
|
||||||
MutableStateFlow(prefs.getBoolean("gateway.manual.enabled", false))
|
|
||||||
val manualEnabled: StateFlow<Boolean> = _manualEnabled
|
|
||||||
|
|
||||||
private val _manualHost =
|
|
||||||
MutableStateFlow(prefs.getString("gateway.manual.host", "") ?: "")
|
|
||||||
val manualHost: StateFlow<String> = _manualHost
|
|
||||||
|
|
||||||
private val _manualPort =
|
|
||||||
MutableStateFlow(prefs.getInt("gateway.manual.port", 18789))
|
|
||||||
val manualPort: StateFlow<Int> = _manualPort
|
|
||||||
|
|
||||||
private val _manualTls =
|
|
||||||
MutableStateFlow(prefs.getBoolean("gateway.manual.tls", true))
|
|
||||||
val manualTls: StateFlow<Boolean> = _manualTls
|
|
||||||
|
|
||||||
private val _lastDiscoveredStableId =
|
|
||||||
MutableStateFlow(
|
|
||||||
prefs.getString("gateway.lastDiscoveredStableID", "") ?: "",
|
|
||||||
)
|
|
||||||
val lastDiscoveredStableId: StateFlow<String> = _lastDiscoveredStableId
|
|
||||||
|
|
||||||
private val _canvasDebugStatusEnabled =
|
|
||||||
MutableStateFlow(prefs.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(prefs.getBoolean("talk.enabled", false))
|
|
||||||
val talkEnabled: StateFlow<Boolean> = _talkEnabled
|
|
||||||
|
|
||||||
fun setLastDiscoveredStableId(value: String) {
|
|
||||||
val trimmed = value.trim()
|
|
||||||
prefs.edit { putString("gateway.lastDiscoveredStableID", trimmed) }
|
|
||||||
_lastDiscoveredStableId.value = trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setDisplayName(value: String) {
|
|
||||||
val trimmed = value.trim()
|
|
||||||
prefs.edit { putString(displayNameKey, trimmed) }
|
|
||||||
_displayName.value = trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setCameraEnabled(value: Boolean) {
|
|
||||||
prefs.edit { putBoolean("camera.enabled", value) }
|
|
||||||
_cameraEnabled.value = value
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setLocationMode(mode: LocationMode) {
|
|
||||||
prefs.edit { putString("location.enabledMode", mode.rawValue) }
|
|
||||||
_locationMode.value = mode
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setLocationPreciseEnabled(value: Boolean) {
|
|
||||||
prefs.edit { putBoolean("location.preciseEnabled", value) }
|
|
||||||
_locationPreciseEnabled.value = value
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setPreventSleep(value: Boolean) {
|
|
||||||
prefs.edit { putBoolean("screen.preventSleep", value) }
|
|
||||||
_preventSleep.value = value
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setManualEnabled(value: Boolean) {
|
|
||||||
prefs.edit { putBoolean("gateway.manual.enabled", value) }
|
|
||||||
_manualEnabled.value = value
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setManualHost(value: String) {
|
|
||||||
val trimmed = value.trim()
|
|
||||||
prefs.edit { putString("gateway.manual.host", trimmed) }
|
|
||||||
_manualHost.value = trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setManualPort(value: Int) {
|
|
||||||
prefs.edit { putInt("gateway.manual.port", value) }
|
|
||||||
_manualPort.value = value
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setManualTls(value: Boolean) {
|
|
||||||
prefs.edit { putBoolean("gateway.manual.tls", value) }
|
|
||||||
_manualTls.value = value
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
|
||||||
prefs.edit { putBoolean("canvas.debugStatusEnabled", value) }
|
|
||||||
_canvasDebugStatusEnabled.value = value
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadGatewayToken(): String? {
|
|
||||||
val key = "gateway.token.${_instanceId.value}"
|
|
||||||
val stored = prefs.getString(key, null)?.trim()
|
|
||||||
return stored?.takeIf { it.isNotEmpty() }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveGatewayToken(token: String) {
|
|
||||||
val key = "gateway.token.${_instanceId.value}"
|
|
||||||
prefs.edit { putString(key, token.trim()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadGatewayPassword(): String? {
|
|
||||||
val key = "gateway.password.${_instanceId.value}"
|
|
||||||
val stored = prefs.getString(key, null)?.trim()
|
|
||||||
return stored?.takeIf { it.isNotEmpty() }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveGatewayPassword(password: String) {
|
|
||||||
val key = "gateway.password.${_instanceId.value}"
|
|
||||||
prefs.edit { putString(key, password.trim()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadGatewayTlsFingerprint(stableId: String): String? {
|
|
||||||
val key = "gateway.tls.$stableId"
|
|
||||||
return prefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveGatewayTlsFingerprint(stableId: String, fingerprint: String) {
|
|
||||||
val key = "gateway.tls.$stableId"
|
|
||||||
prefs.edit { putString(key, fingerprint.trim()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getString(key: String): String? {
|
|
||||||
return prefs.getString(key, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun putString(key: String, value: String) {
|
|
||||||
prefs.edit { putString(key, value) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun remove(key: String) {
|
|
||||||
prefs.edit { remove(key) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createPrefs(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 = prefs.getString("node.instanceId", null)?.trim()
|
|
||||||
if (!existing.isNullOrBlank()) return existing
|
|
||||||
val fresh = UUID.randomUUID().toString()
|
|
||||||
prefs.edit { putString("node.instanceId", fresh) }
|
|
||||||
return fresh
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadOrMigrateDisplayName(context: Context): String {
|
|
||||||
val existing = prefs.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" }
|
|
||||||
|
|
||||||
prefs.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()
|
|
||||||
prefs.edit { putString("voiceWake.triggerWords", encoded) }
|
|
||||||
_wakeWords.value = sanitized
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setVoiceWakeMode(mode: VoiceWakeMode) {
|
|
||||||
prefs.edit { putString(voiceWakeModeKey, mode.rawValue) }
|
|
||||||
_voiceWakeMode.value = mode
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setTalkEnabled(value: Boolean) {
|
|
||||||
prefs.edit { putBoolean("talk.enabled", value) }
|
|
||||||
_talkEnabled.value = value
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadVoiceWakeMode(): VoiceWakeMode {
|
|
||||||
val raw = prefs.getString(voiceWakeModeKey, null)
|
|
||||||
val resolved = VoiceWakeMode.fromRawValue(raw)
|
|
||||||
|
|
||||||
// Default ON (foreground) when unset.
|
|
||||||
if (raw.isNullOrBlank()) {
|
|
||||||
prefs.edit { putString(voiceWakeModeKey, resolved.rawValue) }
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolved
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadWakeWords(): List<String> {
|
|
||||||
val raw = prefs.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,13 +0,0 @@
|
|||||||
package ai.openclaw.android
|
|
||||||
|
|
||||||
internal fun normalizeMainKey(raw: String?): String {
|
|
||||||
val trimmed = raw?.trim()
|
|
||||||
return if (!trimmed.isNullOrEmpty()) trimmed else "main"
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun isCanonicalMainSessionKey(raw: String?): Boolean {
|
|
||||||
val trimmed = raw?.trim().orEmpty()
|
|
||||||
if (trimmed.isEmpty()) return false
|
|
||||||
if (trimmed == "global") return true
|
|
||||||
return trimmed.startsWith("agent:")
|
|
||||||
}
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
package ai.openclaw.android
|
|
||||||
|
|
||||||
enum class VoiceWakeMode(val rawValue: String) {
|
|
||||||
Off("off"),
|
|
||||||
Foreground("foreground"),
|
|
||||||
Always("always"),
|
|
||||||
;
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun fromRawValue(raw: String?): VoiceWakeMode {
|
|
||||||
return entries.firstOrNull { it.rawValue == raw?.trim()?.lowercase() } ?: Foreground
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
package ai.openclaw.android
|
|
||||||
|
|
||||||
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,524 +0,0 @@
|
|||||||
package ai.openclaw.android.chat
|
|
||||||
|
|
||||||
import ai.openclaw.android.gateway.GatewaySession
|
|
||||||
import java.util.UUID
|
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
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.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
|
|
||||||
|
|
||||||
class ChatController(
|
|
||||||
private val scope: CoroutineScope,
|
|
||||||
private val session: GatewaySession,
|
|
||||||
private val json: Json,
|
|
||||||
private val supportsChatSubscribe: Boolean,
|
|
||||||
) {
|
|
||||||
private val _sessionKey = MutableStateFlow("main")
|
|
||||||
val sessionKey: StateFlow<String> = _sessionKey.asStateFlow()
|
|
||||||
|
|
||||||
private val _sessionId = MutableStateFlow<String?>(null)
|
|
||||||
val sessionId: StateFlow<String?> = _sessionId.asStateFlow()
|
|
||||||
|
|
||||||
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
|
|
||||||
val messages: StateFlow<List<ChatMessage>> = _messages.asStateFlow()
|
|
||||||
|
|
||||||
private val _errorText = MutableStateFlow<String?>(null)
|
|
||||||
val errorText: StateFlow<String?> = _errorText.asStateFlow()
|
|
||||||
|
|
||||||
private val _healthOk = MutableStateFlow(false)
|
|
||||||
val healthOk: StateFlow<Boolean> = _healthOk.asStateFlow()
|
|
||||||
|
|
||||||
private val _thinkingLevel = MutableStateFlow("off")
|
|
||||||
val thinkingLevel: StateFlow<String> = _thinkingLevel.asStateFlow()
|
|
||||||
|
|
||||||
private val _pendingRunCount = MutableStateFlow(0)
|
|
||||||
val pendingRunCount: StateFlow<Int> = _pendingRunCount.asStateFlow()
|
|
||||||
|
|
||||||
private val _streamingAssistantText = MutableStateFlow<String?>(null)
|
|
||||||
val streamingAssistantText: StateFlow<String?> = _streamingAssistantText.asStateFlow()
|
|
||||||
|
|
||||||
private val pendingToolCallsById = ConcurrentHashMap<String, ChatPendingToolCall>()
|
|
||||||
private val _pendingToolCalls = MutableStateFlow<List<ChatPendingToolCall>>(emptyList())
|
|
||||||
val pendingToolCalls: StateFlow<List<ChatPendingToolCall>> = _pendingToolCalls.asStateFlow()
|
|
||||||
|
|
||||||
private val _sessions = MutableStateFlow<List<ChatSessionEntry>>(emptyList())
|
|
||||||
val sessions: StateFlow<List<ChatSessionEntry>> = _sessions.asStateFlow()
|
|
||||||
|
|
||||||
private val pendingRuns = mutableSetOf<String>()
|
|
||||||
private val pendingRunTimeoutJobs = ConcurrentHashMap<String, Job>()
|
|
||||||
private val pendingRunTimeoutMs = 120_000L
|
|
||||||
|
|
||||||
private var lastHealthPollAtMs: Long? = null
|
|
||||||
|
|
||||||
fun onDisconnected(message: String) {
|
|
||||||
_healthOk.value = false
|
|
||||||
// Not an error; keep connection status in the UI pill.
|
|
||||||
_errorText.value = null
|
|
||||||
clearPendingRuns()
|
|
||||||
pendingToolCallsById.clear()
|
|
||||||
publishPendingToolCalls()
|
|
||||||
_streamingAssistantText.value = null
|
|
||||||
_sessionId.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun load(sessionKey: String) {
|
|
||||||
val key = sessionKey.trim().ifEmpty { "main" }
|
|
||||||
_sessionKey.value = key
|
|
||||||
scope.launch { bootstrap(forceHealth = true) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun applyMainSessionKey(mainSessionKey: String) {
|
|
||||||
val trimmed = mainSessionKey.trim()
|
|
||||||
if (trimmed.isEmpty()) return
|
|
||||||
if (_sessionKey.value == trimmed) return
|
|
||||||
if (_sessionKey.value != "main") return
|
|
||||||
_sessionKey.value = trimmed
|
|
||||||
scope.launch { bootstrap(forceHealth = true) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun refresh() {
|
|
||||||
scope.launch { bootstrap(forceHealth = true) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun refreshSessions(limit: Int? = null) {
|
|
||||||
scope.launch { fetchSessions(limit = limit) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setThinkingLevel(thinkingLevel: String) {
|
|
||||||
val normalized = normalizeThinking(thinkingLevel)
|
|
||||||
if (normalized == _thinkingLevel.value) return
|
|
||||||
_thinkingLevel.value = normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
fun switchSession(sessionKey: String) {
|
|
||||||
val key = sessionKey.trim()
|
|
||||||
if (key.isEmpty()) return
|
|
||||||
if (key == _sessionKey.value) return
|
|
||||||
_sessionKey.value = key
|
|
||||||
scope.launch { bootstrap(forceHealth = true) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendMessage(
|
|
||||||
message: String,
|
|
||||||
thinkingLevel: String,
|
|
||||||
attachments: List<OutgoingAttachment>,
|
|
||||||
) {
|
|
||||||
val trimmed = message.trim()
|
|
||||||
if (trimmed.isEmpty() && attachments.isEmpty()) return
|
|
||||||
if (!_healthOk.value) {
|
|
||||||
_errorText.value = "Gateway health not OK; cannot send"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val runId = UUID.randomUUID().toString()
|
|
||||||
val text = if (trimmed.isEmpty() && attachments.isNotEmpty()) "See attached." else trimmed
|
|
||||||
val sessionKey = _sessionKey.value
|
|
||||||
val thinking = normalizeThinking(thinkingLevel)
|
|
||||||
|
|
||||||
// Optimistic user message.
|
|
||||||
val userContent =
|
|
||||||
buildList {
|
|
||||||
add(ChatMessageContent(type = "text", text = text))
|
|
||||||
for (att in attachments) {
|
|
||||||
add(
|
|
||||||
ChatMessageContent(
|
|
||||||
type = att.type,
|
|
||||||
mimeType = att.mimeType,
|
|
||||||
fileName = att.fileName,
|
|
||||||
base64 = att.base64,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_messages.value =
|
|
||||||
_messages.value +
|
|
||||||
ChatMessage(
|
|
||||||
id = UUID.randomUUID().toString(),
|
|
||||||
role = "user",
|
|
||||||
content = userContent,
|
|
||||||
timestampMs = System.currentTimeMillis(),
|
|
||||||
)
|
|
||||||
|
|
||||||
armPendingRunTimeout(runId)
|
|
||||||
synchronized(pendingRuns) {
|
|
||||||
pendingRuns.add(runId)
|
|
||||||
_pendingRunCount.value = pendingRuns.size
|
|
||||||
}
|
|
||||||
|
|
||||||
_errorText.value = null
|
|
||||||
_streamingAssistantText.value = null
|
|
||||||
pendingToolCallsById.clear()
|
|
||||||
publishPendingToolCalls()
|
|
||||||
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
val params =
|
|
||||||
buildJsonObject {
|
|
||||||
put("sessionKey", JsonPrimitive(sessionKey))
|
|
||||||
put("message", JsonPrimitive(text))
|
|
||||||
put("thinking", JsonPrimitive(thinking))
|
|
||||||
put("timeoutMs", JsonPrimitive(30_000))
|
|
||||||
put("idempotencyKey", JsonPrimitive(runId))
|
|
||||||
if (attachments.isNotEmpty()) {
|
|
||||||
put(
|
|
||||||
"attachments",
|
|
||||||
JsonArray(
|
|
||||||
attachments.map { att ->
|
|
||||||
buildJsonObject {
|
|
||||||
put("type", JsonPrimitive(att.type))
|
|
||||||
put("mimeType", JsonPrimitive(att.mimeType))
|
|
||||||
put("fileName", JsonPrimitive(att.fileName))
|
|
||||||
put("content", JsonPrimitive(att.base64))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val res = session.request("chat.send", params.toString())
|
|
||||||
val actualRunId = parseRunId(res) ?: runId
|
|
||||||
if (actualRunId != runId) {
|
|
||||||
clearPendingRun(runId)
|
|
||||||
armPendingRunTimeout(actualRunId)
|
|
||||||
synchronized(pendingRuns) {
|
|
||||||
pendingRuns.add(actualRunId)
|
|
||||||
_pendingRunCount.value = pendingRuns.size
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err: Throwable) {
|
|
||||||
clearPendingRun(runId)
|
|
||||||
_errorText.value = err.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun abort() {
|
|
||||||
val runIds =
|
|
||||||
synchronized(pendingRuns) {
|
|
||||||
pendingRuns.toList()
|
|
||||||
}
|
|
||||||
if (runIds.isEmpty()) return
|
|
||||||
scope.launch {
|
|
||||||
for (runId in runIds) {
|
|
||||||
try {
|
|
||||||
val params =
|
|
||||||
buildJsonObject {
|
|
||||||
put("sessionKey", JsonPrimitive(_sessionKey.value))
|
|
||||||
put("runId", JsonPrimitive(runId))
|
|
||||||
}
|
|
||||||
session.request("chat.abort", params.toString())
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
// best-effort
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun handleGatewayEvent(event: String, payloadJson: String?) {
|
|
||||||
when (event) {
|
|
||||||
"tick" -> {
|
|
||||||
scope.launch { pollHealthIfNeeded(force = false) }
|
|
||||||
}
|
|
||||||
"health" -> {
|
|
||||||
// If we receive a health snapshot, the gateway is reachable.
|
|
||||||
_healthOk.value = true
|
|
||||||
}
|
|
||||||
"seqGap" -> {
|
|
||||||
_errorText.value = "Event stream interrupted; try refreshing."
|
|
||||||
clearPendingRuns()
|
|
||||||
}
|
|
||||||
"chat" -> {
|
|
||||||
if (payloadJson.isNullOrBlank()) return
|
|
||||||
handleChatEvent(payloadJson)
|
|
||||||
}
|
|
||||||
"agent" -> {
|
|
||||||
if (payloadJson.isNullOrBlank()) return
|
|
||||||
handleAgentEvent(payloadJson)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun bootstrap(forceHealth: Boolean) {
|
|
||||||
_errorText.value = null
|
|
||||||
_healthOk.value = false
|
|
||||||
clearPendingRuns()
|
|
||||||
pendingToolCallsById.clear()
|
|
||||||
publishPendingToolCalls()
|
|
||||||
_streamingAssistantText.value = null
|
|
||||||
_sessionId.value = null
|
|
||||||
|
|
||||||
val key = _sessionKey.value
|
|
||||||
try {
|
|
||||||
if (supportsChatSubscribe) {
|
|
||||||
try {
|
|
||||||
session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""")
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
// best-effort
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val historyJson = session.request("chat.history", """{"sessionKey":"$key"}""")
|
|
||||||
val history = parseHistory(historyJson, sessionKey = key)
|
|
||||||
_messages.value = history.messages
|
|
||||||
_sessionId.value = history.sessionId
|
|
||||||
history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it }
|
|
||||||
|
|
||||||
pollHealthIfNeeded(force = forceHealth)
|
|
||||||
fetchSessions(limit = 50)
|
|
||||||
} catch (err: Throwable) {
|
|
||||||
_errorText.value = err.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun fetchSessions(limit: Int?) {
|
|
||||||
try {
|
|
||||||
val params =
|
|
||||||
buildJsonObject {
|
|
||||||
put("includeGlobal", JsonPrimitive(true))
|
|
||||||
put("includeUnknown", JsonPrimitive(false))
|
|
||||||
if (limit != null && limit > 0) put("limit", JsonPrimitive(limit))
|
|
||||||
}
|
|
||||||
val res = session.request("sessions.list", params.toString())
|
|
||||||
_sessions.value = parseSessions(res)
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
// best-effort
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun pollHealthIfNeeded(force: Boolean) {
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
val last = lastHealthPollAtMs
|
|
||||||
if (!force && last != null && now - last < 10_000) return
|
|
||||||
lastHealthPollAtMs = now
|
|
||||||
try {
|
|
||||||
session.request("health", null)
|
|
||||||
_healthOk.value = true
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
_healthOk.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleChatEvent(payloadJson: String) {
|
|
||||||
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
|
|
||||||
val sessionKey = payload["sessionKey"].asStringOrNull()?.trim()
|
|
||||||
if (!sessionKey.isNullOrEmpty() && sessionKey != _sessionKey.value) return
|
|
||||||
|
|
||||||
val runId = payload["runId"].asStringOrNull()
|
|
||||||
if (runId != null) {
|
|
||||||
val isPending =
|
|
||||||
synchronized(pendingRuns) {
|
|
||||||
pendingRuns.contains(runId)
|
|
||||||
}
|
|
||||||
if (!isPending) return
|
|
||||||
}
|
|
||||||
|
|
||||||
val state = payload["state"].asStringOrNull()
|
|
||||||
when (state) {
|
|
||||||
"final", "aborted", "error" -> {
|
|
||||||
if (state == "error") {
|
|
||||||
_errorText.value = payload["errorMessage"].asStringOrNull() ?: "Chat failed"
|
|
||||||
}
|
|
||||||
if (runId != null) clearPendingRun(runId) else clearPendingRuns()
|
|
||||||
pendingToolCallsById.clear()
|
|
||||||
publishPendingToolCalls()
|
|
||||||
_streamingAssistantText.value = null
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
val historyJson =
|
|
||||||
session.request("chat.history", """{"sessionKey":"${_sessionKey.value}"}""")
|
|
||||||
val history = parseHistory(historyJson, sessionKey = _sessionKey.value)
|
|
||||||
_messages.value = history.messages
|
|
||||||
_sessionId.value = history.sessionId
|
|
||||||
history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it }
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
// best-effort
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleAgentEvent(payloadJson: String) {
|
|
||||||
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
|
|
||||||
val runId = payload["runId"].asStringOrNull()
|
|
||||||
val sessionId = _sessionId.value
|
|
||||||
if (sessionId != null && runId != sessionId) return
|
|
||||||
|
|
||||||
val stream = payload["stream"].asStringOrNull()
|
|
||||||
val data = payload["data"].asObjectOrNull()
|
|
||||||
|
|
||||||
when (stream) {
|
|
||||||
"assistant" -> {
|
|
||||||
val text = data?.get("text")?.asStringOrNull()
|
|
||||||
if (!text.isNullOrEmpty()) {
|
|
||||||
_streamingAssistantText.value = text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"tool" -> {
|
|
||||||
val phase = data?.get("phase")?.asStringOrNull()
|
|
||||||
val name = data?.get("name")?.asStringOrNull()
|
|
||||||
val toolCallId = data?.get("toolCallId")?.asStringOrNull()
|
|
||||||
if (phase.isNullOrEmpty() || name.isNullOrEmpty() || toolCallId.isNullOrEmpty()) return
|
|
||||||
|
|
||||||
val ts = payload["ts"].asLongOrNull() ?: System.currentTimeMillis()
|
|
||||||
if (phase == "start") {
|
|
||||||
val args = data?.get("args").asObjectOrNull()
|
|
||||||
pendingToolCallsById[toolCallId] =
|
|
||||||
ChatPendingToolCall(
|
|
||||||
toolCallId = toolCallId,
|
|
||||||
name = name,
|
|
||||||
args = args,
|
|
||||||
startedAtMs = ts,
|
|
||||||
isError = null,
|
|
||||||
)
|
|
||||||
publishPendingToolCalls()
|
|
||||||
} else if (phase == "result") {
|
|
||||||
pendingToolCallsById.remove(toolCallId)
|
|
||||||
publishPendingToolCalls()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"error" -> {
|
|
||||||
_errorText.value = "Event stream interrupted; try refreshing."
|
|
||||||
clearPendingRuns()
|
|
||||||
pendingToolCallsById.clear()
|
|
||||||
publishPendingToolCalls()
|
|
||||||
_streamingAssistantText.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun publishPendingToolCalls() {
|
|
||||||
_pendingToolCalls.value =
|
|
||||||
pendingToolCallsById.values.sortedBy { it.startedAtMs }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun armPendingRunTimeout(runId: String) {
|
|
||||||
pendingRunTimeoutJobs[runId]?.cancel()
|
|
||||||
pendingRunTimeoutJobs[runId] =
|
|
||||||
scope.launch {
|
|
||||||
delay(pendingRunTimeoutMs)
|
|
||||||
val stillPending =
|
|
||||||
synchronized(pendingRuns) {
|
|
||||||
pendingRuns.contains(runId)
|
|
||||||
}
|
|
||||||
if (!stillPending) return@launch
|
|
||||||
clearPendingRun(runId)
|
|
||||||
_errorText.value = "Timed out waiting for a reply; try again or refresh."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun clearPendingRun(runId: String) {
|
|
||||||
pendingRunTimeoutJobs.remove(runId)?.cancel()
|
|
||||||
synchronized(pendingRuns) {
|
|
||||||
pendingRuns.remove(runId)
|
|
||||||
_pendingRunCount.value = pendingRuns.size
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun clearPendingRuns() {
|
|
||||||
for ((_, job) in pendingRunTimeoutJobs) {
|
|
||||||
job.cancel()
|
|
||||||
}
|
|
||||||
pendingRunTimeoutJobs.clear()
|
|
||||||
synchronized(pendingRuns) {
|
|
||||||
pendingRuns.clear()
|
|
||||||
_pendingRunCount.value = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseHistory(historyJson: String, sessionKey: String): ChatHistory {
|
|
||||||
val root = json.parseToJsonElement(historyJson).asObjectOrNull() ?: return ChatHistory(sessionKey, null, null, emptyList())
|
|
||||||
val sid = root["sessionId"].asStringOrNull()
|
|
||||||
val thinkingLevel = root["thinkingLevel"].asStringOrNull()
|
|
||||||
val array = root["messages"].asArrayOrNull() ?: JsonArray(emptyList())
|
|
||||||
|
|
||||||
val messages =
|
|
||||||
array.mapNotNull { item ->
|
|
||||||
val obj = item.asObjectOrNull() ?: return@mapNotNull null
|
|
||||||
val role = obj["role"].asStringOrNull() ?: return@mapNotNull null
|
|
||||||
val content = obj["content"].asArrayOrNull()?.mapNotNull(::parseMessageContent) ?: emptyList()
|
|
||||||
val ts = obj["timestamp"].asLongOrNull()
|
|
||||||
ChatMessage(
|
|
||||||
id = UUID.randomUUID().toString(),
|
|
||||||
role = role,
|
|
||||||
content = content,
|
|
||||||
timestampMs = ts,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ChatHistory(sessionKey = sessionKey, sessionId = sid, thinkingLevel = thinkingLevel, messages = messages)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseMessageContent(el: JsonElement): ChatMessageContent? {
|
|
||||||
val obj = el.asObjectOrNull() ?: return null
|
|
||||||
val type = obj["type"].asStringOrNull() ?: "text"
|
|
||||||
return if (type == "text") {
|
|
||||||
ChatMessageContent(type = "text", text = obj["text"].asStringOrNull())
|
|
||||||
} else {
|
|
||||||
ChatMessageContent(
|
|
||||||
type = type,
|
|
||||||
mimeType = obj["mimeType"].asStringOrNull(),
|
|
||||||
fileName = obj["fileName"].asStringOrNull(),
|
|
||||||
base64 = obj["content"].asStringOrNull(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseSessions(jsonString: String): List<ChatSessionEntry> {
|
|
||||||
val root = json.parseToJsonElement(jsonString).asObjectOrNull() ?: return emptyList()
|
|
||||||
val sessions = root["sessions"].asArrayOrNull() ?: return emptyList()
|
|
||||||
return sessions.mapNotNull { item ->
|
|
||||||
val obj = item.asObjectOrNull() ?: return@mapNotNull null
|
|
||||||
val key = obj["key"].asStringOrNull()?.trim().orEmpty()
|
|
||||||
if (key.isEmpty()) return@mapNotNull null
|
|
||||||
val updatedAt = obj["updatedAt"].asLongOrNull()
|
|
||||||
val displayName = obj["displayName"].asStringOrNull()?.trim()
|
|
||||||
ChatSessionEntry(key = key, updatedAtMs = updatedAt, displayName = displayName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseRunId(resJson: String): String? {
|
|
||||||
return try {
|
|
||||||
json.parseToJsonElement(resJson).asObjectOrNull()?.get("runId").asStringOrNull()
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun normalizeThinking(raw: String): String {
|
|
||||||
return when (raw.trim().lowercase()) {
|
|
||||||
"low" -> "low"
|
|
||||||
"medium" -> "medium"
|
|
||||||
"high" -> "high"
|
|
||||||
else -> "off"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
|
||||||
|
|
||||||
private fun JsonElement?.asArrayOrNull(): JsonArray? = this as? JsonArray
|
|
||||||
|
|
||||||
private fun JsonElement?.asStringOrNull(): String? =
|
|
||||||
when (this) {
|
|
||||||
is JsonNull -> null
|
|
||||||
is JsonPrimitive -> content
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun JsonElement?.asLongOrNull(): Long? =
|
|
||||||
when (this) {
|
|
||||||
is JsonPrimitive -> content.toLongOrNull()
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
package ai.openclaw.android.chat
|
|
||||||
|
|
||||||
data class ChatMessage(
|
|
||||||
val id: String,
|
|
||||||
val role: String,
|
|
||||||
val content: List<ChatMessageContent>,
|
|
||||||
val timestampMs: Long?,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ChatMessageContent(
|
|
||||||
val type: String = "text",
|
|
||||||
val text: String? = null,
|
|
||||||
val mimeType: String? = null,
|
|
||||||
val fileName: String? = null,
|
|
||||||
val base64: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ChatPendingToolCall(
|
|
||||||
val toolCallId: String,
|
|
||||||
val name: String,
|
|
||||||
val args: kotlinx.serialization.json.JsonObject? = null,
|
|
||||||
val startedAtMs: Long,
|
|
||||||
val isError: Boolean? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ChatSessionEntry(
|
|
||||||
val key: String,
|
|
||||||
val updatedAtMs: Long?,
|
|
||||||
val displayName: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ChatHistory(
|
|
||||||
val sessionKey: String,
|
|
||||||
val sessionId: String?,
|
|
||||||
val thinkingLevel: String?,
|
|
||||||
val messages: List<ChatMessage>,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class OutgoingAttachment(
|
|
||||||
val type: String,
|
|
||||||
val mimeType: String,
|
|
||||||
val fileName: String,
|
|
||||||
val base64: String,
|
|
||||||
)
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
package ai.openclaw.android.gateway
|
|
||||||
|
|
||||||
object BonjourEscapes {
|
|
||||||
fun decode(input: String): String {
|
|
||||||
if (input.isEmpty()) return input
|
|
||||||
|
|
||||||
val bytes = mutableListOf<Byte>()
|
|
||||||
var i = 0
|
|
||||||
while (i < input.length) {
|
|
||||||
if (input[i] == '\\' && i + 3 < input.length) {
|
|
||||||
val d0 = input[i + 1]
|
|
||||||
val d1 = input[i + 2]
|
|
||||||
val d2 = input[i + 3]
|
|
||||||
if (d0.isDigit() && d1.isDigit() && d2.isDigit()) {
|
|
||||||
val value =
|
|
||||||
((d0.code - '0'.code) * 100) + ((d1.code - '0'.code) * 10) + (d2.code - '0'.code)
|
|
||||||
if (value in 0..255) {
|
|
||||||
bytes.add(value.toByte())
|
|
||||||
i += 4
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val codePoint = Character.codePointAt(input, i)
|
|
||||||
val charBytes = String(Character.toChars(codePoint)).toByteArray(Charsets.UTF_8)
|
|
||||||
for (b in charBytes) {
|
|
||||||
bytes.add(b)
|
|
||||||
}
|
|
||||||
i += Character.charCount(codePoint)
|
|
||||||
}
|
|
||||||
|
|
||||||
return String(bytes.toByteArray(), Charsets.UTF_8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
package ai.openclaw.android.gateway
|
|
||||||
|
|
||||||
import ai.openclaw.android.SecurePrefs
|
|
||||||
|
|
||||||
class DeviceAuthStore(private val prefs: SecurePrefs) {
|
|
||||||
fun loadToken(deviceId: String, role: String): String? {
|
|
||||||
val key = tokenKey(deviceId, role)
|
|
||||||
return prefs.getString(key)?.trim()?.takeIf { it.isNotEmpty() }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveToken(deviceId: String, role: String, token: String) {
|
|
||||||
val key = tokenKey(deviceId, role)
|
|
||||||
prefs.putString(key, token.trim())
|
|
||||||
}
|
|
||||||
|
|
||||||
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,150 +0,0 @@
|
|||||||
package ai.openclaw.android.gateway
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.util.Base64
|
|
||||||
import java.io.File
|
|
||||||
import java.security.KeyFactory
|
|
||||||
import java.security.KeyPairGenerator
|
|
||||||
import java.security.MessageDigest
|
|
||||||
import java.security.Signature
|
|
||||||
import java.security.spec.PKCS8EncodedKeySpec
|
|
||||||
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")
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun loadOrCreate(): DeviceIdentity {
|
|
||||||
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)
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
return existing
|
|
||||||
}
|
|
||||||
val fresh = generate()
|
|
||||||
save(fresh)
|
|
||||||
return fresh
|
|
||||||
}
|
|
||||||
|
|
||||||
fun signPayload(payload: String, identity: DeviceIdentity): String? {
|
|
||||||
return try {
|
|
||||||
val privateKeyBytes = Base64.decode(identity.privateKeyPkcs8Base64, Base64.DEFAULT)
|
|
||||||
val keySpec = PKCS8EncodedKeySpec(privateKeyBytes)
|
|
||||||
val keyFactory = KeyFactory.getInstance("Ed25519")
|
|
||||||
val privateKey = keyFactory.generatePrivate(keySpec)
|
|
||||||
val signature = Signature.getInstance("Ed25519")
|
|
||||||
signature.initSign(privateKey)
|
|
||||||
signature.update(payload.toByteArray(Charsets.UTF_8))
|
|
||||||
base64UrlEncode(signature.sign())
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
val keyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair()
|
|
||||||
val spki = keyPair.public.encoded
|
|
||||||
val rawPublic = stripSpkiPrefix(spki)
|
|
||||||
val deviceId = sha256Hex(rawPublic)
|
|
||||||
val privateKey = keyPair.private.encoded
|
|
||||||
return DeviceIdentity(
|
|
||||||
deviceId = deviceId,
|
|
||||||
publicKeyRawBase64 = Base64.encodeToString(rawPublic, Base64.NO_WRAP),
|
|
||||||
privateKeyPkcs8Base64 = Base64.encodeToString(privateKey, 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 stripSpkiPrefix(spki: ByteArray): ByteArray {
|
|
||||||
if (spki.size == ED25519_SPKI_PREFIX.size + 32 &&
|
|
||||||
spki.copyOfRange(0, ED25519_SPKI_PREFIX.size).contentEquals(ED25519_SPKI_PREFIX)
|
|
||||||
) {
|
|
||||||
return spki.copyOfRange(ED25519_SPKI_PREFIX.size, spki.size)
|
|
||||||
}
|
|
||||||
return spki
|
|
||||||
}
|
|
||||||
|
|
||||||
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("%02x", byte))
|
|
||||||
}
|
|
||||||
return out.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
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 ED25519_SPKI_PREFIX =
|
|
||||||
byteArrayOf(
|
|
||||||
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,521 +0,0 @@
|
|||||||
package ai.openclaw.android.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.android.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.android.gateway
|
|
||||||
|
|
||||||
const val GATEWAY_PROTOCOL_VERSION = 3
|
|
||||||
@ -1,683 +0,0 @@
|
|||||||
package ai.openclaw.android.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,
|
|
||||||
)
|
|
||||||
|
|
||||||
class GatewaySession(
|
|
||||||
private val scope: CoroutineScope,
|
|
||||||
private val identityStore: DeviceIdentityStore,
|
|
||||||
private val deviceAuthStore: DeviceAuthStore,
|
|
||||||
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,
|
|
||||||
) {
|
|
||||||
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)
|
|
||||||
|
|
||||||
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 password: String?,
|
|
||||||
val options: GatewayConnectOptions,
|
|
||||||
val tls: GatewayTlsParams?,
|
|
||||||
)
|
|
||||||
|
|
||||||
private var desired: DesiredConnection? = null
|
|
||||||
private var job: Job? = null
|
|
||||||
@Volatile private var currentConnection: Connection? = null
|
|
||||||
|
|
||||||
fun connect(
|
|
||||||
endpoint: GatewayEndpoint,
|
|
||||||
token: String?,
|
|
||||||
password: String?,
|
|
||||||
options: GatewayConnectOptions,
|
|
||||||
tls: GatewayTlsParams? = null,
|
|
||||||
) {
|
|
||||||
desired = DesiredConnection(endpoint, token, password, options, tls)
|
|
||||||
if (job == null) {
|
|
||||||
job = scope.launch(Dispatchers.IO) { runLoop() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun disconnect() {
|
|
||||||
desired = null
|
|
||||||
currentConnection?.closeQuietly()
|
|
||||||
scope.launch(Dispatchers.IO) {
|
|
||||||
job?.cancelAndJoin()
|
|
||||||
job = null
|
|
||||||
canvasHostUrl = null
|
|
||||||
mainSessionKey = null
|
|
||||||
onDisconnected("Offline")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun reconnect() {
|
|
||||||
currentConnection?.closeQuietly()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun currentCanvasHostUrl(): String? = canvasHostUrl
|
|
||||||
fun currentMainSessionKey(): String? = mainSessionKey
|
|
||||||
|
|
||||||
suspend fun sendNodeEvent(event: String, payloadJson: String?) {
|
|
||||||
val conn = currentConnection ?: return
|
|
||||||
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)
|
|
||||||
} catch (err: Throwable) {
|
|
||||||
Log.w("OpenClawGateway", "node.event failed: ${err.message ?: err::class.java.simpleName}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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"}")
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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()
|
|
||||||
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)
|
|
||||||
val trimmedToken = token?.trim().orEmpty()
|
|
||||||
val authToken = if (storedToken.isNullOrBlank()) trimmedToken else storedToken
|
|
||||||
val canFallbackToShared = !storedToken.isNullOrBlank() && trimmedToken.isNotBlank()
|
|
||||||
val payload = buildConnectParams(identity, connectNonce, authToken, password?.trim())
|
|
||||||
val res = request("connect", payload, timeoutMs = 8_000)
|
|
||||||
if (!res.ok) {
|
|
||||||
val msg = res.error?.message ?: "connect failed"
|
|
||||||
if (canFallbackToShared) {
|
|
||||||
deviceAuthStore.clearToken(identity.deviceId, options.role)
|
|
||||||
}
|
|
||||||
throw IllegalStateException(msg)
|
|
||||||
}
|
|
||||||
val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload")
|
|
||||||
val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: throw IllegalStateException("connect failed")
|
|
||||||
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(identity.deviceId, authRole, deviceToken)
|
|
||||||
}
|
|
||||||
val rawCanvas = obj["canvasHostUrl"].asStringOrNull()
|
|
||||||
canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint)
|
|
||||||
val sessionDefaults =
|
|
||||||
obj["snapshot"].asObjectOrNull()
|
|
||||||
?.get("sessionDefaults").asObjectOrNull()
|
|
||||||
mainSessionKey = sessionDefaults?.get("mainSessionKey").asStringOrNull()
|
|
||||||
onConnected(serverName, remoteAddress, mainSessionKey)
|
|
||||||
connectDeferred.complete(Unit)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildConnectParams(
|
|
||||||
identity: DeviceIdentity,
|
|
||||||
connectNonce: String?,
|
|
||||||
authToken: String,
|
|
||||||
authPassword: String?,
|
|
||||||
): 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 password = authPassword?.trim().orEmpty()
|
|
||||||
val authJson =
|
|
||||||
when {
|
|
||||||
authToken.isNotEmpty() ->
|
|
||||||
buildJsonObject {
|
|
||||||
put("token", JsonPrimitive(authToken))
|
|
||||||
}
|
|
||||||
password.isNotEmpty() ->
|
|
||||||
buildJsonObject {
|
|
||||||
put("password", JsonPrimitive(password))
|
|
||||||
}
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
val signedAtMs = System.currentTimeMillis()
|
|
||||||
val payload =
|
|
||||||
buildDeviceAuthPayload(
|
|
||||||
deviceId = identity.deviceId,
|
|
||||||
clientId = client.id,
|
|
||||||
clientMode = client.mode,
|
|
||||||
role = options.role,
|
|
||||||
scopes = options.scopes,
|
|
||||||
signedAtMs = signedAtMs,
|
|
||||||
token = if (authToken.isNotEmpty()) authToken else null,
|
|
||||||
nonce = connectNonce,
|
|
||||||
)
|
|
||||||
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))
|
|
||||||
if (!connectNonce.isNullOrBlank()) {
|
|
||||||
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"
|
|
||||||
ErrorShape(code, msg)
|
|
||||||
}
|
|
||||||
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) {
|
|
||||||
connectNonceDeferred.complete(nonce)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (event == "node.invoke.request" && payloadJson != null && onInvoke != null) {
|
|
||||||
handleInvokeEvent(payloadJson)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
onEvent(event, payloadJson)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun awaitConnectNonce(): String? {
|
|
||||||
if (isLoopbackHost(endpoint.host)) return null
|
|
||||||
return try {
|
|
||||||
withTimeout(2_000) { connectNonceDeferred.await() }
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun sendInvokeResult(id: String, nodeId: String, result: InvokeResult) {
|
|
||||||
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))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
request("node.invoke.result", params, timeoutMs = 15_000)
|
|
||||||
} catch (err: Throwable) {
|
|
||||||
Log.w(loggerTag, "node.invoke.result failed: ${err.message ?: err::class.java.simpleName}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun invokeErrorFromThrowable(err: Throwable): InvokeResult {
|
|
||||||
val msg = err.message?.trim().takeIf { !it.isNullOrEmpty() } ?: err::class.java.simpleName
|
|
||||||
val parts = msg.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 InvokeResult.error(code = code, message = rest.ifEmpty { msg })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return InvokeResult.error(code = "UNAVAILABLE", message = msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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}")
|
|
||||||
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.password, target.options, target.tls)
|
|
||||||
currentConnection = conn
|
|
||||||
try {
|
|
||||||
conn.connect()
|
|
||||||
conn.awaitClose()
|
|
||||||
} finally {
|
|
||||||
currentConnection = null
|
|
||||||
canvasHostUrl = null
|
|
||||||
mainSessionKey = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildDeviceAuthPayload(
|
|
||||||
deviceId: String,
|
|
||||||
clientId: String,
|
|
||||||
clientMode: String,
|
|
||||||
role: String,
|
|
||||||
scopes: List<String>,
|
|
||||||
signedAtMs: Long,
|
|
||||||
token: String?,
|
|
||||||
nonce: String?,
|
|
||||||
): String {
|
|
||||||
val scopeString = scopes.joinToString(",")
|
|
||||||
val authToken = token.orEmpty()
|
|
||||||
val version = if (nonce.isNullOrBlank()) "v1" else "v2"
|
|
||||||
val parts =
|
|
||||||
mutableListOf(
|
|
||||||
version,
|
|
||||||
deviceId,
|
|
||||||
clientId,
|
|
||||||
clientMode,
|
|
||||||
role,
|
|
||||||
scopeString,
|
|
||||||
signedAtMs.toString(),
|
|
||||||
authToken,
|
|
||||||
)
|
|
||||||
if (!nonce.isNullOrBlank()) {
|
|
||||||
parts.add(nonce)
|
|
||||||
}
|
|
||||||
return parts.joinToString("|")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun normalizeCanvasHostUrl(raw: String?, endpoint: GatewayEndpoint): 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" }
|
|
||||||
|
|
||||||
if (trimmed.isNotBlank() && !isLoopbackHost(host)) {
|
|
||||||
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 }
|
|
||||||
|
|
||||||
val fallbackPort = endpoint.canvasPort ?: if (port > 0) port else 18793
|
|
||||||
val formattedHost = if (fallbackHost.contains(":")) "[${fallbackHost}]" else fallbackHost
|
|
||||||
return "$scheme://$formattedHost:$fallbackPort"
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,90 +0,0 @@
|
|||||||
package ai.openclaw.android.gateway
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import java.security.MessageDigest
|
|
||||||
import java.security.SecureRandom
|
|
||||||
import java.security.cert.CertificateException
|
|
||||||
import java.security.cert.X509Certificate
|
|
||||||
import javax.net.ssl.HostnameVerifier
|
|
||||||
import javax.net.ssl.SSLContext
|
|
||||||
import javax.net.ssl.SSLSocketFactory
|
|
||||||
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())
|
|
||||||
return GatewayTlsConfig(
|
|
||||||
sslSocketFactory = context.socketFactory,
|
|
||||||
trustManager = trustManager,
|
|
||||||
hostnameVerifier = HostnameVerifier { _, _ -> true },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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("%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().filter { it in '0'..'9' || it in 'a'..'f' }
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user