Compare commits
227
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c01bfcbc12 | ||
|
|
47110e88c7 | ||
|
|
51dfd6efdb | ||
|
|
4fad74738a | ||
|
|
69f0469530 | ||
|
|
eb1ee36f59 | ||
|
|
b341512564 | ||
|
|
6734f2d71c | ||
|
|
e12abf3114 | ||
|
|
2dfd3b9a81 | ||
|
|
7b6cbf5869 | ||
|
|
8686b3b951 | ||
|
|
2e7e135bc0 | ||
|
|
c287664923 | ||
|
|
18f0051d26 | ||
|
|
b012b1105e | ||
|
|
21370fc09b | ||
|
|
4999f15688 | ||
|
|
e4f9555f21 | ||
|
|
243a8b019e | ||
|
|
5c4079f66c | ||
|
|
b1f086b536 | ||
|
|
d298b8c16b | ||
|
|
40968bd5e0 | ||
|
|
80e6c070bf | ||
|
|
26fcca087b | ||
|
|
02ca148583 | ||
|
|
ae1c6f4313 | ||
|
|
9faed2226a | ||
|
|
cf04b24632 | ||
|
|
9f856abfe7 | ||
|
|
e74fd9196c | ||
|
|
40e928a4c4 | ||
|
|
faa5838147 | ||
|
|
f6abe62e5f | ||
|
|
5c5745dee5 | ||
|
|
15c735de4d | ||
|
|
8bf484bdad | ||
|
|
36719690a2 | ||
|
|
f2666d2092 | ||
|
|
a28c271488 | ||
|
|
1d9d5b30ce | ||
|
|
14f56a4e18 | ||
|
|
687c41e838 | ||
|
|
ddb7b5c6a4 | ||
|
|
262e35c219 | ||
|
|
95f0befd65 | ||
|
|
83d5e30027 | ||
|
|
842be7b864 | ||
|
|
cb5d76ed3d | ||
|
|
3d5ffee07f | ||
|
|
bd8f4b052d | ||
|
|
929d50b7d1 | ||
|
|
4fda10c508 | ||
|
|
0b0d8b2406 | ||
|
|
844ff2ee8f | ||
|
|
8c666666ef | ||
|
|
2394703593 | ||
|
|
404470853a | ||
|
|
99fc0fbac1 | ||
|
|
91ed00f800 | ||
|
|
76698ed296 | ||
|
|
716546824f | ||
|
|
74f382f732 | ||
|
|
a76aea1bc0 | ||
|
|
533766207f | ||
|
|
59fa002561 | ||
|
|
48ab168df2 | ||
|
|
bef9d5bdc8 | ||
|
|
c6812c6af4 | ||
|
|
1f7cb4b853 | ||
|
|
d161f3ab0f | ||
|
|
c65b91c841 | ||
|
|
760b1e8fc6 | ||
|
|
188893f319 | ||
|
|
04ee9e7765 | ||
|
|
390ba5f42a | ||
|
|
b8593fd4fb | ||
|
|
68a467dd66 | ||
|
|
d18319a57d | ||
|
|
15e5bb3459 | ||
|
|
41f6d06967 | ||
|
|
e3a99aa2ce | ||
|
|
c1d8456860 | ||
|
|
da10ca1585 | ||
|
|
5d017dae5a | ||
|
|
30fd7001f2 | ||
|
|
da4b124480 | ||
|
|
710c681283 | ||
|
|
e45228ac37 | ||
|
|
a0180f364d | ||
|
|
d69f246ba7 | ||
|
|
a81989048d | ||
|
|
b56e9964f5 | ||
|
|
ddd7fc1513 | ||
|
|
4ebf55f1db | ||
|
|
cc24ede586 | ||
|
|
eb3b84f3d2 | ||
|
|
304244f2be | ||
|
|
f067ea25b4 | ||
|
|
fa51294f65 | ||
|
|
a4d1c4d522 | ||
|
|
6e17c463ae | ||
|
|
63797e841d | ||
|
|
fdb171cb15 | ||
|
|
6f9861bb9b | ||
|
|
759068304e | ||
|
|
ded578b1fa | ||
|
|
dcb8d16591 | ||
|
|
06c17a333e | ||
|
|
409a16060b | ||
|
|
7720106624 | ||
|
|
c613769d22 | ||
|
|
87343c374e | ||
|
|
67be9aed28 | ||
|
|
b48d5d96d3 | ||
|
|
d8cc7db5e6 | ||
|
|
dfbf6ac263 | ||
|
|
121ae6036b | ||
|
|
6e1ad31b49 | ||
|
|
b8b0b3f0e7 | ||
|
|
0330b483ad | ||
|
|
9a2bf57e1c | ||
|
|
439044068a | ||
|
|
4c3b4aeb76 | ||
|
|
1e8b291374 | ||
|
|
95f82154f7 | ||
|
|
7bc3998451 | ||
|
|
d029ceab1c | ||
|
|
c331bdc27d | ||
|
|
b0b42b4e14 | ||
|
|
e5514d4854 | ||
|
|
20bc89d96c | ||
|
|
199fef2a5e | ||
|
|
d9a2ac7e72 | ||
|
|
a16934b2ab | ||
|
|
14a072f5fa | ||
|
|
574b848863 | ||
|
|
2e6c58bf75 | ||
|
|
a5d89e6eb1 | ||
|
|
61907ddf3e | ||
|
|
1eab8fa9b0 | ||
|
|
2cf444be02 | ||
|
|
7870ce8177 | ||
|
|
e9d691d472 | ||
|
|
ac2fcfe96a | ||
|
|
627fa3083b | ||
|
|
e4877656ca | ||
|
|
d91f0ceeb3 | ||
|
|
9b71382efb | ||
|
|
dd82d32d85 | ||
|
|
5a42f7cabd | ||
|
|
f2c25c5f40 | ||
|
|
528524e4c7 | ||
|
|
7e08de4a5f | ||
|
|
c9e3c14f9c | ||
|
|
636a8e3181 | ||
|
|
660f87278c | ||
|
|
9ec1fb4a80 | ||
|
|
74757cd5af | ||
|
|
9f75550702 | ||
|
|
8214ab507c | ||
|
|
2f6d5805de | ||
|
|
48f733e4b3 | ||
|
|
e5f7435d9f | ||
|
|
322c8dc4fc | ||
|
|
cf04b0e3bf | ||
|
|
0f9f510dd9 | ||
|
|
e110cf4fb1 | ||
|
|
6942ceb7a9 | ||
|
|
47cf28f6b6 | ||
|
|
115b4379bf | ||
|
|
a6db1edee3 | ||
|
|
4ed1b7c7ed | ||
|
|
7ef7b94bc0 | ||
|
|
da7da30b22 | ||
|
|
213d9b47b0 | ||
|
|
d88b239d3c | ||
|
|
1c02de1309 | ||
|
|
d3c2b83f88 | ||
|
|
93f80894a3 | ||
|
|
c440cc2f84 | ||
|
|
8ddedc3fc5 | ||
|
|
2439c31844 | ||
|
|
292f21ae78 | ||
|
|
9c2c4b1138 | ||
|
|
e0e33e12d1 | ||
|
|
eb5145c5d1 | ||
|
|
aedf4ce328 | ||
|
|
94af5a72fc | ||
|
|
9dbc1435a6 | ||
|
|
32a668e4d9 | ||
|
|
c17c7b4e24 | ||
|
|
c5e732951b | ||
|
|
069b50635b | ||
|
|
074db1905a | ||
|
|
9609a3af40 | ||
|
|
476087f879 | ||
|
|
41b696fa83 | ||
|
|
a5adedea91 | ||
|
|
9266e542ab | ||
|
|
f06ad4502b | ||
|
|
e26c647828 | ||
|
|
51f1f23235 | ||
|
|
c9d02f0132 | ||
|
|
bee72f1ae0 | ||
|
|
509bc81e28 | ||
|
|
6d5195c890 | ||
|
|
11b9b6dba5 | ||
|
|
bbc67f3754 | ||
|
|
4f02c74dca | ||
|
|
a5afe7bc2b | ||
|
|
f4b03599f0 | ||
|
|
7f6e87e918 | ||
|
|
e14ff8f407 | ||
|
|
87d995bcde | ||
|
|
44d55667de | ||
|
|
3e546e691d | ||
|
|
5b0684ebcf | ||
|
|
0bd99717be | ||
|
|
154c49511c | ||
|
|
34462b3221 | ||
|
|
0372bdf6fe | ||
|
|
cd8309cc31 | ||
|
|
c3a34408f3 | ||
|
|
9f280454ba | ||
|
|
5d9a5b7958 |
No files matched your search
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,366 @@
|
|||||||
|
---
|
||||||
|
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."
|
||||||
|
```
|
||||||
@@ -66,3 +66,6 @@ apps/ios/*.mobileprovision
|
|||||||
IDENTITY.md
|
IDENTITY.md
|
||||||
USER.md
|
USER.md
|
||||||
.tgz
|
.tgz
|
||||||
|
|
||||||
|
# local tooling
|
||||||
|
.serena/
|
||||||
@@ -1 +1 @@
|
|||||||
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty,@lydell/node-pty
|
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty,@lydell/node-pty,@matrix-org/matrix-sdk-crypto-nodejs
|
||||||
@@ -41,6 +41,11 @@
|
|||||||
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.
|
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.
|
||||||
- Naming: use **Clawdbot** for product/app/docs headings; use `clawdbot` for CLI command, package/binary, paths, and config keys.
|
- Naming: use **Clawdbot** for product/app/docs headings; use `clawdbot` 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`.
|
||||||
@@ -57,6 +62,7 @@
|
|||||||
- Changelog workflow: keep latest released version at top (no `Unreleased`); after publishing, bump version and start a new top section.
|
- 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 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.
|
- 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.
|
||||||
- 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`.
|
- 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.
|
- 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.
|
||||||
|
|||||||
+47
-3
@@ -2,17 +2,58 @@
|
|||||||
|
|
||||||
Docs: https://docs.clawd.bot
|
Docs: https://docs.clawd.bot
|
||||||
|
|
||||||
|
## 2026.1.20
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Deps: update workspace + memory-lancedb dependencies.
|
||||||
|
- Repo: remove the Peekaboo git submodule now that the SPM release is used.
|
||||||
|
- Update: sync plugin sources on channel switches and update npm-installed plugins during `clawdbot update`.
|
||||||
|
- Plugins: share npm plugin update logic between `clawdbot update` and `clawdbot plugins update`.
|
||||||
|
- Channels: add the Nostr plugin channel with profile management + onboarding install defaults. (#1323) — thanks @joelklabo.
|
||||||
|
- Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow.
|
||||||
|
- Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel.
|
||||||
|
- Discord: fall back to /skill when native command limits are exceeded; expose /skill globally. (#1287) — thanks @thewilloftheshadow.
|
||||||
|
- Docs: refresh bird skill install metadata and usage notes. (#1302) — thanks @odysseus0.
|
||||||
|
- Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) — thanks @sibbl.
|
||||||
|
- Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) — thanks @steipete.
|
||||||
|
- Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) — thanks @suminhthanh.
|
||||||
|
- Security: warn when <=300B models run without sandboxing and with web tools enabled.
|
||||||
|
### Fixes
|
||||||
|
- Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs.
|
||||||
|
- Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241) — thanks @gnarco.
|
||||||
|
- Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs.
|
||||||
|
- Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry.
|
||||||
|
- Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332) — thanks @dougvk.
|
||||||
|
- Doctor: clarify plugin auto-enable hint text in the startup banner.
|
||||||
|
- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.
|
||||||
|
- UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315) — thanks @MaudeBot.
|
||||||
|
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
|
||||||
|
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.
|
||||||
|
- TUI: align custom editor initialization with the latest pi-tui API. (#1298) — thanks @sibbl.
|
||||||
|
- CLI: avoid duplicating --profile/--dev flags when formatting commands.
|
||||||
|
- CLI: load channel plugins for commands that need registry-backed lookups. (#1338) — thanks @MaudeBot.
|
||||||
|
- Status: route native `/status` to the active agent so model selection reflects the correct profile. (#1301)
|
||||||
|
- Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297) — thanks @ysqander.
|
||||||
|
- Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304)
|
||||||
|
- Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297) — thanks @ysqander.
|
||||||
|
- Anthropic: default API prompt caching to 1h with configurable TTL override; ignore TTL for OAuth.
|
||||||
|
- Discord: make resolve warnings avoid raw JSON payloads on rate limits.
|
||||||
|
- Discord: process message handlers in parallel across sessions to avoid event queue blocking. (#1295)
|
||||||
|
- Cron: auto-deliver isolated agent output to explicit targets without tool calls. (#1285)
|
||||||
|
|
||||||
## 2026.1.19-3
|
## 2026.1.19-3
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
- Android: remove legacy bridge transport code now that nodes use the gateway protocol.
|
- Android: remove legacy bridge transport code now that nodes use the gateway protocol.
|
||||||
- Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects.
|
- Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects.
|
||||||
|
- Gateway: expand `/v1/responses` to support file/image inputs, tool_choice, usage, and output limits. (#1229) — thanks @RyanLisse.
|
||||||
- Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) — thanks @steipete.
|
- Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) — thanks @steipete.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Gateway: strip inbound envelope headers from chat history messages to keep clients clean.
|
- Gateway: strip inbound envelope headers from chat history messages to keep clients clean.
|
||||||
- UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283) — thanks @bradleypriest.
|
- UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283) — thanks @bradleypriest.
|
||||||
- UI: centralize Control UI session routing + URL sync so chat deep links stay stable. (#1288) — thanks @bradleypriest.
|
- Config: allow Perplexity as a web_search provider in config validation. (#1230)
|
||||||
|
- Browser: register AI snapshot refs for act commands. (#1282) — thanks @John-Rood.
|
||||||
|
|
||||||
## 2026.1.19-2
|
## 2026.1.19-2
|
||||||
|
|
||||||
@@ -31,9 +72,11 @@ Docs: https://docs.clawd.bot
|
|||||||
- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety; run `clawdbot doctor --fix` to repair.
|
- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety; run `clawdbot doctor --fix` to repair.
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
- Gateway: add `/v1/responses` endpoint (OpenResponses API) for agentic workflows with item-based input and semantic streaming events. Enable via `gateway.http.endpoints.responses.enabled: true`.
|
||||||
- Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting.
|
- Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting.
|
||||||
- Agents: clarify node_modules read-only guidance in agent instructions.
|
- Agents: clarify node_modules read-only guidance in agent instructions.
|
||||||
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07.
|
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07.
|
||||||
|
- TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. (#1271) — thanks @Whoaa512.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba.
|
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba.
|
||||||
@@ -46,6 +89,7 @@ Docs: https://docs.clawd.bot
|
|||||||
- TUI: show generic empty-state text for searchable pickers. (#1201) — thanks @vignesh07.
|
- TUI: show generic empty-state text for searchable pickers. (#1201) — thanks @vignesh07.
|
||||||
- Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)
|
- Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)
|
||||||
- CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207) — thanks @gumadeiras.
|
- CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207) — thanks @gumadeiras.
|
||||||
|
- Config: allow custom fields under `skills.entries.<name>.config` for skill credentials/config. (#1226) — thanks @VACInc. (fixes #1225)
|
||||||
|
|
||||||
## 2026.1.18-5
|
## 2026.1.18-5
|
||||||
|
|
||||||
@@ -71,8 +115,8 @@ Docs: https://docs.clawd.bot
|
|||||||
## 2026.1.18-4
|
## 2026.1.18-4
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release (no submodule).
|
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release.
|
||||||
- macOS: stop syncing Peekaboo as a git submodule in postinstall.
|
- macOS: stop syncing Peekaboo in postinstall.
|
||||||
- Swabble: use the tagged Commander Swift package release.
|
- Swabble: use the tagged Commander Swift package release.
|
||||||
- CLI: add `clawdbot acp client` interactive ACP harness for debugging.
|
- CLI: add `clawdbot acp client` interactive ACP harness for debugging.
|
||||||
- Plugins: route command detection/text chunking helpers through the plugin runtime and drop runtime exports from the SDK.
|
- Plugins: route command detection/text chunking helpers through the plugin runtime and drop runtime exports from the SDK.
|
||||||
|
|||||||
@@ -71,6 +71,15 @@ clawdbot agent --message "Ship checklist" --thinking high
|
|||||||
|
|
||||||
Upgrading? [Updating guide](https://docs.clawd.bot/install/updating) (and run `clawdbot doctor`).
|
Upgrading? [Updating guide](https://docs.clawd.bot/install/updating) (and run `clawdbot 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): `clawdbot update --channel stable|beta|dev`.
|
||||||
|
Details: [Development channels](https://docs.clawd.bot/install/development-channels).
|
||||||
|
|
||||||
## From source (development)
|
## From source (development)
|
||||||
|
|
||||||
Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly.
|
Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly.
|
||||||
@@ -258,12 +267,7 @@ Send these in WhatsApp/Telegram/Slack/Microsoft Teams/WebChat (group commands ar
|
|||||||
|
|
||||||
The Gateway alone delivers a great experience. All apps are optional and add extra features.
|
The Gateway alone delivers a great experience. All apps are optional and add extra features.
|
||||||
|
|
||||||
If you plan to build/run companion apps, initialize submodules first:
|
If you plan to build/run companion apps, follow the platform runbooks below.
|
||||||
|
|
||||||
```bash
|
|
||||||
git submodule update --init --recursive
|
|
||||||
./scripts/restart-mac.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### macOS (Clawdbot.app) (optional)
|
### macOS (Clawdbot.app) (optional)
|
||||||
|
|
||||||
@@ -476,24 +480,26 @@ Thanks to all clawtributors:
|
|||||||
|
|
||||||
<p align="left">
|
<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/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/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/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a>
|
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/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/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a>
|
||||||
<a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a>
|
<a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/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/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/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/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/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/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/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/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a>
|
<a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/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/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a>
|
||||||
<a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/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/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/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/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/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a>
|
||||||
<a href="https://github.com/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/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/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/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/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/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/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/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/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/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/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/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></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/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/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/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/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></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/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/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/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a>
|
||||||
<a href="https://github.com/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/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/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/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/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/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/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/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/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/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/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a>
|
||||||
<a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/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/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/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/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a>
|
<a href="https://github.com/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/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/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/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/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/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/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/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/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/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a>
|
<a href="https://github.com/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/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/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/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/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/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/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/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/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=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/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a>
|
<a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/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/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/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/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a>
|
||||||
<a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/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/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/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/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/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/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/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/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/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a>
|
<a href="https://github.com/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/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/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/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/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/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/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/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/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/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/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a>
|
||||||
<a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=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/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></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/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/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/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a>
|
||||||
<a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/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/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a>
|
<a href="https://github.com/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/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/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/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a>
|
||||||
<a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a>
|
<a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=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/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/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a>
|
||||||
<a href="https://github.com/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/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/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a>
|
<a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></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/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a>
|
||||||
<a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a>
|
<a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/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/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/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/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=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/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>
|
<a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a>
|
||||||
|
<a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/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>
|
</p>
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "5d29ee82825e0764775562242cfa1ff4dc79584797dd638f76c9876545454748",
|
"originHash" : "c0677e232394b5f6b0191b6dbb5bae553d55264f65ae725cd03a8ffdfda9cdd3",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "elevenlabskit",
|
"identity" : "commander",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/steipete/ElevenLabsKit",
|
"location" : "https://github.com/steipete/Commander.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
|
"revision" : "9e349575c8e3c6745e81fe19e5bb5efa01b078ce",
|
||||||
"version" : "0.1.0"
|
"version" : "0.2.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+2
-2
@@ -101,8 +101,8 @@ Environment variables:
|
|||||||
- Authorization requested at first start; requires macOS 26 + new Speech.framework APIs.
|
- Authorization requested at first start; requires macOS 26 + new Speech.framework APIs.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
- Format: `./scripts/format.sh` (uses ../peekaboo/.swiftformat if present)
|
- Format: `./scripts/format.sh` (uses local `.swiftformat`)
|
||||||
- Lint: `./scripts/lint.sh` (uses ../peekaboo/.swiftlint.yml if present)
|
- Lint: `./scripts/lint.sh` (uses local `.swiftlint.yml`)
|
||||||
- Tests: `swift test` (uses swift-testing package)
|
- Tests: `swift test` (uses swift-testing package)
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
PEEKABOO_ROOT="${ROOT}/../peekaboo"
|
CONFIG="${ROOT}/.swiftformat"
|
||||||
if [ -f "${PEEKABOO_ROOT}/.swiftformat" ]; then
|
|
||||||
CONFIG="${PEEKABOO_ROOT}/.swiftformat"
|
|
||||||
else
|
|
||||||
CONFIG="${ROOT}/.swiftformat"
|
|
||||||
fi
|
|
||||||
swiftformat --config "$CONFIG" "$ROOT/Sources"
|
swiftformat --config "$CONFIG" "$ROOT/Sources"
|
||||||
@@ -1,12 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
PEEKABOO_ROOT="${ROOT}/../peekaboo"
|
CONFIG="${ROOT}/.swiftlint.yml"
|
||||||
if [ -f "${PEEKABOO_ROOT}/.swiftlint.yml" ]; then
|
|
||||||
CONFIG="${PEEKABOO_ROOT}/.swiftlint.yml"
|
|
||||||
else
|
|
||||||
CONFIG="$ROOT/.swiftlint.yml"
|
|
||||||
fi
|
|
||||||
if ! command -v swiftlint >/dev/null; then
|
if ! command -v swiftlint >/dev/null; then
|
||||||
echo "swiftlint not installed" >&2
|
echo "swiftlint not installed" >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
## Clawdbot Node (Android) (internal)
|
## Clawdbot Node (Android) (internal)
|
||||||
|
|
||||||
Modern Android node app: connects to the **Gateway WebSocket** (`_clawdbot-gateway._tcp`) and exposes **Canvas + Chat + Camera**.
|
Modern Android node app: connects to the **Gateway WebSocket** (`_clawdbot-gw._tcp`) and exposes **Canvas + Chat + Camera**.
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action).
|
- The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action).
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ android {
|
|||||||
applicationId = "com.clawdbot.android"
|
applicationId = "com.clawdbot.android"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 202601114
|
versionCode = 202601200
|
||||||
versionName = "2026.1.11-4"
|
versionName = "2026.1.20"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import com.clawdbot.android.chat.ChatMessage
|
|||||||
import com.clawdbot.android.chat.ChatPendingToolCall
|
import com.clawdbot.android.chat.ChatPendingToolCall
|
||||||
import com.clawdbot.android.chat.ChatSessionEntry
|
import com.clawdbot.android.chat.ChatSessionEntry
|
||||||
import com.clawdbot.android.chat.OutgoingAttachment
|
import com.clawdbot.android.chat.OutgoingAttachment
|
||||||
|
import com.clawdbot.android.gateway.DeviceAuthStore
|
||||||
import com.clawdbot.android.gateway.DeviceIdentityStore
|
import com.clawdbot.android.gateway.DeviceIdentityStore
|
||||||
import com.clawdbot.android.gateway.GatewayClientInfo
|
import com.clawdbot.android.gateway.GatewayClientInfo
|
||||||
import com.clawdbot.android.gateway.GatewayConnectOptions
|
import com.clawdbot.android.gateway.GatewayConnectOptions
|
||||||
@@ -62,6 +63,7 @@ class NodeRuntime(context: Context) {
|
|||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
val prefs = SecurePrefs(appContext)
|
val prefs = SecurePrefs(appContext)
|
||||||
|
private val deviceAuthStore = DeviceAuthStore(prefs)
|
||||||
val canvas = CanvasController()
|
val canvas = CanvasController()
|
||||||
val camera = CameraCaptureManager(appContext)
|
val camera = CameraCaptureManager(appContext)
|
||||||
val location = LocationCaptureManager(appContext)
|
val location = LocationCaptureManager(appContext)
|
||||||
@@ -153,6 +155,7 @@ class NodeRuntime(context: Context) {
|
|||||||
GatewaySession(
|
GatewaySession(
|
||||||
scope = scope,
|
scope = scope,
|
||||||
identityStore = identityStore,
|
identityStore = identityStore,
|
||||||
|
deviceAuthStore = deviceAuthStore,
|
||||||
onConnected = { name, remote, mainSessionKey ->
|
onConnected = { name, remote, mainSessionKey ->
|
||||||
operatorConnected = true
|
operatorConnected = true
|
||||||
operatorStatusText = "Connected"
|
operatorStatusText = "Connected"
|
||||||
@@ -188,6 +191,7 @@ class NodeRuntime(context: Context) {
|
|||||||
GatewaySession(
|
GatewaySession(
|
||||||
scope = scope,
|
scope = scope,
|
||||||
identityStore = identityStore,
|
identityStore = identityStore,
|
||||||
|
deviceAuthStore = deviceAuthStore,
|
||||||
onConnected = { _, _, _ ->
|
onConnected = { _, _, _ ->
|
||||||
nodeConnected = true
|
nodeConnected = true
|
||||||
nodeStatusText = "Connected"
|
nodeStatusText = "Connected"
|
||||||
|
|||||||
@@ -189,6 +189,18 @@ class SecurePrefs(context: Context) {
|
|||||||
prefs.edit { putString(key, fingerprint.trim()) }
|
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 loadOrCreateInstanceId(): String {
|
private fun loadOrCreateInstanceId(): String {
|
||||||
val existing = prefs.getString("node.instanceId", null)?.trim()
|
val existing = prefs.getString("node.instanceId", null)?.trim()
|
||||||
if (!existing.isNullOrBlank()) return existing
|
if (!existing.isNullOrBlank()) return existing
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.clawdbot.android.gateway
|
||||||
|
|
||||||
|
import com.clawdbot.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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,7 +51,7 @@ class GatewayDiscovery(
|
|||||||
private val nsd = context.getSystemService(NsdManager::class.java)
|
private val nsd = context.getSystemService(NsdManager::class.java)
|
||||||
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
|
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
|
||||||
private val dns = DnsResolver.getInstance()
|
private val dns = DnsResolver.getInstance()
|
||||||
private val serviceType = "_clawdbot-gateway._tcp."
|
private val serviceType = "_clawdbot-gw._tcp."
|
||||||
private val wideAreaDomain = "clawdbot.internal."
|
private val wideAreaDomain = "clawdbot.internal."
|
||||||
private val logTag = "Clawdbot/GatewayDiscovery"
|
private val logTag = "Clawdbot/GatewayDiscovery"
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ data class GatewayConnectOptions(
|
|||||||
class GatewaySession(
|
class GatewaySession(
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
private val identityStore: DeviceIdentityStore,
|
private val identityStore: DeviceIdentityStore,
|
||||||
|
private val deviceAuthStore: DeviceAuthStore,
|
||||||
private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit,
|
private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit,
|
||||||
private val onDisconnected: (message: String) -> Unit,
|
private val onDisconnected: (message: String) -> Unit,
|
||||||
private val onEvent: (event: String, payloadJson: String?) -> Unit,
|
private val onEvent: (event: String, payloadJson: String?) -> Unit,
|
||||||
@@ -177,6 +178,7 @@ class GatewaySession(
|
|||||||
private val connectDeferred = CompletableDeferred<Unit>()
|
private val connectDeferred = CompletableDeferred<Unit>()
|
||||||
private val closedDeferred = CompletableDeferred<Unit>()
|
private val closedDeferred = CompletableDeferred<Unit>()
|
||||||
private val isClosed = AtomicBoolean(false)
|
private val isClosed = AtomicBoolean(false)
|
||||||
|
private val connectNonceDeferred = CompletableDeferred<String?>()
|
||||||
private val client: OkHttpClient = buildClient()
|
private val client: OkHttpClient = buildClient()
|
||||||
private var socket: WebSocket? = null
|
private var socket: WebSocket? = null
|
||||||
private val loggerTag = "ClawdbotGateway"
|
private val loggerTag = "ClawdbotGateway"
|
||||||
@@ -253,7 +255,8 @@ class GatewaySession(
|
|||||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
sendConnect()
|
val nonce = awaitConnectNonce()
|
||||||
|
sendConnect(nonce)
|
||||||
} catch (err: Throwable) {
|
} catch (err: Throwable) {
|
||||||
connectDeferred.completeExceptionally(err)
|
connectDeferred.completeExceptionally(err)
|
||||||
closeQuietly()
|
closeQuietly()
|
||||||
@@ -288,16 +291,30 @@ class GatewaySession(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun sendConnect() {
|
private suspend fun sendConnect(connectNonce: String?) {
|
||||||
val payload = buildConnectParams()
|
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)
|
val res = request("connect", payload, timeoutMs = 8_000)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
val msg = res.error?.message ?: "connect failed"
|
val msg = res.error?.message ?: "connect failed"
|
||||||
|
if (canFallbackToShared) {
|
||||||
|
deviceAuthStore.clearToken(identity.deviceId, options.role)
|
||||||
|
}
|
||||||
throw IllegalStateException(msg)
|
throw IllegalStateException(msg)
|
||||||
}
|
}
|
||||||
val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload")
|
val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload")
|
||||||
val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: throw IllegalStateException("connect failed")
|
val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: throw IllegalStateException("connect failed")
|
||||||
val serverName = obj["server"].asObjectOrNull()?.get("host").asStringOrNull()
|
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()
|
val rawCanvas = obj["canvasHostUrl"].asStringOrNull()
|
||||||
canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint)
|
canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint)
|
||||||
val sessionDefaults =
|
val sessionDefaults =
|
||||||
@@ -308,7 +325,12 @@ class GatewaySession(
|
|||||||
connectDeferred.complete(Unit)
|
connectDeferred.complete(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildConnectParams(): JsonObject {
|
private fun buildConnectParams(
|
||||||
|
identity: DeviceIdentity,
|
||||||
|
connectNonce: String?,
|
||||||
|
authToken: String,
|
||||||
|
authPassword: String?,
|
||||||
|
): JsonObject {
|
||||||
val client = options.client
|
val client = options.client
|
||||||
val locale = Locale.getDefault().toLanguageTag()
|
val locale = Locale.getDefault().toLanguageTag()
|
||||||
val clientObj =
|
val clientObj =
|
||||||
@@ -323,22 +345,20 @@ class GatewaySession(
|
|||||||
client.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
client.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
val authToken = token?.trim().orEmpty()
|
val password = authPassword?.trim().orEmpty()
|
||||||
val authPassword = password?.trim().orEmpty()
|
|
||||||
val authJson =
|
val authJson =
|
||||||
when {
|
when {
|
||||||
authToken.isNotEmpty() ->
|
authToken.isNotEmpty() ->
|
||||||
buildJsonObject {
|
buildJsonObject {
|
||||||
put("token", JsonPrimitive(authToken))
|
put("token", JsonPrimitive(authToken))
|
||||||
}
|
}
|
||||||
authPassword.isNotEmpty() ->
|
password.isNotEmpty() ->
|
||||||
buildJsonObject {
|
buildJsonObject {
|
||||||
put("password", JsonPrimitive(authPassword))
|
put("password", JsonPrimitive(password))
|
||||||
}
|
}
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
val identity = identityStore.loadOrCreate()
|
|
||||||
val signedAtMs = System.currentTimeMillis()
|
val signedAtMs = System.currentTimeMillis()
|
||||||
val payload =
|
val payload =
|
||||||
buildDeviceAuthPayload(
|
buildDeviceAuthPayload(
|
||||||
@@ -349,6 +369,7 @@ class GatewaySession(
|
|||||||
scopes = options.scopes,
|
scopes = options.scopes,
|
||||||
signedAtMs = signedAtMs,
|
signedAtMs = signedAtMs,
|
||||||
token = if (authToken.isNotEmpty()) authToken else null,
|
token = if (authToken.isNotEmpty()) authToken else null,
|
||||||
|
nonce = connectNonce,
|
||||||
)
|
)
|
||||||
val signature = identityStore.signPayload(payload, identity)
|
val signature = identityStore.signPayload(payload, identity)
|
||||||
val publicKey = identityStore.publicKeyBase64Url(identity)
|
val publicKey = identityStore.publicKeyBase64Url(identity)
|
||||||
@@ -359,6 +380,9 @@ class GatewaySession(
|
|||||||
put("publicKey", JsonPrimitive(publicKey))
|
put("publicKey", JsonPrimitive(publicKey))
|
||||||
put("signature", JsonPrimitive(signature))
|
put("signature", JsonPrimitive(signature))
|
||||||
put("signedAt", JsonPrimitive(signedAtMs))
|
put("signedAt", JsonPrimitive(signedAtMs))
|
||||||
|
if (!connectNonce.isNullOrBlank()) {
|
||||||
|
put("nonce", JsonPrimitive(connectNonce))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
@@ -416,6 +440,13 @@ class GatewaySession(
|
|||||||
val event = frame["event"].asStringOrNull() ?: return
|
val event = frame["event"].asStringOrNull() ?: return
|
||||||
val payloadJson =
|
val payloadJson =
|
||||||
frame["payload"]?.let { it.toString() } ?: frame["payloadJSON"].asStringOrNull()
|
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) {
|
if (event == "node.invoke.request" && payloadJson != null && onInvoke != null) {
|
||||||
handleInvokeEvent(payloadJson)
|
handleInvokeEvent(payloadJson)
|
||||||
return
|
return
|
||||||
@@ -423,6 +454,21 @@ class GatewaySession(
|
|||||||
onEvent(event, payloadJson)
|
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) {
|
private fun handleInvokeEvent(payloadJson: String) {
|
||||||
val payload =
|
val payload =
|
||||||
try {
|
try {
|
||||||
@@ -544,19 +590,26 @@ class GatewaySession(
|
|||||||
scopes: List<String>,
|
scopes: List<String>,
|
||||||
signedAtMs: Long,
|
signedAtMs: Long,
|
||||||
token: String?,
|
token: String?,
|
||||||
|
nonce: String?,
|
||||||
): String {
|
): String {
|
||||||
val scopeString = scopes.joinToString(",")
|
val scopeString = scopes.joinToString(",")
|
||||||
val authToken = token.orEmpty()
|
val authToken = token.orEmpty()
|
||||||
return listOf(
|
val version = if (nonce.isNullOrBlank()) "v1" else "v2"
|
||||||
"v1",
|
val parts =
|
||||||
deviceId,
|
mutableListOf(
|
||||||
clientId,
|
version,
|
||||||
clientMode,
|
deviceId,
|
||||||
role,
|
clientId,
|
||||||
scopeString,
|
clientMode,
|
||||||
signedAtMs.toString(),
|
role,
|
||||||
authToken,
|
scopeString,
|
||||||
).joinToString("|")
|
signedAtMs.toString(),
|
||||||
|
authToken,
|
||||||
|
)
|
||||||
|
if (!nonce.isNullOrBlank()) {
|
||||||
|
parts.add(nonce)
|
||||||
|
}
|
||||||
|
return parts.joinToString("|")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun normalizeCanvasHostUrl(raw: String?, endpoint: GatewayEndpoint): String? {
|
private fun normalizeCanvasHostUrl(raw: String?, endpoint: GatewayEndpoint): String? {
|
||||||
|
|||||||
@@ -84,5 +84,7 @@ private fun sha256Hex(data: ByteArray): String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun normalizeFingerprint(raw: String): String {
|
private fun normalizeFingerprint(raw: String): String {
|
||||||
return raw.lowercase().filter { it in '0'..'9' || it in 'a'..'f' }
|
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' }
|
||||||
}
|
}
|
||||||
@@ -160,14 +160,14 @@ actor CameraController {
|
|||||||
defer { session.stopRunning() }
|
defer { session.stopRunning() }
|
||||||
await Self.warmUpCaptureSession()
|
await Self.warmUpCaptureSession()
|
||||||
|
|
||||||
let movURL = FileManager.default.temporaryDirectory
|
let movURL = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mov")
|
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mov")
|
||||||
let mp4URL = FileManager.default.temporaryDirectory
|
let mp4URL = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mp4")
|
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mp4")
|
||||||
|
|
||||||
defer {
|
defer {
|
||||||
try? FileManager.default.removeItem(at: movURL)
|
try? FileManager().removeItem(at: movURL)
|
||||||
try? FileManager.default.removeItem(at: mp4URL)
|
try? FileManager().removeItem(at: mp4URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
var delegate: MovieFileDelegate?
|
var delegate: MovieFileDelegate?
|
||||||
|
|||||||
@@ -19,9 +19,9 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2026.1.11-4</string>
|
<string>2026.1.20</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>202601113</string>
|
<string>20260120</string>
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
</dict>
|
</dict>
|
||||||
<key>NSBonjourServices</key>
|
<key>NSBonjourServices</key>
|
||||||
<array>
|
<array>
|
||||||
<string>_clawdbot-gateway._tcp</string>
|
<string>_clawdbot-gw._tcp</string>
|
||||||
</array>
|
</array>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>Clawdbot can capture photos or short video clips when requested via the gateway.</string>
|
<string>Clawdbot can capture photos or short video clips when requested via the gateway.</string>
|
||||||
|
|||||||
@@ -837,7 +837,7 @@ final class NodeAppModel {
|
|||||||
fps: params.fps,
|
fps: params.fps,
|
||||||
includeAudio: params.includeAudio,
|
includeAudio: params.includeAudio,
|
||||||
outPath: nil)
|
outPath: nil)
|
||||||
defer { try? FileManager.default.removeItem(atPath: path) }
|
defer { try? FileManager().removeItem(atPath: path) }
|
||||||
let data = try Data(contentsOf: URL(fileURLWithPath: path))
|
let data = try Data(contentsOf: URL(fileURLWithPath: path))
|
||||||
struct Payload: Codable {
|
struct Payload: Codable {
|
||||||
var format: String
|
var format: String
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ final class ScreenRecordService: @unchecked Sendable {
|
|||||||
let includeAudio = includeAudio ?? true
|
let includeAudio = includeAudio ?? true
|
||||||
|
|
||||||
let outURL = self.makeOutputURL(outPath: outPath)
|
let outURL = self.makeOutputURL(outPath: outPath)
|
||||||
try? FileManager.default.removeItem(at: outURL)
|
try? FileManager().removeItem(at: outURL)
|
||||||
|
|
||||||
return RecordConfig(
|
return RecordConfig(
|
||||||
durationMs: durationMs,
|
durationMs: durationMs,
|
||||||
@@ -104,7 +104,7 @@ final class ScreenRecordService: @unchecked Sendable {
|
|||||||
if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
return URL(fileURLWithPath: outPath)
|
return URL(fileURLWithPath: outPath)
|
||||||
}
|
}
|
||||||
return FileManager.default.temporaryDirectory
|
return FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-screen-record-\(UUID().uuidString).mp4")
|
.appendingPathComponent("clawdbot-screen-record-\(UUID().uuidString).mp4")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
Sources/Bridge/BridgeClient.swift
|
Sources/Gateway/GatewayConnectionController.swift
|
||||||
Sources/Bridge/BridgeConnectionController.swift
|
Sources/Gateway/GatewayDiscoveryDebugLogView.swift
|
||||||
Sources/Bridge/BridgeDiscoveryDebugLogView.swift
|
Sources/Gateway/GatewayDiscoveryModel.swift
|
||||||
Sources/Bridge/BridgeDiscoveryModel.swift
|
Sources/Gateway/GatewaySettingsStore.swift
|
||||||
Sources/Bridge/BridgeEndpointID.swift
|
Sources/Gateway/KeychainStore.swift
|
||||||
Sources/Bridge/BridgeSession.swift
|
|
||||||
Sources/Bridge/BridgeSettingsStore.swift
|
|
||||||
Sources/Bridge/KeychainStore.swift
|
|
||||||
Sources/Camera/CameraController.swift
|
Sources/Camera/CameraController.swift
|
||||||
Sources/Chat/ChatSheet.swift
|
Sources/Chat/ChatSheet.swift
|
||||||
Sources/Chat/IOSBridgeChatTransport.swift
|
Sources/Chat/IOSGatewayChatTransport.swift
|
||||||
Sources/ClawdbotApp.swift
|
Sources/ClawdbotApp.swift
|
||||||
|
Sources/Location/LocationService.swift
|
||||||
Sources/Model/NodeAppModel.swift
|
Sources/Model/NodeAppModel.swift
|
||||||
Sources/RootCanvas.swift
|
Sources/RootCanvas.swift
|
||||||
Sources/RootTabs.swift
|
Sources/RootTabs.swift
|
||||||
@@ -17,6 +15,7 @@ Sources/Screen/ScreenController.swift
|
|||||||
Sources/Screen/ScreenRecordService.swift
|
Sources/Screen/ScreenRecordService.swift
|
||||||
Sources/Screen/ScreenTab.swift
|
Sources/Screen/ScreenTab.swift
|
||||||
Sources/Screen/ScreenWebView.swift
|
Sources/Screen/ScreenWebView.swift
|
||||||
|
Sources/SessionKey.swift
|
||||||
Sources/Settings/SettingsNetworkingHelpers.swift
|
Sources/Settings/SettingsNetworkingHelpers.swift
|
||||||
Sources/Settings/SettingsTab.swift
|
Sources/Settings/SettingsTab.swift
|
||||||
Sources/Settings/VoiceWakeWordsSettingsView.swift
|
Sources/Settings/VoiceWakeWordsSettingsView.swift
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import Testing
|
|||||||
@Test func stableIDForServiceDecodesAndNormalizesName() {
|
@Test func stableIDForServiceDecodesAndNormalizesName() {
|
||||||
let endpoint = NWEndpoint.service(
|
let endpoint = NWEndpoint.service(
|
||||||
name: "Clawdbot\\032Gateway \\032 Node\n",
|
name: "Clawdbot\\032Gateway \\032 Node\n",
|
||||||
type: "_clawdbot-gateway._tcp",
|
type: "_clawdbot-gw._tcp",
|
||||||
domain: "local.",
|
domain: "local.",
|
||||||
interface: nil)
|
interface: nil)
|
||||||
|
|
||||||
#expect(GatewayEndpointID.stableID(endpoint) == "_clawdbot-gateway._tcp|local.|Clawdbot Gateway Node")
|
#expect(GatewayEndpointID.stableID(endpoint) == "_clawdbot-gw._tcp|local.|Clawdbot Gateway Node")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func stableIDForNonServiceUsesEndpointDescription() {
|
@Test func stableIDForNonServiceUsesEndpointDescription() {
|
||||||
@@ -22,7 +22,7 @@ import Testing
|
|||||||
@Test func prettyDescriptionDecodesBonjourEscapes() {
|
@Test func prettyDescriptionDecodesBonjourEscapes() {
|
||||||
let endpoint = NWEndpoint.service(
|
let endpoint = NWEndpoint.service(
|
||||||
name: "Clawdbot\\032Gateway",
|
name: "Clawdbot\\032Gateway",
|
||||||
type: "_clawdbot-gateway._tcp",
|
type: "_clawdbot-gw._tcp",
|
||||||
domain: "local.",
|
domain: "local.",
|
||||||
interface: nil)
|
interface: nil)
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,8 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>BNDL</string>
|
<string>BNDL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2026.1.11-4</string>
|
<string>2026.1.20</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>202601113</string>
|
<string>20260120</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -81,8 +81,8 @@ targets:
|
|||||||
properties:
|
properties:
|
||||||
CFBundleDisplayName: Clawdbot
|
CFBundleDisplayName: Clawdbot
|
||||||
CFBundleIconName: AppIcon
|
CFBundleIconName: AppIcon
|
||||||
CFBundleShortVersionString: "2026.1.9"
|
CFBundleShortVersionString: "2026.1.20"
|
||||||
CFBundleVersion: "20260109"
|
CFBundleVersion: "20260120"
|
||||||
UILaunchScreen: {}
|
UILaunchScreen: {}
|
||||||
UIApplicationSceneManifest:
|
UIApplicationSceneManifest:
|
||||||
UIApplicationSupportsMultipleScenes: false
|
UIApplicationSupportsMultipleScenes: false
|
||||||
@@ -92,7 +92,7 @@ targets:
|
|||||||
NSAppTransportSecurity:
|
NSAppTransportSecurity:
|
||||||
NSAllowsArbitraryLoadsInWebContent: true
|
NSAllowsArbitraryLoadsInWebContent: true
|
||||||
NSBonjourServices:
|
NSBonjourServices:
|
||||||
- _clawdbot-gateway._tcp
|
- _clawdbot-gw._tcp
|
||||||
NSCameraUsageDescription: Clawdbot can capture photos or short video clips when requested via the gateway.
|
NSCameraUsageDescription: Clawdbot can capture photos or short video clips when requested via the gateway.
|
||||||
NSLocationWhenInUseUsageDescription: Clawdbot uses your location when you allow location sharing.
|
NSLocationWhenInUseUsageDescription: Clawdbot uses your location when you allow location sharing.
|
||||||
NSLocationAlwaysAndWhenInUseUsageDescription: Clawdbot can share your location in the background when you enable Always.
|
NSLocationAlwaysAndWhenInUseUsageDescription: Clawdbot can share your location in the background when you enable Always.
|
||||||
@@ -130,5 +130,5 @@ targets:
|
|||||||
path: Tests/Info.plist
|
path: Tests/Info.plist
|
||||||
properties:
|
properties:
|
||||||
CFBundleDisplayName: ClawdbotTests
|
CFBundleDisplayName: ClawdbotTests
|
||||||
CFBundleShortVersionString: "2026.1.9"
|
CFBundleShortVersionString: "2026.1.20"
|
||||||
CFBundleVersion: "20260109"
|
CFBundleVersion: "20260120"
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "4ed05a95fa9feada29b97f81b3194392e59a0c7b9edf24851f922bc2b72b0438",
|
"originHash" : "550d4ea41d4bb2546b99a7bfa1c5cba7e28a13862bc226727ea7426c61555a33",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "axorcist",
|
"identity" : "axorcist",
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ let package = Package(
|
|||||||
.library(name: "ClawdbotIPC", targets: ["ClawdbotIPC"]),
|
.library(name: "ClawdbotIPC", targets: ["ClawdbotIPC"]),
|
||||||
.library(name: "ClawdbotDiscovery", targets: ["ClawdbotDiscovery"]),
|
.library(name: "ClawdbotDiscovery", targets: ["ClawdbotDiscovery"]),
|
||||||
.executable(name: "Clawdbot", targets: ["Clawdbot"]),
|
.executable(name: "Clawdbot", targets: ["Clawdbot"]),
|
||||||
.executable(name: "clawdbot-mac-discovery", targets: ["ClawdbotDiscoveryCLI"]),
|
.executable(name: "clawdbot-mac", targets: ["ClawdbotMacCLI"]),
|
||||||
.executable(name: "clawdbot-mac-wizard", targets: ["ClawdbotWizardCLI"]),
|
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"),
|
.package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"),
|
||||||
@@ -67,20 +66,13 @@ let package = Package(
|
|||||||
.enableUpcomingFeature("StrictConcurrency"),
|
.enableUpcomingFeature("StrictConcurrency"),
|
||||||
]),
|
]),
|
||||||
.executableTarget(
|
.executableTarget(
|
||||||
name: "ClawdbotDiscoveryCLI",
|
name: "ClawdbotMacCLI",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
"ClawdbotDiscovery",
|
"ClawdbotDiscovery",
|
||||||
],
|
.product(name: "ClawdbotKit", package: "ClawdbotKit"),
|
||||||
path: "Sources/ClawdbotDiscoveryCLI",
|
|
||||||
swiftSettings: [
|
|
||||||
.enableUpcomingFeature("StrictConcurrency"),
|
|
||||||
]),
|
|
||||||
.executableTarget(
|
|
||||||
name: "ClawdbotWizardCLI",
|
|
||||||
dependencies: [
|
|
||||||
.product(name: "ClawdbotProtocol", package: "ClawdbotKit"),
|
.product(name: "ClawdbotProtocol", package: "ClawdbotKit"),
|
||||||
],
|
],
|
||||||
path: "Sources/ClawdbotWizardCLI",
|
path: "Sources/ClawdbotMacCLI",
|
||||||
swiftSettings: [
|
swiftSettings: [
|
||||||
.enableUpcomingFeature("StrictConcurrency"),
|
.enableUpcomingFeature("StrictConcurrency"),
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ private struct EventRow: View {
|
|||||||
return f.string(from: date)
|
return f.string(from: date)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func prettyJSON(_ dict: [String: AnyCodable]) -> String? {
|
private func prettyJSON(_ dict: [String: ClawdbotProtocol.AnyCodable]) -> String? {
|
||||||
let normalized = dict.mapValues { $0.value }
|
let normalized = dict.mapValues { $0.value }
|
||||||
guard JSONSerialization.isValidJSONObject(normalized),
|
guard JSONSerialization.isValidJSONObject(normalized),
|
||||||
let data = try? JSONSerialization.data(withJSONObject: normalized, options: [.prettyPrinted]),
|
let data = try? JSONSerialization.data(withJSONObject: normalized, options: [.prettyPrinted]),
|
||||||
@@ -98,7 +98,10 @@ struct AgentEventsWindow_Previews: PreviewProvider {
|
|||||||
seq: 1,
|
seq: 1,
|
||||||
stream: "tool",
|
stream: "tool",
|
||||||
ts: Date().timeIntervalSince1970 * 1000,
|
ts: Date().timeIntervalSince1970 * 1000,
|
||||||
data: ["phase": AnyCodable("start"), "name": AnyCodable("bash")],
|
data: [
|
||||||
|
"phase": ClawdbotProtocol.AnyCodable("start"),
|
||||||
|
"name": ClawdbotProtocol.AnyCodable("bash"),
|
||||||
|
],
|
||||||
summary: nil)
|
summary: nil)
|
||||||
AgentEventStore.shared.append(sample)
|
AgentEventStore.shared.append(sample)
|
||||||
return AgentEventsWindow()
|
return AgentEventsWindow()
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ enum AgentWorkspace {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func displayPath(for url: URL) -> String {
|
static func displayPath(for url: URL) -> String {
|
||||||
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
let home = FileManager().homeDirectoryForCurrentUser.path
|
||||||
let path = url.path
|
let path = url.path
|
||||||
if path == home { return "~" }
|
if path == home { return "~" }
|
||||||
if path.hasPrefix(home + "/") {
|
if path.hasPrefix(home + "/") {
|
||||||
@@ -44,12 +44,12 @@ enum AgentWorkspace {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func workspaceEntries(workspaceURL: URL) throws -> [String] {
|
static func workspaceEntries(workspaceURL: URL) throws -> [String] {
|
||||||
let contents = try FileManager.default.contentsOfDirectory(atPath: workspaceURL.path)
|
let contents = try FileManager().contentsOfDirectory(atPath: workspaceURL.path)
|
||||||
return contents.filter { !self.ignoredEntries.contains($0) }
|
return contents.filter { !self.ignoredEntries.contains($0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
static func isWorkspaceEmpty(workspaceURL: URL) -> Bool {
|
static func isWorkspaceEmpty(workspaceURL: URL) -> Bool {
|
||||||
let fm = FileManager.default
|
let fm = FileManager()
|
||||||
var isDir: ObjCBool = false
|
var isDir: ObjCBool = false
|
||||||
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
|
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
|
||||||
return true
|
return true
|
||||||
@@ -66,7 +66,7 @@ enum AgentWorkspace {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func bootstrapSafety(for workspaceURL: URL) -> BootstrapSafety {
|
static func bootstrapSafety(for workspaceURL: URL) -> BootstrapSafety {
|
||||||
let fm = FileManager.default
|
let fm = FileManager()
|
||||||
var isDir: ObjCBool = false
|
var isDir: ObjCBool = false
|
||||||
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
|
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
|
||||||
return .safe
|
return .safe
|
||||||
@@ -90,29 +90,29 @@ enum AgentWorkspace {
|
|||||||
|
|
||||||
static func bootstrap(workspaceURL: URL) throws -> URL {
|
static func bootstrap(workspaceURL: URL) throws -> URL {
|
||||||
let shouldSeedBootstrap = self.isWorkspaceEmpty(workspaceURL: workspaceURL)
|
let shouldSeedBootstrap = self.isWorkspaceEmpty(workspaceURL: workspaceURL)
|
||||||
try FileManager.default.createDirectory(at: workspaceURL, withIntermediateDirectories: true)
|
try FileManager().createDirectory(at: workspaceURL, withIntermediateDirectories: true)
|
||||||
let agentsURL = self.agentsURL(workspaceURL: workspaceURL)
|
let agentsURL = self.agentsURL(workspaceURL: workspaceURL)
|
||||||
if !FileManager.default.fileExists(atPath: agentsURL.path) {
|
if !FileManager().fileExists(atPath: agentsURL.path) {
|
||||||
try self.defaultTemplate().write(to: agentsURL, atomically: true, encoding: .utf8)
|
try self.defaultTemplate().write(to: agentsURL, atomically: true, encoding: .utf8)
|
||||||
self.logger.info("Created AGENTS.md at \(agentsURL.path, privacy: .public)")
|
self.logger.info("Created AGENTS.md at \(agentsURL.path, privacy: .public)")
|
||||||
}
|
}
|
||||||
let soulURL = workspaceURL.appendingPathComponent(self.soulFilename)
|
let soulURL = workspaceURL.appendingPathComponent(self.soulFilename)
|
||||||
if !FileManager.default.fileExists(atPath: soulURL.path) {
|
if !FileManager().fileExists(atPath: soulURL.path) {
|
||||||
try self.defaultSoulTemplate().write(to: soulURL, atomically: true, encoding: .utf8)
|
try self.defaultSoulTemplate().write(to: soulURL, atomically: true, encoding: .utf8)
|
||||||
self.logger.info("Created SOUL.md at \(soulURL.path, privacy: .public)")
|
self.logger.info("Created SOUL.md at \(soulURL.path, privacy: .public)")
|
||||||
}
|
}
|
||||||
let identityURL = workspaceURL.appendingPathComponent(self.identityFilename)
|
let identityURL = workspaceURL.appendingPathComponent(self.identityFilename)
|
||||||
if !FileManager.default.fileExists(atPath: identityURL.path) {
|
if !FileManager().fileExists(atPath: identityURL.path) {
|
||||||
try self.defaultIdentityTemplate().write(to: identityURL, atomically: true, encoding: .utf8)
|
try self.defaultIdentityTemplate().write(to: identityURL, atomically: true, encoding: .utf8)
|
||||||
self.logger.info("Created IDENTITY.md at \(identityURL.path, privacy: .public)")
|
self.logger.info("Created IDENTITY.md at \(identityURL.path, privacy: .public)")
|
||||||
}
|
}
|
||||||
let userURL = workspaceURL.appendingPathComponent(self.userFilename)
|
let userURL = workspaceURL.appendingPathComponent(self.userFilename)
|
||||||
if !FileManager.default.fileExists(atPath: userURL.path) {
|
if !FileManager().fileExists(atPath: userURL.path) {
|
||||||
try self.defaultUserTemplate().write(to: userURL, atomically: true, encoding: .utf8)
|
try self.defaultUserTemplate().write(to: userURL, atomically: true, encoding: .utf8)
|
||||||
self.logger.info("Created USER.md at \(userURL.path, privacy: .public)")
|
self.logger.info("Created USER.md at \(userURL.path, privacy: .public)")
|
||||||
}
|
}
|
||||||
let bootstrapURL = workspaceURL.appendingPathComponent(self.bootstrapFilename)
|
let bootstrapURL = workspaceURL.appendingPathComponent(self.bootstrapFilename)
|
||||||
if shouldSeedBootstrap, !FileManager.default.fileExists(atPath: bootstrapURL.path) {
|
if shouldSeedBootstrap, !FileManager().fileExists(atPath: bootstrapURL.path) {
|
||||||
try self.defaultBootstrapTemplate().write(to: bootstrapURL, atomically: true, encoding: .utf8)
|
try self.defaultBootstrapTemplate().write(to: bootstrapURL, atomically: true, encoding: .utf8)
|
||||||
self.logger.info("Created BOOTSTRAP.md at \(bootstrapURL.path, privacy: .public)")
|
self.logger.info("Created BOOTSTRAP.md at \(bootstrapURL.path, privacy: .public)")
|
||||||
}
|
}
|
||||||
@@ -120,7 +120,7 @@ enum AgentWorkspace {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func needsBootstrap(workspaceURL: URL) -> Bool {
|
static func needsBootstrap(workspaceURL: URL) -> Bool {
|
||||||
let fm = FileManager.default
|
let fm = FileManager()
|
||||||
var isDir: ObjCBool = false
|
var isDir: ObjCBool = false
|
||||||
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
|
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
|
||||||
return true
|
return true
|
||||||
@@ -305,7 +305,7 @@ enum AgentWorkspace {
|
|||||||
if let dev = self.devTemplateURL(named: named) {
|
if let dev = self.devTemplateURL(named: named) {
|
||||||
urls.append(dev)
|
urls.append(dev)
|
||||||
}
|
}
|
||||||
let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
|
let cwd = URL(fileURLWithPath: FileManager().currentDirectoryPath)
|
||||||
urls.append(cwd.appendingPathComponent("docs")
|
urls.append(cwd.appendingPathComponent("docs")
|
||||||
.appendingPathComponent(self.templateDirname)
|
.appendingPathComponent(self.templateDirname)
|
||||||
.appendingPathComponent(named))
|
.appendingPathComponent(named))
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ struct AnthropicAuthControls: View {
|
|||||||
NSWorkspace.shared.activateFileViewerSelecting([ClawdbotOAuthStore.oauthURL()])
|
NSWorkspace.shared.activateFileViewerSelecting([ClawdbotOAuthStore.oauthURL()])
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.disabled(!FileManager.default.fileExists(atPath: ClawdbotOAuthStore.oauthURL().path))
|
.disabled(!FileManager().fileExists(atPath: ClawdbotOAuthStore.oauthURL().path))
|
||||||
|
|
||||||
Button("Refresh") {
|
Button("Refresh") {
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ enum ClawdbotOAuthStore {
|
|||||||
return URL(fileURLWithPath: expanded, isDirectory: true)
|
return URL(fileURLWithPath: expanded, isDirectory: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
return FileManager.default.homeDirectoryForCurrentUser
|
return FileManager().homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent(".clawdbot", isDirectory: true)
|
.appendingPathComponent(".clawdbot", isDirectory: true)
|
||||||
.appendingPathComponent("credentials", isDirectory: true)
|
.appendingPathComponent("credentials", isDirectory: true)
|
||||||
}
|
}
|
||||||
@@ -253,7 +253,7 @@ enum ClawdbotOAuthStore {
|
|||||||
urls.append(URL(fileURLWithPath: expanded, isDirectory: true).appendingPathComponent(self.oauthFilename))
|
urls.append(URL(fileURLWithPath: expanded, isDirectory: true).appendingPathComponent(self.oauthFilename))
|
||||||
}
|
}
|
||||||
|
|
||||||
let home = FileManager.default.homeDirectoryForCurrentUser
|
let home = FileManager().homeDirectoryForCurrentUser
|
||||||
urls.append(home.appendingPathComponent(".pi/agent/\(self.oauthFilename)"))
|
urls.append(home.appendingPathComponent(".pi/agent/\(self.oauthFilename)"))
|
||||||
urls.append(home.appendingPathComponent(".claude/\(self.oauthFilename)"))
|
urls.append(home.appendingPathComponent(".claude/\(self.oauthFilename)"))
|
||||||
urls.append(home.appendingPathComponent(".config/claude/\(self.oauthFilename)"))
|
urls.append(home.appendingPathComponent(".config/claude/\(self.oauthFilename)"))
|
||||||
@@ -270,10 +270,10 @@ enum ClawdbotOAuthStore {
|
|||||||
|
|
||||||
static func importLegacyAnthropicOAuthIfNeeded() -> URL? {
|
static func importLegacyAnthropicOAuthIfNeeded() -> URL? {
|
||||||
let dest = self.oauthURL()
|
let dest = self.oauthURL()
|
||||||
guard !FileManager.default.fileExists(atPath: dest.path) else { return nil }
|
guard !FileManager().fileExists(atPath: dest.path) else { return nil }
|
||||||
|
|
||||||
for url in self.legacyOAuthURLs() {
|
for url in self.legacyOAuthURLs() {
|
||||||
guard FileManager.default.fileExists(atPath: url.path) else { continue }
|
guard FileManager().fileExists(atPath: url.path) else { continue }
|
||||||
guard self.anthropicOAuthStatus(at: url).isConnected else { continue }
|
guard self.anthropicOAuthStatus(at: url).isConnected else { continue }
|
||||||
guard let storage = self.loadStorage(at: url) else { continue }
|
guard let storage = self.loadStorage(at: url) else { continue }
|
||||||
do {
|
do {
|
||||||
@@ -296,7 +296,7 @@ enum ClawdbotOAuthStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func anthropicOAuthStatus(at url: URL) -> AnthropicOAuthStatus {
|
static func anthropicOAuthStatus(at url: URL) -> AnthropicOAuthStatus {
|
||||||
guard FileManager.default.fileExists(atPath: url.path) else { return .missingFile }
|
guard FileManager().fileExists(atPath: url.path) else { return .missingFile }
|
||||||
|
|
||||||
guard let data = try? Data(contentsOf: url) else { return .unreadableFile }
|
guard let data = try? Data(contentsOf: url) else { return .unreadableFile }
|
||||||
guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return .invalidJSON }
|
guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return .invalidJSON }
|
||||||
@@ -360,7 +360,7 @@ enum ClawdbotOAuthStore {
|
|||||||
|
|
||||||
private static func saveStorage(_ storage: [String: Any]) throws {
|
private static func saveStorage(_ storage: [String: Any]) throws {
|
||||||
let dir = self.oauthDir()
|
let dir = self.oauthDir()
|
||||||
try FileManager.default.createDirectory(
|
try FileManager().createDirectory(
|
||||||
at: dir,
|
at: dir,
|
||||||
withIntermediateDirectories: true,
|
withIntermediateDirectories: true,
|
||||||
attributes: [.posixPermissions: 0o700])
|
attributes: [.posixPermissions: 0o700])
|
||||||
@@ -370,7 +370,7 @@ enum ClawdbotOAuthStore {
|
|||||||
withJSONObject: storage,
|
withJSONObject: storage,
|
||||||
options: [.prettyPrinted, .sortedKeys])
|
options: [.prettyPrinted, .sortedKeys])
|
||||||
try data.write(to: url, options: [.atomic])
|
try data.write(to: url, options: [.atomic])
|
||||||
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
try FileManager().setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
|
import ClawdbotKit
|
||||||
import ClawdbotProtocol
|
import ClawdbotProtocol
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
// Prefer the ClawdbotKit wrapper to keep gateway request payloads consistent.
|
||||||
|
typealias AnyCodable = ClawdbotKit.AnyCodable
|
||||||
|
typealias InstanceIdentity = ClawdbotKit.InstanceIdentity
|
||||||
|
|
||||||
extension AnyCodable {
|
extension AnyCodable {
|
||||||
var stringValue: String? { self.value as? String }
|
var stringValue: String? { self.value as? String }
|
||||||
var boolValue: Bool? { self.value as? Bool }
|
var boolValue: Bool? { self.value as? Bool }
|
||||||
@@ -20,3 +25,23 @@ extension AnyCodable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension ClawdbotProtocol.AnyCodable {
|
||||||
|
var stringValue: String? { self.value as? String }
|
||||||
|
var boolValue: Bool? { self.value as? Bool }
|
||||||
|
var intValue: Int? { self.value as? Int }
|
||||||
|
var doubleValue: Double? { self.value as? Double }
|
||||||
|
var dictionaryValue: [String: ClawdbotProtocol.AnyCodable]? { self.value as? [String: ClawdbotProtocol.AnyCodable] }
|
||||||
|
var arrayValue: [ClawdbotProtocol.AnyCodable]? { self.value as? [ClawdbotProtocol.AnyCodable] }
|
||||||
|
|
||||||
|
var foundationValue: Any {
|
||||||
|
switch self.value {
|
||||||
|
case let dict as [String: ClawdbotProtocol.AnyCodable]:
|
||||||
|
dict.mapValues { $0.foundationValue }
|
||||||
|
case let array as [ClawdbotProtocol.AnyCodable]:
|
||||||
|
array.map(\.foundationValue)
|
||||||
|
default:
|
||||||
|
self.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,7 +61,7 @@ enum CLIInstaller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func installPrefix() -> String {
|
private static func installPrefix() -> String {
|
||||||
FileManager.default.homeDirectoryForCurrentUser
|
FileManager().homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent(".clawdbot")
|
.appendingPathComponent(".clawdbot")
|
||||||
.path
|
.path
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,20 +167,20 @@ actor CameraCaptureService {
|
|||||||
defer { session.stopRunning() }
|
defer { session.stopRunning() }
|
||||||
await Self.warmUpCaptureSession()
|
await Self.warmUpCaptureSession()
|
||||||
|
|
||||||
let tmpMovURL = FileManager.default.temporaryDirectory
|
let tmpMovURL = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mov")
|
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mov")
|
||||||
defer { try? FileManager.default.removeItem(at: tmpMovURL) }
|
defer { try? FileManager().removeItem(at: tmpMovURL) }
|
||||||
|
|
||||||
let outputURL: URL = {
|
let outputURL: URL = {
|
||||||
if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
return URL(fileURLWithPath: outPath)
|
return URL(fileURLWithPath: outPath)
|
||||||
}
|
}
|
||||||
return FileManager.default.temporaryDirectory
|
return FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mp4")
|
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mp4")
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Ensure we don't fail exporting due to an existing file.
|
// Ensure we don't fail exporting due to an existing file.
|
||||||
try? FileManager.default.removeItem(at: outputURL)
|
try? FileManager().removeItem(at: outputURL)
|
||||||
|
|
||||||
let logger = self.logger
|
let logger = self.logger
|
||||||
var delegate: MovieFileDelegate?
|
var delegate: MovieFileDelegate?
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ final class CanvasManager {
|
|||||||
var defaultAnchorProvider: (() -> NSRect?)?
|
var defaultAnchorProvider: (() -> NSRect?)?
|
||||||
|
|
||||||
private nonisolated static let canvasRoot: URL = {
|
private nonisolated static let canvasRoot: URL = {
|
||||||
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||||
return base.appendingPathComponent("Clawdbot/canvas", isDirectory: true)
|
return base.appendingPathComponent("Clawdbot/canvas", isDirectory: true)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ final class CanvasManager {
|
|||||||
self.panelSessionKey = nil
|
self.panelSessionKey = nil
|
||||||
|
|
||||||
Self.logger.debug("showDetailed ensure canvas root dir")
|
Self.logger.debug("showDetailed ensure canvas root dir")
|
||||||
try FileManager.default.createDirectory(at: Self.canvasRoot, withIntermediateDirectories: true)
|
try FileManager().createDirectory(at: Self.canvasRoot, withIntermediateDirectories: true)
|
||||||
Self.logger.debug("showDetailed init CanvasWindowController")
|
Self.logger.debug("showDetailed init CanvasWindowController")
|
||||||
let controller = try CanvasWindowController(
|
let controller = try CanvasWindowController(
|
||||||
sessionKey: session,
|
sessionKey: session,
|
||||||
@@ -258,7 +258,7 @@ final class CanvasManager {
|
|||||||
// (Avoid treating Canvas routes like "/" as filesystem paths.)
|
// (Avoid treating Canvas routes like "/" as filesystem paths.)
|
||||||
if trimmed.hasPrefix("/") {
|
if trimmed.hasPrefix("/") {
|
||||||
var isDir: ObjCBool = false
|
var isDir: ObjCBool = false
|
||||||
if FileManager.default.fileExists(atPath: trimmed, isDirectory: &isDir), !isDir.boolValue {
|
if FileManager().fileExists(atPath: trimmed, isDirectory: &isDir), !isDir.boolValue {
|
||||||
return URL(fileURLWithPath: trimmed)
|
return URL(fileURLWithPath: trimmed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -293,7 +293,7 @@ final class CanvasManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func localStatus(sessionDir: URL, target: String) -> CanvasShowStatus {
|
private static func localStatus(sessionDir: URL, target: String) -> CanvasShowStatus {
|
||||||
let fm = FileManager.default
|
let fm = FileManager()
|
||||||
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let withoutQuery = trimmed.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false).first
|
let withoutQuery = trimmed.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false).first
|
||||||
.map(String.init) ?? trimmed
|
.map(String.init) ?? trimmed
|
||||||
@@ -331,7 +331,7 @@ final class CanvasManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func indexExists(in dir: URL) -> Bool {
|
private static func indexExists(in dir: URL) -> Bool {
|
||||||
let fm = FileManager.default
|
let fm = FileManager()
|
||||||
let a = dir.appendingPathComponent("index.html", isDirectory: false)
|
let a = dir.appendingPathComponent("index.html", isDirectory: false)
|
||||||
if fm.fileExists(atPath: a.path) { return true }
|
if fm.fileExists(atPath: a.path) { return true }
|
||||||
let b = dir.appendingPathComponent("index.htm", isDirectory: false)
|
let b = dir.appendingPathComponent("index.htm", isDirectory: false)
|
||||||
|
|||||||
@@ -69,8 +69,8 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
|
|||||||
if path.isEmpty {
|
if path.isEmpty {
|
||||||
let indexA = sessionRoot.appendingPathComponent("index.html", isDirectory: false)
|
let indexA = sessionRoot.appendingPathComponent("index.html", isDirectory: false)
|
||||||
let indexB = sessionRoot.appendingPathComponent("index.htm", isDirectory: false)
|
let indexB = sessionRoot.appendingPathComponent("index.htm", isDirectory: false)
|
||||||
if !FileManager.default.fileExists(atPath: indexA.path),
|
if !FileManager().fileExists(atPath: indexA.path),
|
||||||
!FileManager.default.fileExists(atPath: indexB.path)
|
!FileManager().fileExists(atPath: indexB.path)
|
||||||
{
|
{
|
||||||
return self.scaffoldPage(sessionRoot: sessionRoot)
|
return self.scaffoldPage(sessionRoot: sessionRoot)
|
||||||
}
|
}
|
||||||
@@ -106,7 +106,7 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func resolveFileURL(sessionRoot: URL, requestPath: String) -> URL? {
|
private func resolveFileURL(sessionRoot: URL, requestPath: String) -> URL? {
|
||||||
let fm = FileManager.default
|
let fm = FileManager()
|
||||||
var candidate = sessionRoot.appendingPathComponent(requestPath, isDirectory: false)
|
var candidate = sessionRoot.appendingPathComponent(requestPath, isDirectory: false)
|
||||||
|
|
||||||
var isDir: ObjCBool = false
|
var isDir: ObjCBool = false
|
||||||
@@ -137,7 +137,7 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func resolveIndex(in dir: URL) -> URL? {
|
private func resolveIndex(in dir: URL) -> URL? {
|
||||||
let fm = FileManager.default
|
let fm = FileManager()
|
||||||
let a = dir.appendingPathComponent("index.html", isDirectory: false)
|
let a = dir.appendingPathComponent("index.html", isDirectory: false)
|
||||||
if fm.fileExists(atPath: a.path) { return a }
|
if fm.fileExists(atPath: a.path) { return a }
|
||||||
let b = dir.appendingPathComponent("index.htm", isDirectory: false)
|
let b = dir.appendingPathComponent("index.htm", isDirectory: false)
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
|||||||
let safeSessionKey = CanvasWindowController.sanitizeSessionKey(sessionKey)
|
let safeSessionKey = CanvasWindowController.sanitizeSessionKey(sessionKey)
|
||||||
canvasWindowLogger.debug("CanvasWindowController init sanitized session=\(safeSessionKey, privacy: .public)")
|
canvasWindowLogger.debug("CanvasWindowController init sanitized session=\(safeSessionKey, privacy: .public)")
|
||||||
self.sessionDir = root.appendingPathComponent(safeSessionKey, isDirectory: true)
|
self.sessionDir = root.appendingPathComponent(safeSessionKey, isDirectory: true)
|
||||||
try FileManager.default.createDirectory(at: self.sessionDir, withIntermediateDirectories: true)
|
try FileManager().createDirectory(at: self.sessionDir, withIntermediateDirectories: true)
|
||||||
canvasWindowLogger.debug("CanvasWindowController init session dir ready")
|
canvasWindowLogger.debug("CanvasWindowController init session dir ready")
|
||||||
|
|
||||||
self.schemeHandler = CanvasSchemeHandler(root: root)
|
self.schemeHandler = CanvasSchemeHandler(root: root)
|
||||||
@@ -143,8 +143,8 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
|||||||
if path == "/" || path.isEmpty {
|
if path == "/" || path.isEmpty {
|
||||||
let indexA = sessionDir.appendingPathComponent("index.html", isDirectory: false)
|
let indexA = sessionDir.appendingPathComponent("index.html", isDirectory: false)
|
||||||
let indexB = sessionDir.appendingPathComponent("index.htm", isDirectory: false)
|
let indexB = sessionDir.appendingPathComponent("index.htm", isDirectory: false)
|
||||||
if !FileManager.default.fileExists(atPath: indexA.path),
|
if !FileManager().fileExists(atPath: indexA.path),
|
||||||
!FileManager.default.fileExists(atPath: indexB.path)
|
!FileManager().fileExists(atPath: indexB.path)
|
||||||
{
|
{
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -233,7 +233,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
|||||||
// (Avoid treating Canvas routes like "/" as filesystem paths.)
|
// (Avoid treating Canvas routes like "/" as filesystem paths.)
|
||||||
if trimmed.hasPrefix("/") {
|
if trimmed.hasPrefix("/") {
|
||||||
var isDir: ObjCBool = false
|
var isDir: ObjCBool = false
|
||||||
if FileManager.default.fileExists(atPath: trimmed, isDirectory: &isDir), !isDir.boolValue {
|
if FileManager().fileExists(atPath: trimmed, isDirectory: &isDir), !isDir.boolValue {
|
||||||
let url = URL(fileURLWithPath: trimmed)
|
let url = URL(fileURLWithPath: trimmed)
|
||||||
canvasWindowLogger.debug("canvas load file \(url.absoluteString, privacy: .public)")
|
canvasWindowLogger.debug("canvas load file \(url.absoluteString, privacy: .public)")
|
||||||
self.loadFile(url)
|
self.loadFile(url)
|
||||||
|
|||||||
@@ -426,34 +426,17 @@ extension ChannelsSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func resolveChannelTitle(_ id: String) -> String {
|
private func resolveChannelTitle(_ id: String) -> String {
|
||||||
if let label = self.store.snapshot?.channelLabels[id], !label.isEmpty {
|
let label = self.store.resolveChannelLabel(id)
|
||||||
return label
|
if label != id { return label }
|
||||||
}
|
|
||||||
return id.prefix(1).uppercased() + id.dropFirst()
|
return id.prefix(1).uppercased() + id.dropFirst()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveChannelDetailTitle(_ id: String) -> String {
|
private func resolveChannelDetailTitle(_ id: String) -> String {
|
||||||
switch id {
|
self.store.resolveChannelDetailLabel(id)
|
||||||
case "whatsapp": "WhatsApp Web"
|
|
||||||
case "telegram": "Telegram Bot"
|
|
||||||
case "discord": "Discord Bot"
|
|
||||||
case "slack": "Slack Bot"
|
|
||||||
case "signal": "Signal REST"
|
|
||||||
case "imessage": "iMessage"
|
|
||||||
default: self.resolveChannelTitle(id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveChannelSystemImage(_ id: String) -> String {
|
private func resolveChannelSystemImage(_ id: String) -> String {
|
||||||
switch id {
|
self.store.resolveChannelSystemImage(id)
|
||||||
case "whatsapp": "message"
|
|
||||||
case "telegram": "paperplane"
|
|
||||||
case "discord": "bubble.left.and.bubble.right"
|
|
||||||
case "slack": "number"
|
|
||||||
case "signal": "antenna.radiowaves.left.and.right"
|
|
||||||
case "imessage": "message.fill"
|
|
||||||
default: "message"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func channelStatusDictionary(_ id: String) -> [String: AnyCodable]? {
|
private func channelStatusDictionary(_ id: String) -> [String: AnyCodable]? {
|
||||||
|
|||||||
@@ -153,9 +153,19 @@ struct ChannelsStatusSnapshot: Codable {
|
|||||||
let application: AnyCodable?
|
let application: AnyCodable?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ChannelUiMetaEntry: Codable {
|
||||||
|
let id: String
|
||||||
|
let label: String
|
||||||
|
let detailLabel: String
|
||||||
|
let systemImage: String?
|
||||||
|
}
|
||||||
|
|
||||||
let ts: Double
|
let ts: Double
|
||||||
let channelOrder: [String]
|
let channelOrder: [String]
|
||||||
let channelLabels: [String: String]
|
let channelLabels: [String: String]
|
||||||
|
let channelDetailLabels: [String: String]?
|
||||||
|
let channelSystemImages: [String: String]?
|
||||||
|
let channelMeta: [ChannelUiMetaEntry]?
|
||||||
let channels: [String: AnyCodable]
|
let channels: [String: AnyCodable]
|
||||||
let channelAccounts: [String: [ChannelAccountSnapshot]]
|
let channelAccounts: [String: [ChannelAccountSnapshot]]
|
||||||
let channelDefaultAccountId: [String: String]
|
let channelDefaultAccountId: [String: String]
|
||||||
@@ -217,6 +227,47 @@ final class ChannelsStore {
|
|||||||
var configRoot: [String: Any] = [:]
|
var configRoot: [String: Any] = [:]
|
||||||
var configLoaded = false
|
var configLoaded = false
|
||||||
|
|
||||||
|
func channelMetaEntry(_ id: String) -> ChannelsStatusSnapshot.ChannelUiMetaEntry? {
|
||||||
|
self.snapshot?.channelMeta?.first(where: { $0.id == id })
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveChannelLabel(_ id: String) -> String {
|
||||||
|
if let meta = self.channelMetaEntry(id), !meta.label.isEmpty {
|
||||||
|
return meta.label
|
||||||
|
}
|
||||||
|
if let label = self.snapshot?.channelLabels[id], !label.isEmpty {
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveChannelDetailLabel(_ id: String) -> String {
|
||||||
|
if let meta = self.channelMetaEntry(id), !meta.detailLabel.isEmpty {
|
||||||
|
return meta.detailLabel
|
||||||
|
}
|
||||||
|
if let detail = self.snapshot?.channelDetailLabels?[id], !detail.isEmpty {
|
||||||
|
return detail
|
||||||
|
}
|
||||||
|
return self.resolveChannelLabel(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveChannelSystemImage(_ id: String) -> String {
|
||||||
|
if let meta = self.channelMetaEntry(id), let symbol = meta.systemImage, !symbol.isEmpty {
|
||||||
|
return symbol
|
||||||
|
}
|
||||||
|
if let symbol = self.snapshot?.channelSystemImages?[id], !symbol.isEmpty {
|
||||||
|
return symbol
|
||||||
|
}
|
||||||
|
return "message"
|
||||||
|
}
|
||||||
|
|
||||||
|
func orderedChannelIds() -> [String] {
|
||||||
|
if let meta = self.snapshot?.channelMeta, !meta.isEmpty {
|
||||||
|
return meta.map(\.id)
|
||||||
|
}
|
||||||
|
return self.snapshot?.channelOrder ?? []
|
||||||
|
}
|
||||||
|
|
||||||
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
|
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
|
||||||
self.isPreview = isPreview
|
self.isPreview = isPreview
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ enum ClawdbotConfigFile {
|
|||||||
|
|
||||||
static func loadDict() -> [String: Any] {
|
static func loadDict() -> [String: Any] {
|
||||||
let url = self.url()
|
let url = self.url()
|
||||||
guard FileManager.default.fileExists(atPath: url.path) else { return [:] }
|
guard FileManager().fileExists(atPath: url.path) else { return [:] }
|
||||||
do {
|
do {
|
||||||
let data = try Data(contentsOf: url)
|
let data = try Data(contentsOf: url)
|
||||||
guard let root = self.parseConfigData(data) else {
|
guard let root = self.parseConfigData(data) else {
|
||||||
@@ -38,7 +38,7 @@ enum ClawdbotConfigFile {
|
|||||||
do {
|
do {
|
||||||
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
|
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
|
||||||
let url = self.url()
|
let url = self.url()
|
||||||
try FileManager.default.createDirectory(
|
try FileManager().createDirectory(
|
||||||
at: url.deletingLastPathComponent(),
|
at: url.deletingLastPathComponent(),
|
||||||
withIntermediateDirectories: true)
|
withIntermediateDirectories: true)
|
||||||
try data.write(to: url, options: [.atomic])
|
try data.write(to: url, options: [.atomic])
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ enum ClawdbotPaths {
|
|||||||
if let override = ClawdbotEnv.path(self.stateDirEnv) {
|
if let override = ClawdbotEnv.path(self.stateDirEnv) {
|
||||||
return URL(fileURLWithPath: override, isDirectory: true)
|
return URL(fileURLWithPath: override, isDirectory: true)
|
||||||
}
|
}
|
||||||
return FileManager.default.homeDirectoryForCurrentUser
|
return FileManager().homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent(".clawdbot", isDirectory: true)
|
.appendingPathComponent(".clawdbot", isDirectory: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ enum CommandResolver {
|
|||||||
|
|
||||||
static func gatewayEntrypoint(in root: URL) -> String? {
|
static func gatewayEntrypoint(in root: URL) -> String? {
|
||||||
let distEntry = root.appendingPathComponent("dist/index.js").path
|
let distEntry = root.appendingPathComponent("dist/index.js").path
|
||||||
if FileManager.default.isReadableFile(atPath: distEntry) { return distEntry }
|
if FileManager().isReadableFile(atPath: distEntry) { return distEntry }
|
||||||
let binEntry = root.appendingPathComponent("bin/clawdbot.js").path
|
let binEntry = root.appendingPathComponent("bin/clawdbot.js").path
|
||||||
if FileManager.default.isReadableFile(atPath: binEntry) { return binEntry }
|
if FileManager().isReadableFile(atPath: binEntry) { return binEntry }
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,16 +47,16 @@ enum CommandResolver {
|
|||||||
static func projectRoot() -> URL {
|
static func projectRoot() -> URL {
|
||||||
if let stored = UserDefaults.standard.string(forKey: self.projectRootDefaultsKey),
|
if let stored = UserDefaults.standard.string(forKey: self.projectRootDefaultsKey),
|
||||||
let url = self.expandPath(stored),
|
let url = self.expandPath(stored),
|
||||||
FileManager.default.fileExists(atPath: url.path)
|
FileManager().fileExists(atPath: url.path)
|
||||||
{
|
{
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
let fallback = FileManager.default.homeDirectoryForCurrentUser
|
let fallback = FileManager().homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent("Projects/clawdbot")
|
.appendingPathComponent("Projects/clawdbot")
|
||||||
if FileManager.default.fileExists(atPath: fallback.path) {
|
if FileManager().fileExists(atPath: fallback.path) {
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
return FileManager.default.homeDirectoryForCurrentUser
|
return FileManager().homeDirectoryForCurrentUser
|
||||||
}
|
}
|
||||||
|
|
||||||
static func setProjectRoot(_ path: String) {
|
static func setProjectRoot(_ path: String) {
|
||||||
@@ -70,7 +70,7 @@ enum CommandResolver {
|
|||||||
static func preferredPaths() -> [String] {
|
static func preferredPaths() -> [String] {
|
||||||
let current = ProcessInfo.processInfo.environment["PATH"]?
|
let current = ProcessInfo.processInfo.environment["PATH"]?
|
||||||
.split(separator: ":").map(String.init) ?? []
|
.split(separator: ":").map(String.init) ?? []
|
||||||
let home = FileManager.default.homeDirectoryForCurrentUser
|
let home = FileManager().homeDirectoryForCurrentUser
|
||||||
let projectRoot = self.projectRoot()
|
let projectRoot = self.projectRoot()
|
||||||
return self.preferredPaths(home: home, current: current, projectRoot: projectRoot)
|
return self.preferredPaths(home: home, current: current, projectRoot: projectRoot)
|
||||||
}
|
}
|
||||||
@@ -99,10 +99,10 @@ enum CommandResolver {
|
|||||||
let bin = base.appendingPathComponent("bin")
|
let bin = base.appendingPathComponent("bin")
|
||||||
let nodeBin = base.appendingPathComponent("tools/node/bin")
|
let nodeBin = base.appendingPathComponent("tools/node/bin")
|
||||||
var paths: [String] = []
|
var paths: [String] = []
|
||||||
if FileManager.default.fileExists(atPath: bin.path) {
|
if FileManager().fileExists(atPath: bin.path) {
|
||||||
paths.append(bin.path)
|
paths.append(bin.path)
|
||||||
}
|
}
|
||||||
if FileManager.default.fileExists(atPath: nodeBin.path) {
|
if FileManager().fileExists(atPath: nodeBin.path) {
|
||||||
paths.append(nodeBin.path)
|
paths.append(nodeBin.path)
|
||||||
}
|
}
|
||||||
return paths
|
return paths
|
||||||
@@ -113,13 +113,13 @@ enum CommandResolver {
|
|||||||
|
|
||||||
// Volta
|
// Volta
|
||||||
let volta = home.appendingPathComponent(".volta/bin")
|
let volta = home.appendingPathComponent(".volta/bin")
|
||||||
if FileManager.default.fileExists(atPath: volta.path) {
|
if FileManager().fileExists(atPath: volta.path) {
|
||||||
bins.append(volta.path)
|
bins.append(volta.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// asdf
|
// asdf
|
||||||
let asdf = home.appendingPathComponent(".asdf/shims")
|
let asdf = home.appendingPathComponent(".asdf/shims")
|
||||||
if FileManager.default.fileExists(atPath: asdf.path) {
|
if FileManager().fileExists(atPath: asdf.path) {
|
||||||
bins.append(asdf.path)
|
bins.append(asdf.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,10 +137,10 @@ enum CommandResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func versionedNodeBinPaths(base: URL, suffix: String) -> [String] {
|
private static func versionedNodeBinPaths(base: URL, suffix: String) -> [String] {
|
||||||
guard FileManager.default.fileExists(atPath: base.path) else { return [] }
|
guard FileManager().fileExists(atPath: base.path) else { return [] }
|
||||||
let entries: [String]
|
let entries: [String]
|
||||||
do {
|
do {
|
||||||
entries = try FileManager.default.contentsOfDirectory(atPath: base.path)
|
entries = try FileManager().contentsOfDirectory(atPath: base.path)
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -167,7 +167,7 @@ enum CommandResolver {
|
|||||||
for entry in sorted {
|
for entry in sorted {
|
||||||
let binDir = base.appendingPathComponent(entry).appendingPathComponent(suffix)
|
let binDir = base.appendingPathComponent(entry).appendingPathComponent(suffix)
|
||||||
let node = binDir.appendingPathComponent("node")
|
let node = binDir.appendingPathComponent("node")
|
||||||
if FileManager.default.isExecutableFile(atPath: node.path) {
|
if FileManager().isExecutableFile(atPath: node.path) {
|
||||||
paths.append(binDir.path)
|
paths.append(binDir.path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,7 +177,7 @@ enum CommandResolver {
|
|||||||
static func findExecutable(named name: String, searchPaths: [String]? = nil) -> String? {
|
static func findExecutable(named name: String, searchPaths: [String]? = nil) -> String? {
|
||||||
for dir in searchPaths ?? self.preferredPaths() {
|
for dir in searchPaths ?? self.preferredPaths() {
|
||||||
let candidate = (dir as NSString).appendingPathComponent(name)
|
let candidate = (dir as NSString).appendingPathComponent(name)
|
||||||
if FileManager.default.isExecutableFile(atPath: candidate) {
|
if FileManager().isExecutableFile(atPath: candidate) {
|
||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,12 +191,12 @@ enum CommandResolver {
|
|||||||
static func projectClawdbotExecutable(projectRoot: URL? = nil) -> String? {
|
static func projectClawdbotExecutable(projectRoot: URL? = nil) -> String? {
|
||||||
let root = projectRoot ?? self.projectRoot()
|
let root = projectRoot ?? self.projectRoot()
|
||||||
let candidate = root.appendingPathComponent("node_modules/.bin").appendingPathComponent(self.helperName).path
|
let candidate = root.appendingPathComponent("node_modules/.bin").appendingPathComponent(self.helperName).path
|
||||||
return FileManager.default.isExecutableFile(atPath: candidate) ? candidate : nil
|
return FileManager().isExecutableFile(atPath: candidate) ? candidate : nil
|
||||||
}
|
}
|
||||||
|
|
||||||
static func nodeCliPath() -> String? {
|
static func nodeCliPath() -> String? {
|
||||||
let candidate = self.projectRoot().appendingPathComponent("bin/clawdbot.js").path
|
let candidate = self.projectRoot().appendingPathComponent("bin/clawdbot.js").path
|
||||||
return FileManager.default.isReadableFile(atPath: candidate) ? candidate : nil
|
return FileManager().isReadableFile(atPath: candidate) ? candidate : nil
|
||||||
}
|
}
|
||||||
|
|
||||||
static func hasAnyClawdbotInvoker(searchPaths: [String]? = nil) -> Bool {
|
static func hasAnyClawdbotInvoker(searchPaths: [String]? = nil) -> Bool {
|
||||||
@@ -459,7 +459,7 @@ enum CommandResolver {
|
|||||||
private static func expandPath(_ path: String) -> URL? {
|
private static func expandPath(_ path: String) -> URL? {
|
||||||
var expanded = path
|
var expanded = path
|
||||||
if expanded.hasPrefix("~") {
|
if expanded.hasPrefix("~") {
|
||||||
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
let home = FileManager().homeDirectoryForCurrentUser.path
|
||||||
expanded.replaceSubrange(expanded.startIndex...expanded.startIndex, with: home)
|
expanded.replaceSubrange(expanded.startIndex...expanded.startIndex, with: home)
|
||||||
}
|
}
|
||||||
return URL(fileURLWithPath: expanded)
|
return URL(fileURLWithPath: expanded)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ struct ControlAgentEvent: Codable, Sendable, Identifiable {
|
|||||||
let seq: Int
|
let seq: Int
|
||||||
let stream: String
|
let stream: String
|
||||||
let ts: Double
|
let ts: Double
|
||||||
let data: [String: AnyCodable]
|
let data: [String: ClawdbotProtocol.AnyCodable]
|
||||||
let summary: String?
|
let summary: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,8 +156,8 @@ final class ControlChannel {
|
|||||||
timeoutMs: Double? = nil) async throws -> Data
|
timeoutMs: Double? = nil) async throws -> Data
|
||||||
{
|
{
|
||||||
do {
|
do {
|
||||||
let rawParams = params?.reduce(into: [String: AnyCodable]()) {
|
let rawParams = params?.reduce(into: [String: ClawdbotKit.AnyCodable]()) {
|
||||||
$0[$1.key] = AnyCodable($1.value.base)
|
$0[$1.key] = ClawdbotKit.AnyCodable($1.value.base)
|
||||||
}
|
}
|
||||||
let data = try await GatewayConnection.shared.request(
|
let data = try await GatewayConnection.shared.request(
|
||||||
method: method,
|
method: method,
|
||||||
@@ -346,7 +346,7 @@ final class ControlChannel {
|
|||||||
let phase = event.data["phase"]?.value as? String ?? ""
|
let phase = event.data["phase"]?.value as? String ?? ""
|
||||||
let name = event.data["name"]?.value as? String
|
let name = event.data["name"]?.value as? String
|
||||||
let meta = event.data["meta"]?.value as? String
|
let meta = event.data["meta"]?.value as? String
|
||||||
let args = event.data["args"]?.value as? [String: AnyCodable]
|
let args = Self.bridgeToProtocolArgs(event.data["args"])
|
||||||
WorkActivityStore.shared.handleTool(
|
WorkActivityStore.shared.handleTool(
|
||||||
sessionKey: sessionKey,
|
sessionKey: sessionKey,
|
||||||
phase: phase,
|
phase: phase,
|
||||||
@@ -357,6 +357,27 @@ final class ControlChannel {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func bridgeToProtocolArgs(
|
||||||
|
_ value: ClawdbotProtocol.AnyCodable?) -> [String: ClawdbotProtocol.AnyCodable]?
|
||||||
|
{
|
||||||
|
guard let value else { return nil }
|
||||||
|
if let dict = value.value as? [String: ClawdbotProtocol.AnyCodable] {
|
||||||
|
return dict
|
||||||
|
}
|
||||||
|
if let dict = value.value as? [String: ClawdbotKit.AnyCodable],
|
||||||
|
let data = try? JSONEncoder().encode(dict),
|
||||||
|
let decoded = try? JSONDecoder().decode([String: ClawdbotProtocol.AnyCodable].self, from: data)
|
||||||
|
{
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
if let data = try? JSONEncoder().encode(value),
|
||||||
|
let decoded = try? JSONDecoder().decode([String: ClawdbotProtocol.AnyCodable].self, from: data)
|
||||||
|
{
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Notification.Name {
|
extension Notification.Name {
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ extension CronJobEditor {
|
|||||||
self.thinking = thinking ?? ""
|
self.thinking = thinking ?? ""
|
||||||
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
|
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
|
||||||
self.deliver = deliver ?? false
|
self.deliver = deliver ?? false
|
||||||
self.channel = GatewayAgentChannel(raw: channel)
|
let trimmed = (channel ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
self.channel = trimmed.isEmpty ? "last" : trimmed
|
||||||
self.to = to ?? ""
|
self.to = to ?? ""
|
||||||
self.bestEffortDeliver = bestEffortDeliver ?? false
|
self.bestEffortDeliver = bestEffortDeliver ?? false
|
||||||
}
|
}
|
||||||
@@ -210,7 +211,8 @@ extension CronJobEditor {
|
|||||||
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
|
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
|
||||||
payload["deliver"] = self.deliver
|
payload["deliver"] = self.deliver
|
||||||
if self.deliver {
|
if self.deliver {
|
||||||
payload["channel"] = self.channel.rawValue
|
let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
payload["channel"] = trimmed.isEmpty ? "last" : trimmed
|
||||||
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
|
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if !to.isEmpty { payload["to"] = to }
|
if !to.isEmpty { payload["to"] = to }
|
||||||
payload["bestEffortDeliver"] = self.bestEffortDeliver
|
payload["bestEffortDeliver"] = self.bestEffortDeliver
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ extension CronJobEditor {
|
|||||||
self.payloadKind = .agentTurn
|
self.payloadKind = .agentTurn
|
||||||
self.agentMessage = "Run diagnostic"
|
self.agentMessage = "Run diagnostic"
|
||||||
self.deliver = true
|
self.deliver = true
|
||||||
self.channel = .last
|
self.channel = "last"
|
||||||
self.to = "+15551230000"
|
self.to = "+15551230000"
|
||||||
self.thinking = "low"
|
self.thinking = "low"
|
||||||
self.timeoutSeconds = "90"
|
self.timeoutSeconds = "90"
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import ClawdbotProtocol
|
import ClawdbotProtocol
|
||||||
|
import Observation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct CronJobEditor: View {
|
struct CronJobEditor: View {
|
||||||
let job: CronJob?
|
let job: CronJob?
|
||||||
@Binding var isSaving: Bool
|
@Binding var isSaving: Bool
|
||||||
@Binding var error: String?
|
@Binding var error: String?
|
||||||
|
@Bindable var channelsStore: ChannelsStore
|
||||||
let onCancel: () -> Void
|
let onCancel: () -> Void
|
||||||
let onSave: ([String: AnyCodable]) -> Void
|
let onSave: ([String: AnyCodable]) -> Void
|
||||||
|
|
||||||
@@ -45,13 +47,29 @@ struct CronJobEditor: View {
|
|||||||
@State var systemEventText: String = ""
|
@State var systemEventText: String = ""
|
||||||
@State var agentMessage: String = ""
|
@State var agentMessage: String = ""
|
||||||
@State var deliver: Bool = false
|
@State var deliver: Bool = false
|
||||||
@State var channel: GatewayAgentChannel = .last
|
@State var channel: String = "last"
|
||||||
@State var to: String = ""
|
@State var to: String = ""
|
||||||
@State var thinking: String = ""
|
@State var thinking: String = ""
|
||||||
@State var timeoutSeconds: String = ""
|
@State var timeoutSeconds: String = ""
|
||||||
@State var bestEffortDeliver: Bool = false
|
@State var bestEffortDeliver: Bool = false
|
||||||
@State var postPrefix: String = "Cron"
|
@State var postPrefix: String = "Cron"
|
||||||
|
|
||||||
|
var channelOptions: [String] {
|
||||||
|
let ordered = self.channelsStore.orderedChannelIds()
|
||||||
|
var options = ["last"] + ordered
|
||||||
|
let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !trimmed.isEmpty, !options.contains(trimmed) {
|
||||||
|
options.append(trimmed)
|
||||||
|
}
|
||||||
|
var seen = Set<String>()
|
||||||
|
return options.filter { seen.insert($0).inserted }
|
||||||
|
}
|
||||||
|
|
||||||
|
func channelLabel(for id: String) -> String {
|
||||||
|
if id == "last" { return "last" }
|
||||||
|
return self.channelsStore.resolveChannelLabel(id)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
@@ -333,13 +351,9 @@ struct CronJobEditor: View {
|
|||||||
GridRow {
|
GridRow {
|
||||||
self.gridLabel("Channel")
|
self.gridLabel("Channel")
|
||||||
Picker("", selection: self.$channel) {
|
Picker("", selection: self.$channel) {
|
||||||
Text("last").tag(GatewayAgentChannel.last)
|
ForEach(self.channelOptions, id: \.self) { channel in
|
||||||
Text("whatsapp").tag(GatewayAgentChannel.whatsapp)
|
Text(self.channelLabel(for: channel)).tag(channel)
|
||||||
Text("telegram").tag(GatewayAgentChannel.telegram)
|
}
|
||||||
Text("discord").tag(GatewayAgentChannel.discord)
|
|
||||||
Text("slack").tag(GatewayAgentChannel.slack)
|
|
||||||
Text("signal").tag(GatewayAgentChannel.signal)
|
|
||||||
Text("imessage").tag(GatewayAgentChannel.imessage)
|
|
||||||
}
|
}
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
|
|||||||
@@ -8,13 +8,20 @@ extension CronSettings {
|
|||||||
self.content
|
self.content
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
.onAppear { self.store.start() }
|
.onAppear {
|
||||||
.onDisappear { self.store.stop() }
|
self.store.start()
|
||||||
|
self.channelsStore.start()
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
self.store.stop()
|
||||||
|
self.channelsStore.stop()
|
||||||
|
}
|
||||||
.sheet(isPresented: self.$showEditor) {
|
.sheet(isPresented: self.$showEditor) {
|
||||||
CronJobEditor(
|
CronJobEditor(
|
||||||
job: self.editingJob,
|
job: self.editingJob,
|
||||||
isSaving: self.$isSaving,
|
isSaving: self.$isSaving,
|
||||||
error: self.$editorError,
|
error: self.$editorError,
|
||||||
|
channelsStore: self.channelsStore,
|
||||||
onCancel: {
|
onCancel: {
|
||||||
self.showEditor = false
|
self.showEditor = false
|
||||||
self.editingJob = nil
|
self.editingJob = nil
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ struct CronSettings_Previews: PreviewProvider {
|
|||||||
durationMs: 1234,
|
durationMs: 1234,
|
||||||
nextRunAtMs: nil),
|
nextRunAtMs: nil),
|
||||||
]
|
]
|
||||||
return CronSettings(store: store)
|
return CronSettings(store: store, channelsStore: ChannelsStore(isPreview: true))
|
||||||
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,7 +103,7 @@ extension CronSettings {
|
|||||||
store.selectedJobId = job.id
|
store.selectedJobId = job.id
|
||||||
store.runEntries = [run]
|
store.runEntries = [run]
|
||||||
|
|
||||||
let view = CronSettings(store: store)
|
let view = CronSettings(store: store, channelsStore: ChannelsStore(isPreview: true))
|
||||||
_ = view.body
|
_ = view.body
|
||||||
_ = view.jobRow(job)
|
_ = view.jobRow(job)
|
||||||
_ = view.jobContextMenu(job)
|
_ = view.jobContextMenu(job)
|
||||||
|
|||||||
@@ -3,13 +3,15 @@ import SwiftUI
|
|||||||
|
|
||||||
struct CronSettings: View {
|
struct CronSettings: View {
|
||||||
@Bindable var store: CronJobsStore
|
@Bindable var store: CronJobsStore
|
||||||
|
@Bindable var channelsStore: ChannelsStore
|
||||||
@State var showEditor = false
|
@State var showEditor = false
|
||||||
@State var editingJob: CronJob?
|
@State var editingJob: CronJob?
|
||||||
@State var editorError: String?
|
@State var editorError: String?
|
||||||
@State var isSaving = false
|
@State var isSaving = false
|
||||||
@State var confirmDelete: CronJob?
|
@State var confirmDelete: CronJob?
|
||||||
|
|
||||||
init(store: CronJobsStore = .shared) {
|
init(store: CronJobsStore = .shared, channelsStore: ChannelsStore = .shared) {
|
||||||
self.store = store
|
self.store = store
|
||||||
|
self.channelsStore = channelsStore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,7 @@ enum DebugActions {
|
|||||||
static func openLog() {
|
static func openLog() {
|
||||||
let path = self.pinoLogPath()
|
let path = self.pinoLogPath()
|
||||||
let url = URL(fileURLWithPath: path)
|
let url = URL(fileURLWithPath: path)
|
||||||
guard FileManager.default.fileExists(atPath: path) else {
|
guard FileManager().fileExists(atPath: path) else {
|
||||||
let alert = NSAlert()
|
let alert = NSAlert()
|
||||||
alert.messageText = "Log file not found"
|
alert.messageText = "Log file not found"
|
||||||
alert.informativeText = path
|
alert.informativeText = path
|
||||||
@@ -38,7 +38,7 @@ enum DebugActions {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
static func openConfigFolder() {
|
static func openConfigFolder() {
|
||||||
let url = FileManager.default
|
let url = FileManager()
|
||||||
.homeDirectoryForCurrentUser
|
.homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent(".clawdbot", isDirectory: true)
|
.appendingPathComponent(".clawdbot", isDirectory: true)
|
||||||
NSWorkspace.shared.activateFileViewerSelecting([url])
|
NSWorkspace.shared.activateFileViewerSelecting([url])
|
||||||
@@ -55,7 +55,7 @@ enum DebugActions {
|
|||||||
}
|
}
|
||||||
let path = self.resolveSessionStorePath()
|
let path = self.resolveSessionStorePath()
|
||||||
let url = URL(fileURLWithPath: path)
|
let url = URL(fileURLWithPath: path)
|
||||||
if FileManager.default.fileExists(atPath: path) {
|
if FileManager().fileExists(atPath: path) {
|
||||||
NSWorkspace.shared.activateFileViewerSelecting([url])
|
NSWorkspace.shared.activateFileViewerSelecting([url])
|
||||||
} else {
|
} else {
|
||||||
NSWorkspace.shared.open(url.deletingLastPathComponent())
|
NSWorkspace.shared.open(url.deletingLastPathComponent())
|
||||||
@@ -195,7 +195,7 @@ enum DebugActions {
|
|||||||
@MainActor
|
@MainActor
|
||||||
private static func resolveSessionStorePath() -> String {
|
private static func resolveSessionStorePath() -> String {
|
||||||
let defaultPath = SessionLoader.defaultStorePath
|
let defaultPath = SessionLoader.defaultStorePath
|
||||||
let configURL = FileManager.default.homeDirectoryForCurrentUser
|
let configURL = FileManager().homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent(".clawdbot/clawdbot.json")
|
.appendingPathComponent(".clawdbot/clawdbot.json")
|
||||||
guard
|
guard
|
||||||
let data = try? Data(contentsOf: configURL),
|
let data = try? Data(contentsOf: configURL),
|
||||||
|
|||||||
@@ -354,7 +354,7 @@ struct DebugSettings: View {
|
|||||||
Button("Save") { self.saveRelayRoot() }
|
Button("Save") { self.saveRelayRoot() }
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
Button("Reset") {
|
Button("Reset") {
|
||||||
let def = FileManager.default.homeDirectoryForCurrentUser
|
let def = FileManager().homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent("Projects/clawdbot").path
|
.appendingPathComponent("Projects/clawdbot").path
|
||||||
self.gatewayRootInput = def
|
self.gatewayRootInput = def
|
||||||
self.saveRelayRoot()
|
self.saveRelayRoot()
|
||||||
@@ -743,7 +743,7 @@ struct DebugSettings: View {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
|
let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
|
||||||
try FileManager.default.createDirectory(
|
try FileManager().createDirectory(
|
||||||
at: url.deletingLastPathComponent(),
|
at: url.deletingLastPathComponent(),
|
||||||
withIntermediateDirectories: true)
|
withIntermediateDirectories: true)
|
||||||
try data.write(to: url, options: [.atomic])
|
try data.write(to: url, options: [.atomic])
|
||||||
@@ -776,7 +776,7 @@ struct DebugSettings: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func configURL() -> URL {
|
private func configURL() -> URL {
|
||||||
FileManager.default.homeDirectoryForCurrentUser
|
FileManager().homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent(".clawdbot")
|
.appendingPathComponent(".clawdbot")
|
||||||
.appendingPathComponent("clawdbot.json")
|
.appendingPathComponent("clawdbot.json")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ actor DiagnosticsFileLog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
nonisolated static func logDirectoryURL() -> URL {
|
nonisolated static func logDirectoryURL() -> URL {
|
||||||
let library = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first
|
let library = FileManager().urls(for: .libraryDirectory, in: .userDomainMask).first
|
||||||
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library", isDirectory: true)
|
?? FileManager().homeDirectoryForCurrentUser.appendingPathComponent("Library", isDirectory: true)
|
||||||
return library
|
return library
|
||||||
.appendingPathComponent("Logs", isDirectory: true)
|
.appendingPathComponent("Logs", isDirectory: true)
|
||||||
.appendingPathComponent("Clawdbot", isDirectory: true)
|
.appendingPathComponent("Clawdbot", isDirectory: true)
|
||||||
@@ -43,7 +43,7 @@ actor DiagnosticsFileLog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func clear() throws {
|
func clear() throws {
|
||||||
let fm = FileManager.default
|
let fm = FileManager()
|
||||||
let base = Self.logFileURL()
|
let base = Self.logFileURL()
|
||||||
if fm.fileExists(atPath: base.path) {
|
if fm.fileExists(atPath: base.path) {
|
||||||
try fm.removeItem(at: base)
|
try fm.removeItem(at: base)
|
||||||
@@ -67,7 +67,7 @@ actor DiagnosticsFileLog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func ensureDirectory() throws {
|
private func ensureDirectory() throws {
|
||||||
try FileManager.default.createDirectory(
|
try FileManager().createDirectory(
|
||||||
at: Self.logDirectoryURL(),
|
at: Self.logDirectoryURL(),
|
||||||
withIntermediateDirectories: true)
|
withIntermediateDirectories: true)
|
||||||
}
|
}
|
||||||
@@ -79,7 +79,7 @@ actor DiagnosticsFileLog {
|
|||||||
line.append(data)
|
line.append(data)
|
||||||
line.append(0x0A) // newline
|
line.append(0x0A) // newline
|
||||||
|
|
||||||
let fm = FileManager.default
|
let fm = FileManager()
|
||||||
if !fm.fileExists(atPath: url.path) {
|
if !fm.fileExists(atPath: url.path) {
|
||||||
fm.createFile(atPath: url.path, contents: nil)
|
fm.createFile(atPath: url.path, contents: nil)
|
||||||
}
|
}
|
||||||
@@ -92,13 +92,13 @@ actor DiagnosticsFileLog {
|
|||||||
|
|
||||||
private func rotateIfNeeded() throws {
|
private func rotateIfNeeded() throws {
|
||||||
let url = Self.logFileURL()
|
let url = Self.logFileURL()
|
||||||
guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),
|
guard let attrs = try? FileManager().attributesOfItem(atPath: url.path),
|
||||||
let size = attrs[.size] as? NSNumber
|
let size = attrs[.size] as? NSNumber
|
||||||
else { return }
|
else { return }
|
||||||
|
|
||||||
if size.int64Value < self.maxBytes { return }
|
if size.int64Value < self.maxBytes { return }
|
||||||
|
|
||||||
let fm = FileManager.default
|
let fm = FileManager()
|
||||||
|
|
||||||
let oldest = self.rotatedURL(index: self.maxBackups)
|
let oldest = self.rotatedURL(index: self.maxBackups)
|
||||||
if fm.fileExists(atPath: oldest.path) {
|
if fm.fileExists(atPath: oldest.path) {
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ enum ExecApprovalsStore {
|
|||||||
|
|
||||||
static func readSnapshot() -> ExecApprovalsSnapshot {
|
static func readSnapshot() -> ExecApprovalsSnapshot {
|
||||||
let url = self.fileURL()
|
let url = self.fileURL()
|
||||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
guard FileManager().fileExists(atPath: url.path) else {
|
||||||
return ExecApprovalsSnapshot(
|
return ExecApprovalsSnapshot(
|
||||||
path: url.path,
|
path: url.path,
|
||||||
exists: false,
|
exists: false,
|
||||||
@@ -216,7 +216,7 @@ enum ExecApprovalsStore {
|
|||||||
|
|
||||||
static func loadFile() -> ExecApprovalsFile {
|
static func loadFile() -> ExecApprovalsFile {
|
||||||
let url = self.fileURL()
|
let url = self.fileURL()
|
||||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
guard FileManager().fileExists(atPath: url.path) else {
|
||||||
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
|
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
@@ -238,11 +238,11 @@ enum ExecApprovalsStore {
|
|||||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
let data = try encoder.encode(file)
|
let data = try encoder.encode(file)
|
||||||
let url = self.fileURL()
|
let url = self.fileURL()
|
||||||
try FileManager.default.createDirectory(
|
try FileManager().createDirectory(
|
||||||
at: url.deletingLastPathComponent(),
|
at: url.deletingLastPathComponent(),
|
||||||
withIntermediateDirectories: true)
|
withIntermediateDirectories: true)
|
||||||
try data.write(to: url, options: [.atomic])
|
try data.write(to: url, options: [.atomic])
|
||||||
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
try? FileManager().setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
||||||
} catch {
|
} catch {
|
||||||
self.logger.error("exec approvals save failed: \(error.localizedDescription, privacy: .public)")
|
self.logger.error("exec approvals save failed: \(error.localizedDescription, privacy: .public)")
|
||||||
}
|
}
|
||||||
@@ -442,11 +442,11 @@ enum ExecApprovalsStore {
|
|||||||
private static func expandPath(_ raw: String) -> String {
|
private static func expandPath(_ raw: String) -> String {
|
||||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if trimmed == "~" {
|
if trimmed == "~" {
|
||||||
return FileManager.default.homeDirectoryForCurrentUser.path
|
return FileManager().homeDirectoryForCurrentUser.path
|
||||||
}
|
}
|
||||||
if trimmed.hasPrefix("~/") {
|
if trimmed.hasPrefix("~/") {
|
||||||
let suffix = trimmed.dropFirst(2)
|
let suffix = trimmed.dropFirst(2)
|
||||||
return FileManager.default.homeDirectoryForCurrentUser
|
return FileManager().homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent(String(suffix)).path
|
.appendingPathComponent(String(suffix)).path
|
||||||
}
|
}
|
||||||
return trimmed
|
return trimmed
|
||||||
@@ -497,7 +497,7 @@ struct ExecCommandResolution: Sendable {
|
|||||||
return expanded
|
return expanded
|
||||||
}
|
}
|
||||||
let base = cwd?.trimmingCharacters(in: .whitespacesAndNewlines)
|
let base = cwd?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let root = (base?.isEmpty == false) ? base! : FileManager.default.currentDirectoryPath
|
let root = (base?.isEmpty == false) ? base! : FileManager().currentDirectoryPath
|
||||||
return URL(fileURLWithPath: root).appendingPathComponent(expanded).path
|
return URL(fileURLWithPath: root).appendingPathComponent(expanded).path
|
||||||
}
|
}
|
||||||
let searchPaths = self.searchPaths(from: env)
|
let searchPaths = self.searchPaths(from: env)
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ private struct ExecHostRequest: Codable {
|
|||||||
var needsScreenRecording: Bool?
|
var needsScreenRecording: Bool?
|
||||||
var agentId: String?
|
var agentId: String?
|
||||||
var sessionKey: String?
|
var sessionKey: String?
|
||||||
|
var approvalDecision: ExecApprovalDecision?
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ExecHostRunResult: Codable {
|
private struct ExecHostRunResult: Codable {
|
||||||
@@ -258,6 +259,20 @@ enum ExecApprovalsPromptPresenter {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private enum ExecHostExecutor {
|
private enum ExecHostExecutor {
|
||||||
|
private struct ExecApprovalContext {
|
||||||
|
let command: [String]
|
||||||
|
let displayCommand: String
|
||||||
|
let trimmedAgent: String?
|
||||||
|
let approvals: ExecApprovalsResolved
|
||||||
|
let security: ExecSecurity
|
||||||
|
let ask: ExecAsk
|
||||||
|
let autoAllowSkills: Bool
|
||||||
|
let env: [String: String]?
|
||||||
|
let resolution: ExecCommandResolution?
|
||||||
|
let allowlistMatch: ExecAllowlistEntry?
|
||||||
|
let skillAllow: Bool
|
||||||
|
}
|
||||||
|
|
||||||
private static let blockedEnvKeys: Set<String> = [
|
private static let blockedEnvKeys: Set<String> = [
|
||||||
"PATH",
|
"PATH",
|
||||||
"NODE_OPTIONS",
|
"NODE_OPTIONS",
|
||||||
@@ -276,14 +291,93 @@ private enum ExecHostExecutor {
|
|||||||
static func handle(_ request: ExecHostRequest) async -> ExecHostResponse {
|
static func handle(_ request: ExecHostRequest) async -> ExecHostResponse {
|
||||||
let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
guard !command.isEmpty else {
|
guard !command.isEmpty else {
|
||||||
return ExecHostResponse(
|
return self.errorResponse(
|
||||||
type: "exec-res",
|
code: "INVALID_REQUEST",
|
||||||
id: UUID().uuidString,
|
message: "command required",
|
||||||
ok: false,
|
reason: "invalid")
|
||||||
payload: nil,
|
|
||||||
error: ExecHostError(code: "INVALID_REQUEST", message: "command required", reason: "invalid"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let context = await self.buildContext(request: request, command: command)
|
||||||
|
if context.security == .deny {
|
||||||
|
return self.errorResponse(
|
||||||
|
code: "UNAVAILABLE",
|
||||||
|
message: "SYSTEM_RUN_DISABLED: security=deny",
|
||||||
|
reason: "security=deny")
|
||||||
|
}
|
||||||
|
|
||||||
|
let approvalDecision = request.approvalDecision
|
||||||
|
if approvalDecision == .deny {
|
||||||
|
return self.errorResponse(
|
||||||
|
code: "UNAVAILABLE",
|
||||||
|
message: "SYSTEM_RUN_DENIED: user denied",
|
||||||
|
reason: "user-denied")
|
||||||
|
}
|
||||||
|
|
||||||
|
var approvedByAsk = approvalDecision != nil
|
||||||
|
if self.requiresAsk(
|
||||||
|
ask: context.ask,
|
||||||
|
security: context.security,
|
||||||
|
allowlistMatch: context.allowlistMatch,
|
||||||
|
skillAllow: context.skillAllow),
|
||||||
|
approvalDecision == nil
|
||||||
|
{
|
||||||
|
let decision = ExecApprovalsPromptPresenter.prompt(
|
||||||
|
ExecApprovalPromptRequest(
|
||||||
|
command: context.displayCommand,
|
||||||
|
cwd: request.cwd,
|
||||||
|
host: "node",
|
||||||
|
security: context.security.rawValue,
|
||||||
|
ask: context.ask.rawValue,
|
||||||
|
agentId: context.trimmedAgent,
|
||||||
|
resolvedPath: context.resolution?.resolvedPath))
|
||||||
|
|
||||||
|
switch decision {
|
||||||
|
case .deny:
|
||||||
|
return self.errorResponse(
|
||||||
|
code: "UNAVAILABLE",
|
||||||
|
message: "SYSTEM_RUN_DENIED: user denied",
|
||||||
|
reason: "user-denied")
|
||||||
|
case .allowAlways:
|
||||||
|
approvedByAsk = true
|
||||||
|
self.persistAllowlistEntry(decision: decision, context: context)
|
||||||
|
case .allowOnce:
|
||||||
|
approvedByAsk = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.persistAllowlistEntry(decision: approvalDecision, context: context)
|
||||||
|
|
||||||
|
if context.security == .allowlist,
|
||||||
|
context.allowlistMatch == nil,
|
||||||
|
!context.skillAllow,
|
||||||
|
!approvedByAsk
|
||||||
|
{
|
||||||
|
return self.errorResponse(
|
||||||
|
code: "UNAVAILABLE",
|
||||||
|
message: "SYSTEM_RUN_DENIED: allowlist miss",
|
||||||
|
reason: "allowlist-miss")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let match = context.allowlistMatch {
|
||||||
|
ExecApprovalsStore.recordAllowlistUse(
|
||||||
|
agentId: context.trimmedAgent,
|
||||||
|
pattern: match.pattern,
|
||||||
|
command: context.displayCommand,
|
||||||
|
resolvedPath: context.resolution?.resolvedPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let errorResponse = await self.ensureScreenRecordingAccess(request.needsScreenRecording) {
|
||||||
|
return errorResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
return await self.runCommand(
|
||||||
|
command: command,
|
||||||
|
cwd: request.cwd,
|
||||||
|
env: context.env,
|
||||||
|
timeoutMs: request.timeoutMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func buildContext(request: ExecHostRequest, command: [String]) async -> ExecApprovalContext {
|
||||||
let displayCommand = ExecCommandFormatter.displayString(
|
let displayCommand = ExecCommandFormatter.displayString(
|
||||||
for: command,
|
for: command,
|
||||||
rawCommand: request.rawCommand)
|
rawCommand: request.rawCommand)
|
||||||
@@ -309,102 +403,72 @@ private enum ExecHostExecutor {
|
|||||||
} else {
|
} else {
|
||||||
skillAllow = false
|
skillAllow = false
|
||||||
}
|
}
|
||||||
|
return ExecApprovalContext(
|
||||||
|
command: command,
|
||||||
|
displayCommand: displayCommand,
|
||||||
|
trimmedAgent: trimmedAgent,
|
||||||
|
approvals: approvals,
|
||||||
|
security: security,
|
||||||
|
ask: ask,
|
||||||
|
autoAllowSkills: autoAllowSkills,
|
||||||
|
env: env,
|
||||||
|
resolution: resolution,
|
||||||
|
allowlistMatch: allowlistMatch,
|
||||||
|
skillAllow: skillAllow)
|
||||||
|
}
|
||||||
|
|
||||||
if security == .deny {
|
private static func requiresAsk(
|
||||||
return ExecHostResponse(
|
ask: ExecAsk,
|
||||||
type: "exec-res",
|
security: ExecSecurity,
|
||||||
id: UUID().uuidString,
|
allowlistMatch: ExecAllowlistEntry?,
|
||||||
ok: false,
|
skillAllow: Bool) -> Bool
|
||||||
payload: nil,
|
{
|
||||||
error: ExecHostError(
|
if ask == .always { return true }
|
||||||
code: "UNAVAILABLE",
|
if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true }
|
||||||
message: "SYSTEM_RUN_DISABLED: security=deny",
|
return false
|
||||||
reason: "security=deny"))
|
}
|
||||||
|
|
||||||
|
private static func persistAllowlistEntry(
|
||||||
|
decision: ExecApprovalDecision?,
|
||||||
|
context: ExecApprovalContext)
|
||||||
|
{
|
||||||
|
guard decision == .allowAlways, context.security == .allowlist else { return }
|
||||||
|
guard let pattern = self.allowlistPattern(command: context.command, resolution: context.resolution) else {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
ExecApprovalsStore.addAllowlistEntry(agentId: context.trimmedAgent, pattern: pattern)
|
||||||
|
}
|
||||||
|
|
||||||
let requiresAsk: Bool = {
|
private static func allowlistPattern(
|
||||||
if ask == .always { return true }
|
command: [String],
|
||||||
if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true }
|
resolution: ExecCommandResolution?) -> String?
|
||||||
return false
|
{
|
||||||
}()
|
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
|
||||||
|
return pattern.isEmpty ? nil : pattern
|
||||||
|
}
|
||||||
|
|
||||||
var approvedByAsk = false
|
private static func ensureScreenRecordingAccess(_ needsScreenRecording: Bool?) async -> ExecHostResponse? {
|
||||||
if requiresAsk {
|
guard needsScreenRecording == true else { return nil }
|
||||||
let decision = ExecApprovalsPromptPresenter.prompt(
|
let authorized = await PermissionManager
|
||||||
ExecApprovalPromptRequest(
|
.status([.screenRecording])[.screenRecording] ?? false
|
||||||
command: displayCommand,
|
if authorized { return nil }
|
||||||
cwd: request.cwd,
|
return self.errorResponse(
|
||||||
host: "node",
|
code: "UNAVAILABLE",
|
||||||
security: security.rawValue,
|
message: "PERMISSION_MISSING: screenRecording",
|
||||||
ask: ask.rawValue,
|
reason: "permission:screenRecording")
|
||||||
agentId: trimmedAgent,
|
}
|
||||||
resolvedPath: resolution?.resolvedPath))
|
|
||||||
|
|
||||||
switch decision {
|
private static func runCommand(
|
||||||
case .deny:
|
command: [String],
|
||||||
return ExecHostResponse(
|
cwd: String?,
|
||||||
type: "exec-res",
|
env: [String: String]?,
|
||||||
id: UUID().uuidString,
|
timeoutMs: Int?) async -> ExecHostResponse
|
||||||
ok: false,
|
{
|
||||||
payload: nil,
|
let timeoutSec = timeoutMs.flatMap { Double($0) / 1000.0 }
|
||||||
error: ExecHostError(
|
|
||||||
code: "UNAVAILABLE",
|
|
||||||
message: "SYSTEM_RUN_DENIED: user denied",
|
|
||||||
reason: "user-denied"))
|
|
||||||
case .allowAlways:
|
|
||||||
approvedByAsk = true
|
|
||||||
if security == .allowlist {
|
|
||||||
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
|
|
||||||
if !pattern.isEmpty {
|
|
||||||
ExecApprovalsStore.addAllowlistEntry(agentId: trimmedAgent, pattern: pattern)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case .allowOnce:
|
|
||||||
approvedByAsk = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if security == .allowlist, allowlistMatch == nil, !skillAllow, !approvedByAsk {
|
|
||||||
return ExecHostResponse(
|
|
||||||
type: "exec-res",
|
|
||||||
id: UUID().uuidString,
|
|
||||||
ok: false,
|
|
||||||
payload: nil,
|
|
||||||
error: ExecHostError(
|
|
||||||
code: "UNAVAILABLE",
|
|
||||||
message: "SYSTEM_RUN_DENIED: allowlist miss",
|
|
||||||
reason: "allowlist-miss"))
|
|
||||||
}
|
|
||||||
|
|
||||||
if let match = allowlistMatch {
|
|
||||||
ExecApprovalsStore.recordAllowlistUse(
|
|
||||||
agentId: trimmedAgent,
|
|
||||||
pattern: match.pattern,
|
|
||||||
command: displayCommand,
|
|
||||||
resolvedPath: resolution?.resolvedPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
if request.needsScreenRecording == true {
|
|
||||||
let authorized = await PermissionManager
|
|
||||||
.status([.screenRecording])[.screenRecording] ?? false
|
|
||||||
if !authorized {
|
|
||||||
return ExecHostResponse(
|
|
||||||
type: "exec-res",
|
|
||||||
id: UUID().uuidString,
|
|
||||||
ok: false,
|
|
||||||
payload: nil,
|
|
||||||
error: ExecHostError(
|
|
||||||
code: "UNAVAILABLE",
|
|
||||||
message: "PERMISSION_MISSING: screenRecording",
|
|
||||||
reason: "permission:screenRecording"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let timeoutSec = request.timeoutMs.flatMap { Double($0) / 1000.0 }
|
|
||||||
let result = await Task.detached { () -> ShellExecutor.ShellResult in
|
let result = await Task.detached { () -> ShellExecutor.ShellResult in
|
||||||
await ShellExecutor.runDetailed(
|
await ShellExecutor.runDetailed(
|
||||||
command: command,
|
command: command,
|
||||||
cwd: request.cwd,
|
cwd: cwd,
|
||||||
env: env,
|
env: env,
|
||||||
timeout: timeoutSec)
|
timeout: timeoutSec)
|
||||||
}.value
|
}.value
|
||||||
@@ -415,7 +479,24 @@ private enum ExecHostExecutor {
|
|||||||
stdout: result.stdout,
|
stdout: result.stdout,
|
||||||
stderr: result.stderr,
|
stderr: result.stderr,
|
||||||
error: result.errorMessage)
|
error: result.errorMessage)
|
||||||
return ExecHostResponse(
|
return self.successResponse(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func errorResponse(
|
||||||
|
code: String,
|
||||||
|
message: String,
|
||||||
|
reason: String?) -> ExecHostResponse
|
||||||
|
{
|
||||||
|
ExecHostResponse(
|
||||||
|
type: "exec-res",
|
||||||
|
id: UUID().uuidString,
|
||||||
|
ok: false,
|
||||||
|
payload: nil,
|
||||||
|
error: ExecHostError(code: code, message: message, reason: reason))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func successResponse(_ payload: ExecHostRunResult) -> ExecHostResponse {
|
||||||
|
ExecHostResponse(
|
||||||
type: "exec-res",
|
type: "exec-res",
|
||||||
id: UUID().uuidString,
|
id: UUID().uuidString,
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
|
|||||||
case signal
|
case signal
|
||||||
case imessage
|
case imessage
|
||||||
case msteams
|
case msteams
|
||||||
|
case bluebubbles
|
||||||
case webchat
|
case webchat
|
||||||
|
|
||||||
init(raw: String?) {
|
init(raw: String?) {
|
||||||
@@ -147,6 +148,27 @@ actor GatewayConnection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let nsError = lastError as NSError
|
||||||
|
if nsError.domain == URLError.errorDomain,
|
||||||
|
let fallback = await GatewayEndpointStore.shared.maybeFallbackToTailnet(from: cfg.url)
|
||||||
|
{
|
||||||
|
await self.configure(url: fallback.url, token: fallback.token, password: fallback.password)
|
||||||
|
for delayMs in [150, 400, 900] {
|
||||||
|
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
|
||||||
|
do {
|
||||||
|
guard let client = self.client else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "Gateway",
|
||||||
|
code: 0,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "gateway not configured"])
|
||||||
|
}
|
||||||
|
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
|
||||||
|
} catch {
|
||||||
|
lastError = error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
throw lastError
|
throw lastError
|
||||||
case .remote:
|
case .remote:
|
||||||
let nsError = error as NSError
|
let nsError = error as NSError
|
||||||
@@ -243,9 +265,9 @@ actor GatewayConnection {
|
|||||||
return trimmed.isEmpty ? nil : trimmed
|
return trimmed.isEmpty ? nil : trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sessionDefaultString(_ defaults: [String: AnyCodable]?, key: String) -> String {
|
private func sessionDefaultString(_ defaults: [String: ClawdbotProtocol.AnyCodable]?, key: String) -> String {
|
||||||
(defaults?[key]?.stringValue ?? "")
|
let raw = defaults?[key]?.value as? String
|
||||||
.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
return (raw ?? "").trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
||||||
}
|
}
|
||||||
|
|
||||||
func cachedMainSessionKey() -> String? {
|
func cachedMainSessionKey() -> String? {
|
||||||
|
|||||||
@@ -155,7 +155,8 @@ actor GatewayEndpointStore {
|
|||||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if !trimmed.isEmpty {
|
if !trimmed.isEmpty {
|
||||||
if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root),
|
if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root),
|
||||||
!configToken.isEmpty
|
!configToken.isEmpty,
|
||||||
|
configToken != trimmed
|
||||||
{
|
{
|
||||||
self.warnEnvOverrideOnce(
|
self.warnEnvOverrideOnce(
|
||||||
kind: .token,
|
kind: .token,
|
||||||
@@ -164,32 +165,19 @@ actor GatewayEndpointStore {
|
|||||||
}
|
}
|
||||||
return trimmed
|
return trimmed
|
||||||
}
|
}
|
||||||
if isRemote {
|
|
||||||
if let gateway = root["gateway"] as? [String: Any],
|
if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root),
|
||||||
let remote = gateway["remote"] as? [String: Any],
|
!configToken.isEmpty
|
||||||
let token = remote["token"] as? String
|
|
||||||
{
|
|
||||||
let value = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if !value.isEmpty {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if let gateway = root["gateway"] as? [String: Any],
|
|
||||||
let auth = gateway["auth"] as? [String: Any],
|
|
||||||
let token = auth["token"] as? String
|
|
||||||
{
|
{
|
||||||
let value = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
return configToken
|
||||||
if !value.isEmpty {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines),
|
if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
!token.isEmpty
|
!token.isEmpty
|
||||||
{
|
{
|
||||||
return token
|
return token
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,6 +469,35 @@ actor GatewayEndpointStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func maybeFallbackToTailnet(from currentURL: URL) async -> GatewayConnection.Config? {
|
||||||
|
let mode = await self.deps.mode()
|
||||||
|
guard mode == .local else { return nil }
|
||||||
|
|
||||||
|
let root = ClawdbotConfigFile.loadDict()
|
||||||
|
let bind = GatewayEndpointStore.resolveGatewayBindMode(
|
||||||
|
root: root,
|
||||||
|
env: ProcessInfo.processInfo.environment)
|
||||||
|
guard bind == "auto" else { return nil }
|
||||||
|
|
||||||
|
let currentHost = currentURL.host?.lowercased() ?? ""
|
||||||
|
guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil }
|
||||||
|
|
||||||
|
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
|
||||||
|
guard let tailscaleIP, !tailscaleIP.isEmpty else { return nil }
|
||||||
|
|
||||||
|
let scheme = GatewayEndpointStore.resolveGatewayScheme(
|
||||||
|
root: root,
|
||||||
|
env: ProcessInfo.processInfo.environment)
|
||||||
|
let port = self.deps.localPort()
|
||||||
|
let token = self.deps.token()
|
||||||
|
let password = self.deps.password()
|
||||||
|
let url = URL(string: "\(scheme)://\(tailscaleIP):\(port)")!
|
||||||
|
|
||||||
|
self.logger.info("auto bind fallback to tailnet host=\(tailscaleIP, privacy: .public)")
|
||||||
|
self.setState(.ready(mode: .local, url: url, token: token, password: password))
|
||||||
|
return (url, token, password)
|
||||||
|
}
|
||||||
|
|
||||||
private static func resolveGatewayBindMode(
|
private static func resolveGatewayBindMode(
|
||||||
root: [String: Any],
|
root: [String: Any],
|
||||||
env: [String: String]) -> String?
|
env: [String: String]) -> String?
|
||||||
@@ -536,8 +553,10 @@ actor GatewayEndpointStore {
|
|||||||
tailscaleIP: String?) -> String
|
tailscaleIP: String?) -> String
|
||||||
{
|
{
|
||||||
switch bindMode {
|
switch bindMode {
|
||||||
case "tailnet", "auto":
|
case "tailnet":
|
||||||
tailscaleIP ?? "127.0.0.1"
|
tailscaleIP ?? "127.0.0.1"
|
||||||
|
case "auto":
|
||||||
|
"127.0.0.1"
|
||||||
case "custom":
|
case "custom":
|
||||||
customBindHost ?? "127.0.0.1"
|
customBindHost ?? "127.0.0.1"
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ enum GatewayLaunchAgentManager {
|
|||||||
private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent"
|
private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent"
|
||||||
|
|
||||||
private static var plistURL: URL {
|
private static var plistURL: URL {
|
||||||
FileManager.default.homeDirectoryForCurrentUser
|
FileManager().homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist")
|
.appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,9 +67,9 @@ enum GatewayLaunchAgentManager {
|
|||||||
|
|
||||||
extension GatewayLaunchAgentManager {
|
extension GatewayLaunchAgentManager {
|
||||||
private static func isLaunchAgentWriteDisabled() -> Bool {
|
private static func isLaunchAgentWriteDisabled() -> Bool {
|
||||||
let marker = FileManager.default.homeDirectoryForCurrentUser
|
let marker = FileManager().homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent(self.disableLaunchAgentMarker)
|
.appendingPathComponent(self.disableLaunchAgentMarker)
|
||||||
return FileManager.default.fileExists(atPath: marker.path)
|
return FileManager().fileExists(atPath: marker.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func readDaemonLoaded() async -> Bool? {
|
private static func readDaemonLoaded() async -> Bool? {
|
||||||
|
|||||||
@@ -365,7 +365,7 @@ final class GatewayProcessManager {
|
|||||||
|
|
||||||
func clearLog() {
|
func clearLog() {
|
||||||
self.log = ""
|
self.log = ""
|
||||||
try? FileManager.default.removeItem(atPath: GatewayLaunchAgentManager.launchdGatewayLogPath())
|
try? FileManager().removeItem(atPath: GatewayLaunchAgentManager.launchdGatewayLogPath())
|
||||||
self.logger.debug("gateway log cleared")
|
self.logger.debug("gateway log cleared")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,7 +378,7 @@ final class GatewayProcessManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private nonisolated static func readGatewayLog(path: String, limit: Int) -> String {
|
private nonisolated static func readGatewayLog(path: String, limit: Int) -> String {
|
||||||
guard FileManager.default.fileExists(atPath: path) else { return "" }
|
guard FileManager().fileExists(atPath: path) else { return "" }
|
||||||
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return "" }
|
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return "" }
|
||||||
let text = String(data: data, encoding: .utf8) ?? ""
|
let text = String(data: data, encoding: .utf8) ?? ""
|
||||||
if text.count <= limit { return text }
|
if text.count <= limit { return text }
|
||||||
|
|||||||
@@ -3,17 +3,17 @@ import Foundation
|
|||||||
enum LaunchAgentManager {
|
enum LaunchAgentManager {
|
||||||
private static let legacyLaunchdLabel = "com.steipete.clawdbot"
|
private static let legacyLaunchdLabel = "com.steipete.clawdbot"
|
||||||
private static var plistURL: URL {
|
private static var plistURL: URL {
|
||||||
FileManager.default.homeDirectoryForCurrentUser
|
FileManager().homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent("Library/LaunchAgents/com.clawdbot.mac.plist")
|
.appendingPathComponent("Library/LaunchAgents/com.clawdbot.mac.plist")
|
||||||
}
|
}
|
||||||
|
|
||||||
private static var legacyPlistURL: URL {
|
private static var legacyPlistURL: URL {
|
||||||
FileManager.default.homeDirectoryForCurrentUser
|
FileManager().homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent("Library/LaunchAgents/\(legacyLaunchdLabel).plist")
|
.appendingPathComponent("Library/LaunchAgents/\(legacyLaunchdLabel).plist")
|
||||||
}
|
}
|
||||||
|
|
||||||
static func status() async -> Bool {
|
static func status() async -> Bool {
|
||||||
guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false }
|
guard FileManager().fileExists(atPath: self.plistURL.path) else { return false }
|
||||||
let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(launchdLabel)"])
|
let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(launchdLabel)"])
|
||||||
return result == 0
|
return result == 0
|
||||||
}
|
}
|
||||||
@@ -21,7 +21,7 @@ enum LaunchAgentManager {
|
|||||||
static func set(enabled: Bool, bundlePath: String) async {
|
static func set(enabled: Bool, bundlePath: String) async {
|
||||||
if enabled {
|
if enabled {
|
||||||
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(self.legacyLaunchdLabel)"])
|
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(self.legacyLaunchdLabel)"])
|
||||||
try? FileManager.default.removeItem(at: self.legacyPlistURL)
|
try? FileManager().removeItem(at: self.legacyPlistURL)
|
||||||
self.writePlist(bundlePath: bundlePath)
|
self.writePlist(bundlePath: bundlePath)
|
||||||
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"])
|
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"])
|
||||||
_ = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path])
|
_ = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path])
|
||||||
@@ -29,7 +29,7 @@ enum LaunchAgentManager {
|
|||||||
} else {
|
} else {
|
||||||
// Disable autostart going forward but leave the current app running.
|
// Disable autostart going forward but leave the current app running.
|
||||||
// bootout would terminate the launchd job immediately (and crash the app if launched via agent).
|
// bootout would terminate the launchd job immediately (and crash the app if launched via agent).
|
||||||
try? FileManager.default.removeItem(at: self.plistURL)
|
try? FileManager().removeItem(at: self.plistURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ enum LaunchAgentManager {
|
|||||||
<string>\(bundlePath)/Contents/MacOS/Clawdbot</string>
|
<string>\(bundlePath)/Contents/MacOS/Clawdbot</string>
|
||||||
</array>
|
</array>
|
||||||
<key>WorkingDirectory</key>
|
<key>WorkingDirectory</key>
|
||||||
<string>\(FileManager.default.homeDirectoryForCurrentUser.path)</string>
|
<string>\(FileManager().homeDirectoryForCurrentUser.path)</string>
|
||||||
<key>RunAtLoad</key>
|
<key>RunAtLoad</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>KeepAlive</key>
|
<key>KeepAlive</key>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ enum LogLocator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func ensureLogDirExists() {
|
private static func ensureLogDirExists() {
|
||||||
try? FileManager.default.createDirectory(at: self.logDir, withIntermediateDirectories: true)
|
try? FileManager().createDirectory(at: self.logDir, withIntermediateDirectories: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func modificationDate(for url: URL) -> Date {
|
private static func modificationDate(for url: URL) -> Date {
|
||||||
@@ -28,7 +28,7 @@ enum LogLocator {
|
|||||||
/// Returns the newest log file under /tmp/clawdbot/ (rolling or stdout), or nil if none exist.
|
/// Returns the newest log file under /tmp/clawdbot/ (rolling or stdout), or nil if none exist.
|
||||||
static func bestLogFile() -> URL? {
|
static func bestLogFile() -> URL? {
|
||||||
self.ensureLogDirExists()
|
self.ensureLogDirExists()
|
||||||
let fm = FileManager.default
|
let fm = FileManager()
|
||||||
let files = (try? fm.contentsOfDirectory(
|
let files = (try? fm.contentsOfDirectory(
|
||||||
at: self.logDir,
|
at: self.logDir,
|
||||||
includingPropertiesForKeys: [.contentModificationDateKey],
|
includingPropertiesForKeys: [.contentModificationDateKey],
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Foundation
|
|||||||
import JavaScriptCore
|
import JavaScriptCore
|
||||||
|
|
||||||
enum ModelCatalogLoader {
|
enum ModelCatalogLoader {
|
||||||
static let defaultPath: String = FileManager.default.homeDirectoryForCurrentUser
|
static let defaultPath: String = FileManager().homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent("Projects/pi-mono/packages/ai/src/models.generated.ts").path
|
.appendingPathComponent("Projects/pi-mono/packages/ai/src/models.generated.ts").path
|
||||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "models")
|
private static let logger = Logger(subsystem: "com.clawdbot", category: "models")
|
||||||
|
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ actor MacNodeRuntime {
|
|||||||
|
|
||||||
let sessionKey = self.mainSessionKey
|
let sessionKey = self.mainSessionKey
|
||||||
let path = try await CanvasManager.shared.snapshot(sessionKey: sessionKey, outPath: nil)
|
let path = try await CanvasManager.shared.snapshot(sessionKey: sessionKey, outPath: nil)
|
||||||
defer { try? FileManager.default.removeItem(atPath: path) }
|
defer { try? FileManager().removeItem(atPath: path) }
|
||||||
let data = try Data(contentsOf: URL(fileURLWithPath: path))
|
let data = try Data(contentsOf: URL(fileURLWithPath: path))
|
||||||
guard let image = NSImage(data: data) else {
|
guard let image = NSImage(data: data) else {
|
||||||
return Self.errorResponse(req, code: .unavailable, message: "canvas snapshot decode failed")
|
return Self.errorResponse(req, code: .unavailable, message: "canvas snapshot decode failed")
|
||||||
@@ -206,7 +206,7 @@ actor MacNodeRuntime {
|
|||||||
includeAudio: params.includeAudio ?? true,
|
includeAudio: params.includeAudio ?? true,
|
||||||
deviceId: params.deviceId,
|
deviceId: params.deviceId,
|
||||||
outPath: nil)
|
outPath: nil)
|
||||||
defer { try? FileManager.default.removeItem(atPath: res.path) }
|
defer { try? FileManager().removeItem(atPath: res.path) }
|
||||||
let data = try Data(contentsOf: URL(fileURLWithPath: res.path))
|
let data = try Data(contentsOf: URL(fileURLWithPath: res.path))
|
||||||
struct ClipPayload: Encodable {
|
struct ClipPayload: Encodable {
|
||||||
var format: String
|
var format: String
|
||||||
@@ -312,7 +312,7 @@ actor MacNodeRuntime {
|
|||||||
fps: params.fps,
|
fps: params.fps,
|
||||||
includeAudio: params.includeAudio,
|
includeAudio: params.includeAudio,
|
||||||
outPath: nil)
|
outPath: nil)
|
||||||
defer { try? FileManager.default.removeItem(atPath: res.path) }
|
defer { try? FileManager().removeItem(atPath: res.path) }
|
||||||
let data = try Data(contentsOf: URL(fileURLWithPath: res.path))
|
let data = try Data(contentsOf: URL(fileURLWithPath: res.path))
|
||||||
struct ScreenPayload: Encodable {
|
struct ScreenPayload: Encodable {
|
||||||
var format: String
|
var format: String
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ final class OnboardingWizardModel {
|
|||||||
struct OnboardingWizardStepView: View {
|
struct OnboardingWizardStepView: View {
|
||||||
let step: WizardStep
|
let step: WizardStep
|
||||||
let isSubmitting: Bool
|
let isSubmitting: Bool
|
||||||
let onSubmit: (AnyCodable?) -> Void
|
let onStepSubmit: (AnyCodable?) -> Void
|
||||||
|
|
||||||
@State private var textValue: String
|
@State private var textValue: String
|
||||||
@State private var confirmValue: Bool
|
@State private var confirmValue: Bool
|
||||||
@@ -229,7 +229,7 @@ struct OnboardingWizardStepView: View {
|
|||||||
init(step: WizardStep, isSubmitting: Bool, onSubmit: @escaping (AnyCodable?) -> Void) {
|
init(step: WizardStep, isSubmitting: Bool, onSubmit: @escaping (AnyCodable?) -> Void) {
|
||||||
self.step = step
|
self.step = step
|
||||||
self.isSubmitting = isSubmitting
|
self.isSubmitting = isSubmitting
|
||||||
self.onSubmit = onSubmit
|
self.onStepSubmit = onSubmit
|
||||||
let options = parseWizardOptions(step.options).enumerated().map { index, option in
|
let options = parseWizardOptions(step.options).enumerated().map { index, option in
|
||||||
WizardOptionItem(index: index, option: option)
|
WizardOptionItem(index: index, option: option)
|
||||||
}
|
}
|
||||||
@@ -379,27 +379,27 @@ struct OnboardingWizardStepView: View {
|
|||||||
private func submit() {
|
private func submit() {
|
||||||
switch wizardStepType(self.step) {
|
switch wizardStepType(self.step) {
|
||||||
case "note", "progress":
|
case "note", "progress":
|
||||||
self.onSubmit(nil)
|
self.onStepSubmit(nil)
|
||||||
case "text":
|
case "text":
|
||||||
self.onSubmit(AnyCodable(self.textValue))
|
self.onStepSubmit(AnyCodable(self.textValue))
|
||||||
case "confirm":
|
case "confirm":
|
||||||
self.onSubmit(AnyCodable(self.confirmValue))
|
self.onStepSubmit(AnyCodable(self.confirmValue))
|
||||||
case "select":
|
case "select":
|
||||||
guard self.optionItems.indices.contains(self.selectedIndex) else {
|
guard self.optionItems.indices.contains(self.selectedIndex) else {
|
||||||
self.onSubmit(nil)
|
self.onStepSubmit(nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let option = self.optionItems[self.selectedIndex].option
|
let option = self.optionItems[self.selectedIndex].option
|
||||||
self.onSubmit(bridgeToLocal(option.value) ?? AnyCodable(option.label))
|
self.onStepSubmit(bridgeToLocal(option.value) ?? AnyCodable(option.label))
|
||||||
case "multiselect":
|
case "multiselect":
|
||||||
let values = self.optionItems
|
let values = self.optionItems
|
||||||
.filter { self.selectedIndices.contains($0.index) }
|
.filter { self.selectedIndices.contains($0.index) }
|
||||||
.map { bridgeToLocal($0.option.value) ?? AnyCodable($0.option.label) }
|
.map { bridgeToLocal($0.option.value) ?? AnyCodable($0.option.label) }
|
||||||
self.onSubmit(AnyCodable(values))
|
self.onStepSubmit(AnyCodable(values))
|
||||||
case "action":
|
case "action":
|
||||||
self.onSubmit(AnyCodable(true))
|
self.onStepSubmit(AnyCodable(true))
|
||||||
default:
|
default:
|
||||||
self.onSubmit(nil)
|
self.onStepSubmit(nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ actor PortGuardian {
|
|||||||
private var records: [Record] = []
|
private var records: [Record] = []
|
||||||
private let logger = Logger(subsystem: "com.clawdbot", category: "portguard")
|
private let logger = Logger(subsystem: "com.clawdbot", category: "portguard")
|
||||||
private nonisolated static let appSupportDir: URL = {
|
private nonisolated static let appSupportDir: URL = {
|
||||||
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||||
return base.appendingPathComponent("Clawdbot", isDirectory: true)
|
return base.appendingPathComponent("Clawdbot", isDirectory: true)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ actor PortGuardian {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func record(port: Int, pid: Int32, command: String, mode: AppState.ConnectionMode) async {
|
func record(port: Int, pid: Int32, command: String, mode: AppState.ConnectionMode) async {
|
||||||
try? FileManager.default.createDirectory(at: Self.appSupportDir, withIntermediateDirectories: true)
|
try? FileManager().createDirectory(at: Self.appSupportDir, withIntermediateDirectories: true)
|
||||||
self.records.removeAll { $0.pid == pid }
|
self.records.removeAll { $0.pid == pid }
|
||||||
self.records.append(
|
self.records.append(
|
||||||
Record(
|
Record(
|
||||||
|
|||||||
@@ -15,9 +15,9 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2026.1.11-4</string>
|
<string>2026.1.20</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>202601113</string>
|
<string>202601200</string>
|
||||||
<key>CFBundleIconFile</key>
|
<key>CFBundleIconFile</key>
|
||||||
<string>Clawdbot</string>
|
<string>Clawdbot</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ enum RuntimeLocator {
|
|||||||
// MARK: - Internals
|
// MARK: - Internals
|
||||||
|
|
||||||
private static func findExecutable(named name: String, searchPaths: [String]) -> String? {
|
private static func findExecutable(named name: String, searchPaths: [String]) -> String? {
|
||||||
let fm = FileManager.default
|
let fm = FileManager()
|
||||||
for dir in searchPaths {
|
for dir in searchPaths {
|
||||||
let candidate = (dir as NSString).appendingPathComponent(name)
|
let candidate = (dir as NSString).appendingPathComponent(name)
|
||||||
if fm.isExecutableFile(atPath: candidate) {
|
if fm.isExecutableFile(atPath: candidate) {
|
||||||
|
|||||||
@@ -42,10 +42,10 @@ final class ScreenRecordService {
|
|||||||
if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
return URL(fileURLWithPath: outPath)
|
return URL(fileURLWithPath: outPath)
|
||||||
}
|
}
|
||||||
return FileManager.default.temporaryDirectory
|
return FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-screen-record-\(UUID().uuidString).mp4")
|
.appendingPathComponent("clawdbot-screen-record-\(UUID().uuidString).mp4")
|
||||||
}()
|
}()
|
||||||
try? FileManager.default.removeItem(at: outURL)
|
try? FileManager().removeItem(at: outURL)
|
||||||
|
|
||||||
let content = try await SCShareableContent.current
|
let content = try await SCShareableContent.current
|
||||||
let displays = content.displays.sorted { $0.displayID < $1.displayID }
|
let displays = content.displays.sorted { $0.displayID < $1.displayID }
|
||||||
|
|||||||
@@ -66,12 +66,12 @@ enum SessionActions {
|
|||||||
let dir = URL(fileURLWithPath: storePath).deletingLastPathComponent()
|
let dir = URL(fileURLWithPath: storePath).deletingLastPathComponent()
|
||||||
urls.append(dir.appendingPathComponent("\(sessionId).jsonl"))
|
urls.append(dir.appendingPathComponent("\(sessionId).jsonl"))
|
||||||
}
|
}
|
||||||
let home = FileManager.default.homeDirectoryForCurrentUser
|
let home = FileManager().homeDirectoryForCurrentUser
|
||||||
urls.append(home.appendingPathComponent(".clawdbot/sessions/\(sessionId).jsonl"))
|
urls.append(home.appendingPathComponent(".clawdbot/sessions/\(sessionId).jsonl"))
|
||||||
return urls
|
return urls
|
||||||
}()
|
}()
|
||||||
|
|
||||||
let existing = candidates.first(where: { FileManager.default.fileExists(atPath: $0.path) })
|
let existing = candidates.first(where: { FileManager().fileExists(atPath: $0.path) })
|
||||||
guard let url = existing else {
|
guard let url = existing else {
|
||||||
let alert = NSAlert()
|
let alert = NSAlert()
|
||||||
alert.messageText = "Session log not found"
|
alert.messageText = "Session log not found"
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ enum SessionLoader {
|
|||||||
static let fallbackContextTokens = 200_000
|
static let fallbackContextTokens = 200_000
|
||||||
|
|
||||||
static let defaultStorePath = standardize(
|
static let defaultStorePath = standardize(
|
||||||
FileManager.default.homeDirectoryForCurrentUser
|
FileManager().homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent(".clawdbot/sessions/sessions.json").path)
|
.appendingPathComponent(".clawdbot/sessions/sessions.json").path)
|
||||||
|
|
||||||
static func loadSnapshot(
|
static func loadSnapshot(
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ enum SoundEffectCatalog {
|
|||||||
]
|
]
|
||||||
|
|
||||||
private static let searchRoots: [URL] = [
|
private static let searchRoots: [URL] = [
|
||||||
FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Sounds"),
|
FileManager().homeDirectoryForCurrentUser.appendingPathComponent("Library/Sounds"),
|
||||||
URL(fileURLWithPath: "/Library/Sounds"),
|
URL(fileURLWithPath: "/Library/Sounds"),
|
||||||
URL(fileURLWithPath: "/System/Applications/Mail.app/Contents/Resources"), // Mail “swoosh”
|
URL(fileURLWithPath: "/System/Applications/Mail.app/Contents/Resources"), // Mail “swoosh”
|
||||||
URL(fileURLWithPath: "/System/Library/Sounds"),
|
URL(fileURLWithPath: "/System/Library/Sounds"),
|
||||||
@@ -53,7 +53,7 @@ enum SoundEffectCatalog {
|
|||||||
private static let discoveredSoundMap: [String: URL] = {
|
private static let discoveredSoundMap: [String: URL] = {
|
||||||
var map: [String: URL] = [:]
|
var map: [String: URL] = [:]
|
||||||
for root in Self.searchRoots {
|
for root in Self.searchRoots {
|
||||||
guard let contents = try? FileManager.default.contentsOfDirectory(
|
guard let contents = try? FileManager().contentsOfDirectory(
|
||||||
at: root,
|
at: root,
|
||||||
includingPropertiesForKeys: nil,
|
includingPropertiesForKeys: nil,
|
||||||
options: [.skipsHiddenFiles])
|
options: [.skipsHiddenFiles])
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ final class TailscaleService {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
func checkAppInstallation() -> Bool {
|
func checkAppInstallation() -> Bool {
|
||||||
let installed = FileManager.default.fileExists(atPath: "/Applications/Tailscale.app")
|
let installed = FileManager().fileExists(atPath: "/Applications/Tailscale.app")
|
||||||
self.logger.info("Tailscale app installed: \(installed)")
|
self.logger.info("Tailscale app installed: \(installed)")
|
||||||
return installed
|
return installed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ final class VoicePushToTalkHotkey: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func startMonitoring() {
|
private func startMonitoring() {
|
||||||
assert(Thread.isMainThread)
|
// assert(Thread.isMainThread) - Removed for Swift 6
|
||||||
guard self.globalMonitor == nil, self.localMonitor == nil else { return }
|
guard self.globalMonitor == nil, self.localMonitor == nil else { return }
|
||||||
// Listen-only global monitor; we rely on Input Monitoring permission to receive events.
|
// Listen-only global monitor; we rely on Input Monitoring permission to receive events.
|
||||||
self.globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
|
self.globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
|
||||||
@@ -55,7 +55,7 @@ final class VoicePushToTalkHotkey: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func stopMonitoring() {
|
private func stopMonitoring() {
|
||||||
assert(Thread.isMainThread)
|
// assert(Thread.isMainThread) - Removed for Swift 6
|
||||||
if let globalMonitor {
|
if let globalMonitor {
|
||||||
NSEvent.removeMonitor(globalMonitor)
|
NSEvent.removeMonitor(globalMonitor)
|
||||||
self.globalMonitor = nil
|
self.globalMonitor = nil
|
||||||
@@ -75,15 +75,11 @@ final class VoicePushToTalkHotkey: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func withMainThread(_ block: @escaping @Sendable () -> Void) {
|
private func withMainThread(_ block: @escaping @Sendable () -> Void) {
|
||||||
if Thread.isMainThread {
|
DispatchQueue.main.async(execute: block)
|
||||||
block()
|
|
||||||
} else {
|
|
||||||
DispatchQueue.main.async(execute: block)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateModifierState(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) {
|
private func updateModifierState(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) {
|
||||||
assert(Thread.isMainThread)
|
// assert(Thread.isMainThread) - Removed for Swift 6
|
||||||
// Right Option (keyCode 61) acts as a hold-to-talk modifier.
|
// Right Option (keyCode 61) acts as a hold-to-talk modifier.
|
||||||
if keyCode == 61 {
|
if keyCode == 61 {
|
||||||
self.optionDown = modifierFlags.contains(.option)
|
self.optionDown = modifierFlags.contains(.option)
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ enum WideAreaGatewayDiscovery {
|
|||||||
|
|
||||||
let domain = ClawdbotBonjour.wideAreaGatewayServiceDomain
|
let domain = ClawdbotBonjour.wideAreaGatewayServiceDomain
|
||||||
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
||||||
let probeName = "_clawdbot-gateway._tcp.\(domainTrimmed)"
|
let probeName = "_clawdbot-gw._tcp.\(domainTrimmed)"
|
||||||
guard let ptrLines = context.dig(
|
guard let ptrLines = context.dig(
|
||||||
["+short", "+time=1", "+tries=1", "@\(nameserver)", probeName, "PTR"],
|
["+short", "+time=1", "+tries=1", "@\(nameserver)", probeName, "PTR"],
|
||||||
min(defaultTimeoutSeconds, remaining()))?.split(whereSeparator: \.isNewline),
|
min(defaultTimeoutSeconds, remaining()))?.split(whereSeparator: \.isNewline),
|
||||||
@@ -66,7 +66,7 @@ enum WideAreaGatewayDiscovery {
|
|||||||
let ptr = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
let ptr = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if ptr.isEmpty { continue }
|
if ptr.isEmpty { continue }
|
||||||
let ptrName = ptr.hasSuffix(".") ? String(ptr.dropLast()) : ptr
|
let ptrName = ptr.hasSuffix(".") ? String(ptr.dropLast()) : ptr
|
||||||
let suffix = "._clawdbot-gateway._tcp.\(domainTrimmed)"
|
let suffix = "._clawdbot-gw._tcp.\(domainTrimmed)"
|
||||||
let rawInstanceName = ptrName.hasSuffix(suffix)
|
let rawInstanceName = ptrName.hasSuffix(suffix)
|
||||||
? String(ptrName.dropLast(suffix.count))
|
? String(ptrName.dropLast(suffix.count))
|
||||||
: ptrName
|
: ptrName
|
||||||
@@ -156,7 +156,7 @@ enum WideAreaGatewayDiscovery {
|
|||||||
{
|
{
|
||||||
let domain = ClawdbotBonjour.wideAreaGatewayServiceDomain
|
let domain = ClawdbotBonjour.wideAreaGatewayServiceDomain
|
||||||
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
||||||
let probeName = "_clawdbot-gateway._tcp.\(domainTrimmed)"
|
let probeName = "_clawdbot-gw._tcp.\(domainTrimmed)"
|
||||||
|
|
||||||
let ips = candidates
|
let ips = candidates
|
||||||
candidates.removeAll(keepingCapacity: true)
|
candidates.removeAll(keepingCapacity: true)
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
import ClawdbotDiscovery
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct DiscoveryOptions {
|
|
||||||
var timeoutMs: Int = 2000
|
|
||||||
var json: Bool = false
|
|
||||||
var includeLocal: Bool = false
|
|
||||||
var help: Bool = false
|
|
||||||
|
|
||||||
static func parse(_ args: [String]) -> DiscoveryOptions {
|
|
||||||
var opts = DiscoveryOptions()
|
|
||||||
var i = 0
|
|
||||||
while i < args.count {
|
|
||||||
let arg = args[i]
|
|
||||||
switch arg {
|
|
||||||
case "-h", "--help":
|
|
||||||
opts.help = true
|
|
||||||
case "--json":
|
|
||||||
opts.json = true
|
|
||||||
case "--include-local":
|
|
||||||
opts.includeLocal = true
|
|
||||||
case "--timeout":
|
|
||||||
let next = (i + 1 < args.count) ? args[i + 1] : nil
|
|
||||||
if let next, let parsed = Int(next.trimmingCharacters(in: .whitespacesAndNewlines)) {
|
|
||||||
opts.timeoutMs = max(100, parsed)
|
|
||||||
i += 1
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
i += 1
|
|
||||||
}
|
|
||||||
return opts
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct DiscoveryOutput: Encodable {
|
|
||||||
struct Gateway: Encodable {
|
|
||||||
var displayName: String
|
|
||||||
var lanHost: String?
|
|
||||||
var tailnetDns: String?
|
|
||||||
var sshPort: Int
|
|
||||||
var gatewayPort: Int?
|
|
||||||
var cliPath: String?
|
|
||||||
var stableID: String
|
|
||||||
var debugID: String
|
|
||||||
var isLocal: Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
var status: String
|
|
||||||
var timeoutMs: Int
|
|
||||||
var includeLocal: Bool
|
|
||||||
var count: Int
|
|
||||||
var gateways: [Gateway]
|
|
||||||
}
|
|
||||||
|
|
||||||
@main
|
|
||||||
struct ClawdbotDiscoveryCLI {
|
|
||||||
static func main() async {
|
|
||||||
let opts = DiscoveryOptions.parse(Array(CommandLine.arguments.dropFirst()))
|
|
||||||
if opts.help {
|
|
||||||
print("""
|
|
||||||
clawdbot-mac-discovery
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
clawdbot-mac-discovery [--timeout <ms>] [--json] [--include-local]
|
|
||||||
|
|
||||||
Options:
|
|
||||||
--timeout <ms> Discovery window in milliseconds (default: 2000)
|
|
||||||
--json Emit JSON
|
|
||||||
--include-local Include gateways considered local
|
|
||||||
-h, --help Show help
|
|
||||||
""")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let displayName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
|
|
||||||
let model = GatewayDiscoveryModel(
|
|
||||||
localDisplayName: displayName,
|
|
||||||
filterLocalGateways: !opts.includeLocal)
|
|
||||||
|
|
||||||
await MainActor.run {
|
|
||||||
model.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
let nanos = UInt64(max(100, opts.timeoutMs)) * 1_000_000
|
|
||||||
try? await Task.sleep(nanoseconds: nanos)
|
|
||||||
|
|
||||||
let gateways = await MainActor.run { model.gateways }
|
|
||||||
let status = await MainActor.run { model.statusText }
|
|
||||||
|
|
||||||
await MainActor.run {
|
|
||||||
model.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
if opts.json {
|
|
||||||
let payload = DiscoveryOutput(
|
|
||||||
status: status,
|
|
||||||
timeoutMs: opts.timeoutMs,
|
|
||||||
includeLocal: opts.includeLocal,
|
|
||||||
count: gateways.count,
|
|
||||||
gateways: gateways.map {
|
|
||||||
DiscoveryOutput.Gateway(
|
|
||||||
displayName: $0.displayName,
|
|
||||||
lanHost: $0.lanHost,
|
|
||||||
tailnetDns: $0.tailnetDns,
|
|
||||||
sshPort: $0.sshPort,
|
|
||||||
gatewayPort: $0.gatewayPort,
|
|
||||||
cliPath: $0.cliPath,
|
|
||||||
stableID: $0.stableID,
|
|
||||||
debugID: $0.debugID,
|
|
||||||
isLocal: $0.isLocal)
|
|
||||||
})
|
|
||||||
let encoder = JSONEncoder()
|
|
||||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
|
||||||
if let data = try? encoder.encode(payload),
|
|
||||||
let json = String(data: data, encoding: .utf8)
|
|
||||||
{
|
|
||||||
print(json)
|
|
||||||
} else {
|
|
||||||
print("{\"error\":\"failed to encode JSON\"}")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
print("Gateway Discovery (macOS NWBrowser)")
|
|
||||||
print("Status: \(status)")
|
|
||||||
print("Found \(gateways.count) gateway(s)\(opts.includeLocal ? "" : " (local filtered)")")
|
|
||||||
if gateways.isEmpty { return }
|
|
||||||
|
|
||||||
for gateway in gateways {
|
|
||||||
let hosts = [gateway.tailnetDns, gateway.lanHost]
|
|
||||||
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
||||||
.filter { !$0.isEmpty }
|
|
||||||
.joined(separator: ", ")
|
|
||||||
print("- \(gateway.displayName)")
|
|
||||||
print(" hosts: \(hosts.isEmpty ? "(none)" : hosts)")
|
|
||||||
print(" ssh: \(gateway.sshPort)")
|
|
||||||
if let port = gateway.gatewayPort {
|
|
||||||
print(" gatewayPort: \(port)")
|
|
||||||
}
|
|
||||||
if let cliPath = gateway.cliPath {
|
|
||||||
print(" cliPath: \(cliPath)")
|
|
||||||
}
|
|
||||||
print(" isLocal: \(gateway.isLocal)")
|
|
||||||
print(" stableID: \(gateway.stableID)")
|
|
||||||
print(" debugID: \(gateway.debugID)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -408,8 +408,7 @@ extension Request: Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Shared transport settings
|
// Shared transport settings
|
||||||
public let controlSocketPath =
|
public let controlSocketPath = FileManager()
|
||||||
FileManager.default
|
.homeDirectoryForCurrentUser
|
||||||
.homeDirectoryForCurrentUser
|
.appendingPathComponent("Library/Application Support/clawdbot/control.sock")
|
||||||
.appendingPathComponent("Library/Application Support/clawdbot/control.sock")
|
.path
|
||||||
.path
|
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
import ClawdbotKit
|
||||||
|
import ClawdbotProtocol
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct ConnectOptions {
|
||||||
|
var url: String?
|
||||||
|
var token: String?
|
||||||
|
var password: String?
|
||||||
|
var mode: String?
|
||||||
|
var timeoutMs: Int = 15_000
|
||||||
|
var json: Bool = false
|
||||||
|
var probe: Bool = false
|
||||||
|
var clientId: String = "clawdbot-macos"
|
||||||
|
var clientMode: String = "ui"
|
||||||
|
var displayName: String?
|
||||||
|
var role: String = "operator"
|
||||||
|
var scopes: [String] = ["operator.admin", "operator.approvals", "operator.pairing"]
|
||||||
|
var help: Bool = false
|
||||||
|
|
||||||
|
static func parse(_ args: [String]) -> ConnectOptions {
|
||||||
|
var opts = ConnectOptions()
|
||||||
|
var i = 0
|
||||||
|
while i < args.count {
|
||||||
|
let arg = args[i]
|
||||||
|
switch arg {
|
||||||
|
case "-h", "--help":
|
||||||
|
opts.help = true
|
||||||
|
case "--json":
|
||||||
|
opts.json = true
|
||||||
|
case "--probe":
|
||||||
|
opts.probe = true
|
||||||
|
case "--url":
|
||||||
|
opts.url = self.nextValue(args, index: &i)
|
||||||
|
case "--token":
|
||||||
|
opts.token = self.nextValue(args, index: &i)
|
||||||
|
case "--password":
|
||||||
|
opts.password = self.nextValue(args, index: &i)
|
||||||
|
case "--mode":
|
||||||
|
if let value = self.nextValue(args, index: &i) {
|
||||||
|
opts.mode = value
|
||||||
|
}
|
||||||
|
case "--timeout":
|
||||||
|
if let raw = self.nextValue(args, index: &i),
|
||||||
|
let parsed = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||||
|
{
|
||||||
|
opts.timeoutMs = max(250, parsed)
|
||||||
|
}
|
||||||
|
case "--client-id":
|
||||||
|
if let value = self.nextValue(args, index: &i) {
|
||||||
|
opts.clientId = value
|
||||||
|
}
|
||||||
|
case "--client-mode":
|
||||||
|
if let value = self.nextValue(args, index: &i) {
|
||||||
|
opts.clientMode = value
|
||||||
|
}
|
||||||
|
case "--display-name":
|
||||||
|
opts.displayName = self.nextValue(args, index: &i)
|
||||||
|
case "--role":
|
||||||
|
if let value = self.nextValue(args, index: &i) {
|
||||||
|
opts.role = value
|
||||||
|
}
|
||||||
|
case "--scopes":
|
||||||
|
if let value = self.nextValue(args, index: &i) {
|
||||||
|
opts.scopes = value.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func nextValue(_ args: [String], index: inout Int) -> String? {
|
||||||
|
guard index + 1 < args.count else { return nil }
|
||||||
|
index += 1
|
||||||
|
return args[index].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ConnectOutput: Encodable {
|
||||||
|
var status: String
|
||||||
|
var url: String
|
||||||
|
var mode: String
|
||||||
|
var role: String
|
||||||
|
var clientId: String
|
||||||
|
var clientMode: String
|
||||||
|
var scopes: [String]
|
||||||
|
var snapshot: HelloOk?
|
||||||
|
var health: ProtoAnyCodable?
|
||||||
|
var error: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
actor SnapshotStore {
|
||||||
|
private var value: HelloOk?
|
||||||
|
|
||||||
|
func set(_ snapshot: HelloOk) {
|
||||||
|
self.value = snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
func get() -> HelloOk? {
|
||||||
|
self.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runConnect(_ args: [String]) async {
|
||||||
|
let opts = ConnectOptions.parse(args)
|
||||||
|
if opts.help {
|
||||||
|
print("""
|
||||||
|
clawdbot-mac connect
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
clawdbot-mac connect [--url <ws://host:port>] [--token <token>] [--password <password>]
|
||||||
|
[--mode <local|remote>] [--timeout <ms>] [--probe] [--json]
|
||||||
|
[--client-id <id>] [--client-mode <mode>] [--display-name <name>]
|
||||||
|
[--role <role>] [--scopes <a,b,c>]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--url <url> Gateway WebSocket URL (overrides config)
|
||||||
|
--token <token> Gateway token (if required)
|
||||||
|
--password <pw> Gateway password (if required)
|
||||||
|
--mode <mode> Resolve from config: local|remote (default: config or local)
|
||||||
|
--timeout <ms> Request timeout (default: 15000)
|
||||||
|
--probe Force a fresh health probe
|
||||||
|
--json Emit JSON
|
||||||
|
--client-id <id> Override client id (default: clawdbot-macos)
|
||||||
|
--client-mode <m> Override client mode (default: ui)
|
||||||
|
--display-name <n> Override display name
|
||||||
|
--role <role> Override role (default: operator)
|
||||||
|
--scopes <a,b,c> Override scopes list
|
||||||
|
-h, --help Show help
|
||||||
|
""")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = loadGatewayConfig()
|
||||||
|
do {
|
||||||
|
let endpoint = try resolveGatewayEndpoint(opts: opts, config: config)
|
||||||
|
let displayName = opts.displayName ?? Host.current().localizedName ?? "Clawdbot macOS Debug CLI"
|
||||||
|
let connectOptions = GatewayConnectOptions(
|
||||||
|
role: opts.role,
|
||||||
|
scopes: opts.scopes,
|
||||||
|
caps: [],
|
||||||
|
commands: [],
|
||||||
|
permissions: [:],
|
||||||
|
clientId: opts.clientId,
|
||||||
|
clientMode: opts.clientMode,
|
||||||
|
clientDisplayName: displayName)
|
||||||
|
|
||||||
|
let snapshotStore = SnapshotStore()
|
||||||
|
let channel = GatewayChannelActor(
|
||||||
|
url: endpoint.url,
|
||||||
|
token: endpoint.token,
|
||||||
|
password: endpoint.password,
|
||||||
|
pushHandler: { push in
|
||||||
|
if case let .snapshot(ok) = push {
|
||||||
|
await snapshotStore.set(ok)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
connectOptions: connectOptions)
|
||||||
|
|
||||||
|
let params: [String: KitAnyCodable]? = opts.probe ? ["probe": KitAnyCodable(true)] : nil
|
||||||
|
let data = try await channel.request(
|
||||||
|
method: "health",
|
||||||
|
params: params,
|
||||||
|
timeoutMs: Double(opts.timeoutMs))
|
||||||
|
let health = try? JSONDecoder().decode(ProtoAnyCodable.self, from: data)
|
||||||
|
let snapshot = await snapshotStore.get()
|
||||||
|
await channel.shutdown()
|
||||||
|
|
||||||
|
let output = ConnectOutput(
|
||||||
|
status: "ok",
|
||||||
|
url: endpoint.url.absoluteString,
|
||||||
|
mode: endpoint.mode,
|
||||||
|
role: opts.role,
|
||||||
|
clientId: opts.clientId,
|
||||||
|
clientMode: opts.clientMode,
|
||||||
|
scopes: opts.scopes,
|
||||||
|
snapshot: snapshot,
|
||||||
|
health: health,
|
||||||
|
error: nil)
|
||||||
|
printConnectOutput(output, json: opts.json)
|
||||||
|
} catch {
|
||||||
|
let endpoint = bestEffortEndpoint(opts: opts, config: config)
|
||||||
|
let fallbackMode = (opts.mode ?? config.mode ?? "local").lowercased()
|
||||||
|
let output = ConnectOutput(
|
||||||
|
status: "error",
|
||||||
|
url: endpoint?.url.absoluteString ?? "unknown",
|
||||||
|
mode: endpoint?.mode ?? fallbackMode,
|
||||||
|
role: opts.role,
|
||||||
|
clientId: opts.clientId,
|
||||||
|
clientMode: opts.clientMode,
|
||||||
|
scopes: opts.scopes,
|
||||||
|
snapshot: nil,
|
||||||
|
health: nil,
|
||||||
|
error: error.localizedDescription)
|
||||||
|
printConnectOutput(output, json: opts.json)
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func printConnectOutput(_ output: ConnectOutput, json: Bool) {
|
||||||
|
if json {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
if let data = try? encoder.encode(output),
|
||||||
|
let text = String(data: data, encoding: .utf8)
|
||||||
|
{
|
||||||
|
print(text)
|
||||||
|
} else {
|
||||||
|
print("{\"error\":\"failed to encode JSON\"}")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
print("Clawdbot macOS Gateway Connect")
|
||||||
|
print("Status: \(output.status)")
|
||||||
|
print("URL: \(output.url)")
|
||||||
|
print("Mode: \(output.mode)")
|
||||||
|
print("Client: \(output.clientId) (\(output.clientMode))")
|
||||||
|
print("Role: \(output.role)")
|
||||||
|
print("Scopes: \(output.scopes.joined(separator: ", "))")
|
||||||
|
if let snapshot = output.snapshot {
|
||||||
|
print("Protocol: \(snapshot._protocol)")
|
||||||
|
if let version = snapshot.server["version"]?.value as? String {
|
||||||
|
print("Server: \(version)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let health = output.health,
|
||||||
|
let ok = (health.value as? [String: ProtoAnyCodable])?["ok"]?.value as? Bool
|
||||||
|
{
|
||||||
|
print("Health: \(ok ? "ok" : "error")")
|
||||||
|
} else if output.health != nil {
|
||||||
|
print("Health: received")
|
||||||
|
}
|
||||||
|
if let error = output.error {
|
||||||
|
print("Error: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig) throws -> GatewayEndpoint {
|
||||||
|
let resolvedMode = (opts.mode ?? config.mode ?? "local").lowercased()
|
||||||
|
if let raw = opts.url, !raw.isEmpty {
|
||||||
|
guard let url = URL(string: raw) else {
|
||||||
|
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"])
|
||||||
|
}
|
||||||
|
return GatewayEndpoint(
|
||||||
|
url: url,
|
||||||
|
token: resolvedToken(opts: opts, mode: resolvedMode, config: config),
|
||||||
|
password: resolvedPassword(opts: opts, mode: resolvedMode, config: config),
|
||||||
|
mode: resolvedMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resolvedMode == "remote" {
|
||||||
|
guard let raw = config.remoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!raw.isEmpty else {
|
||||||
|
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url is missing"])
|
||||||
|
}
|
||||||
|
guard let url = URL(string: raw) else {
|
||||||
|
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"])
|
||||||
|
}
|
||||||
|
return GatewayEndpoint(
|
||||||
|
url: url,
|
||||||
|
token: resolvedToken(opts: opts, mode: resolvedMode, config: config),
|
||||||
|
password: resolvedPassword(opts: opts, mode: resolvedMode, config: config),
|
||||||
|
mode: resolvedMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
let port = config.port ?? 18789
|
||||||
|
let host = "127.0.0.1"
|
||||||
|
guard let url = URL(string: "ws://\(host):\(port)") else {
|
||||||
|
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: ws://\(host):\(port)"])
|
||||||
|
}
|
||||||
|
return GatewayEndpoint(
|
||||||
|
url: url,
|
||||||
|
token: resolvedToken(opts: opts, mode: resolvedMode, config: config),
|
||||||
|
password: resolvedPassword(opts: opts, mode: resolvedMode, config: config),
|
||||||
|
mode: resolvedMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bestEffortEndpoint(opts: ConnectOptions, config: GatewayConfig) -> GatewayEndpoint? {
|
||||||
|
return try? resolveGatewayEndpoint(opts: opts, config: config)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolvedToken(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? {
|
||||||
|
if let token = opts.token, !token.isEmpty { return token }
|
||||||
|
if let token = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_TOKEN"], !token.isEmpty {
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
if mode == "remote" {
|
||||||
|
return config.remoteToken
|
||||||
|
}
|
||||||
|
return config.token
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolvedPassword(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? {
|
||||||
|
if let password = opts.password, !password.isEmpty { return password }
|
||||||
|
if let password = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_PASSWORD"], !password.isEmpty {
|
||||||
|
return password
|
||||||
|
}
|
||||||
|
if mode == "remote" {
|
||||||
|
return config.remotePassword
|
||||||
|
}
|
||||||
|
return config.password
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import ClawdbotDiscovery
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct DiscoveryOptions {
|
||||||
|
var timeoutMs: Int = 2000
|
||||||
|
var json: Bool = false
|
||||||
|
var includeLocal: Bool = false
|
||||||
|
var help: Bool = false
|
||||||
|
|
||||||
|
static func parse(_ args: [String]) -> DiscoveryOptions {
|
||||||
|
var opts = DiscoveryOptions()
|
||||||
|
var i = 0
|
||||||
|
while i < args.count {
|
||||||
|
let arg = args[i]
|
||||||
|
switch arg {
|
||||||
|
case "-h", "--help":
|
||||||
|
opts.help = true
|
||||||
|
case "--json":
|
||||||
|
opts.json = true
|
||||||
|
case "--include-local":
|
||||||
|
opts.includeLocal = true
|
||||||
|
case "--timeout":
|
||||||
|
let next = (i + 1 < args.count) ? args[i + 1] : nil
|
||||||
|
if let next, let parsed = Int(next.trimmingCharacters(in: .whitespacesAndNewlines)) {
|
||||||
|
opts.timeoutMs = max(100, parsed)
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DiscoveryOutput: Encodable {
|
||||||
|
struct Gateway: Encodable {
|
||||||
|
var displayName: String
|
||||||
|
var lanHost: String?
|
||||||
|
var tailnetDns: String?
|
||||||
|
var sshPort: Int
|
||||||
|
var gatewayPort: Int?
|
||||||
|
var cliPath: String?
|
||||||
|
var stableID: String
|
||||||
|
var debugID: String
|
||||||
|
var isLocal: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var status: String
|
||||||
|
var timeoutMs: Int
|
||||||
|
var includeLocal: Bool
|
||||||
|
var count: Int
|
||||||
|
var gateways: [Gateway]
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDiscover(_ args: [String]) async {
|
||||||
|
let opts = DiscoveryOptions.parse(args)
|
||||||
|
if opts.help {
|
||||||
|
print("""
|
||||||
|
clawdbot-mac discover
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
clawdbot-mac discover [--timeout <ms>] [--json] [--include-local]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--timeout <ms> Discovery window in milliseconds (default: 2000)
|
||||||
|
--json Emit JSON
|
||||||
|
--include-local Include gateways considered local
|
||||||
|
-h, --help Show help
|
||||||
|
""")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let displayName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
|
||||||
|
let model = await MainActor.run {
|
||||||
|
GatewayDiscoveryModel(
|
||||||
|
localDisplayName: displayName,
|
||||||
|
filterLocalGateways: !opts.includeLocal)
|
||||||
|
}
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
model.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
let nanos = UInt64(max(100, opts.timeoutMs)) * 1_000_000
|
||||||
|
try? await Task.sleep(nanoseconds: nanos)
|
||||||
|
|
||||||
|
let gateways = await MainActor.run { model.gateways }
|
||||||
|
let status = await MainActor.run { model.statusText }
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
model.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.json {
|
||||||
|
let payload = DiscoveryOutput(
|
||||||
|
status: status,
|
||||||
|
timeoutMs: opts.timeoutMs,
|
||||||
|
includeLocal: opts.includeLocal,
|
||||||
|
count: gateways.count,
|
||||||
|
gateways: gateways.map {
|
||||||
|
DiscoveryOutput.Gateway(
|
||||||
|
displayName: $0.displayName,
|
||||||
|
lanHost: $0.lanHost,
|
||||||
|
tailnetDns: $0.tailnetDns,
|
||||||
|
sshPort: $0.sshPort,
|
||||||
|
gatewayPort: $0.gatewayPort,
|
||||||
|
cliPath: $0.cliPath,
|
||||||
|
stableID: $0.stableID,
|
||||||
|
debugID: $0.debugID,
|
||||||
|
isLocal: $0.isLocal)
|
||||||
|
})
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
if let data = try? encoder.encode(payload),
|
||||||
|
let json = String(data: data, encoding: .utf8)
|
||||||
|
{
|
||||||
|
print(json)
|
||||||
|
} else {
|
||||||
|
print("{\"error\":\"failed to encode JSON\"}")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
print("Gateway Discovery (macOS NWBrowser)")
|
||||||
|
print("Status: \(status)")
|
||||||
|
print("Found \(gateways.count) gateway(s)\(opts.includeLocal ? "" : " (local filtered)")")
|
||||||
|
if gateways.isEmpty { return }
|
||||||
|
|
||||||
|
for gateway in gateways {
|
||||||
|
let hosts = [gateway.tailnetDns, gateway.lanHost]
|
||||||
|
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
.joined(separator: ", ")
|
||||||
|
print("- \(gateway.displayName)")
|
||||||
|
print(" hosts: \(hosts.isEmpty ? "(none)" : hosts)")
|
||||||
|
print(" ssh: \(gateway.sshPort)")
|
||||||
|
if let port = gateway.gatewayPort {
|
||||||
|
print(" gatewayPort: \(port)")
|
||||||
|
}
|
||||||
|
if let cliPath = gateway.cliPath {
|
||||||
|
print(" cliPath: \(cliPath)")
|
||||||
|
}
|
||||||
|
print(" isLocal: \(gateway.isLocal)")
|
||||||
|
print(" stableID: \(gateway.stableID)")
|
||||||
|
print(" debugID: \(gateway.debugID)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
private struct RootCommand {
|
||||||
|
var name: String
|
||||||
|
var args: [String]
|
||||||
|
}
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct ClawdbotMacCLI {
|
||||||
|
static func main() async {
|
||||||
|
let args = Array(CommandLine.arguments.dropFirst())
|
||||||
|
let command = parseRootCommand(args)
|
||||||
|
switch command?.name {
|
||||||
|
case nil:
|
||||||
|
printUsage()
|
||||||
|
case "-h", "--help", "help":
|
||||||
|
printUsage()
|
||||||
|
case "connect":
|
||||||
|
await runConnect(command?.args ?? [])
|
||||||
|
case "discover":
|
||||||
|
await runDiscover(command?.args ?? [])
|
||||||
|
case "wizard":
|
||||||
|
await runWizardCommand(command?.args ?? [])
|
||||||
|
default:
|
||||||
|
fputs("clawdbot-mac: unknown command\n", stderr)
|
||||||
|
printUsage()
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseRootCommand(_ args: [String]) -> RootCommand? {
|
||||||
|
guard let first = args.first else { return nil }
|
||||||
|
return RootCommand(name: first, args: Array(args.dropFirst()))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func printUsage() {
|
||||||
|
print("""
|
||||||
|
clawdbot-mac
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
clawdbot-mac connect [--url <ws://host:port>] [--token <token>] [--password <password>]
|
||||||
|
[--mode <local|remote>] [--timeout <ms>] [--probe] [--json]
|
||||||
|
[--client-id <id>] [--client-mode <mode>] [--display-name <name>]
|
||||||
|
[--role <role>] [--scopes <a,b,c>]
|
||||||
|
clawdbot-mac discover [--timeout <ms>] [--json] [--include-local]
|
||||||
|
clawdbot-mac wizard [--url <ws://host:port>] [--token <token>] [--password <password>]
|
||||||
|
[--mode <local|remote>] [--workspace <path>] [--json]
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
clawdbot-mac connect
|
||||||
|
clawdbot-mac connect --url ws://127.0.0.1:18789 --json
|
||||||
|
clawdbot-mac discover --timeout 3000 --json
|
||||||
|
clawdbot-mac wizard --mode local
|
||||||
|
""")
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct GatewayConfig {
|
||||||
|
var mode: String?
|
||||||
|
var bind: String?
|
||||||
|
var port: Int?
|
||||||
|
var remoteUrl: String?
|
||||||
|
var token: String?
|
||||||
|
var password: String?
|
||||||
|
var remoteToken: String?
|
||||||
|
var remotePassword: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GatewayEndpoint {
|
||||||
|
let url: URL
|
||||||
|
let token: String?
|
||||||
|
let password: String?
|
||||||
|
let mode: String
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadGatewayConfig() -> GatewayConfig {
|
||||||
|
let url = FileManager().homeDirectoryForCurrentUser
|
||||||
|
.appendingPathComponent(".clawdbot")
|
||||||
|
.appendingPathComponent("clawdbot.json")
|
||||||
|
guard let data = try? Data(contentsOf: url) else { return GatewayConfig() }
|
||||||
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||||
|
return GatewayConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg = GatewayConfig()
|
||||||
|
if let gateway = json["gateway"] as? [String: Any] {
|
||||||
|
cfg.mode = gateway["mode"] as? String
|
||||||
|
cfg.bind = gateway["bind"] as? String
|
||||||
|
cfg.port = gateway["port"] as? Int ?? parseInt(gateway["port"])
|
||||||
|
|
||||||
|
if let auth = gateway["auth"] as? [String: Any] {
|
||||||
|
cfg.token = auth["token"] as? String
|
||||||
|
cfg.password = auth["password"] as? String
|
||||||
|
}
|
||||||
|
if let remote = gateway["remote"] as? [String: Any] {
|
||||||
|
cfg.remoteUrl = remote["url"] as? String
|
||||||
|
cfg.remoteToken = remote["token"] as? String
|
||||||
|
cfg.remotePassword = remote["password"] as? String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInt(_ value: Any?) -> Int? {
|
||||||
|
switch value {
|
||||||
|
case let number as Int:
|
||||||
|
number
|
||||||
|
case let number as Double:
|
||||||
|
Int(number)
|
||||||
|
case let raw as String:
|
||||||
|
Int(raw.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||||
|
default:
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import ClawdbotKit
|
||||||
|
import ClawdbotProtocol
|
||||||
|
|
||||||
|
typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
|
||||||
|
typealias KitAnyCodable = ClawdbotKit.AnyCodable
|
||||||
+153
-144
@@ -1,3 +1,4 @@
|
|||||||
|
import ClawdbotKit
|
||||||
import ClawdbotProtocol
|
import ClawdbotProtocol
|
||||||
import Darwin
|
import Darwin
|
||||||
import Foundation
|
import Foundation
|
||||||
@@ -48,17 +49,6 @@ struct WizardCliOptions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct GatewayConfig {
|
|
||||||
var mode: String?
|
|
||||||
var bind: String?
|
|
||||||
var port: Int?
|
|
||||||
var remoteUrl: String?
|
|
||||||
var token: String?
|
|
||||||
var password: String?
|
|
||||||
var remoteToken: String?
|
|
||||||
var remotePassword: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
enum WizardCliError: Error, CustomStringConvertible {
|
enum WizardCliError: Error, CustomStringConvertible {
|
||||||
case invalidUrl(String)
|
case invalidUrl(String)
|
||||||
case missingRemoteUrl
|
case missingRemoteUrl
|
||||||
@@ -77,68 +67,56 @@ enum WizardCliError: Error, CustomStringConvertible {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@main
|
func runWizardCommand(_ args: [String]) async {
|
||||||
struct ClawdbotWizardCLI {
|
let opts = WizardCliOptions.parse(args)
|
||||||
static func main() async {
|
if opts.help {
|
||||||
let opts = WizardCliOptions.parse(Array(CommandLine.arguments.dropFirst()))
|
print("""
|
||||||
if opts.help {
|
clawdbot-mac wizard
|
||||||
printUsage()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let config = loadGatewayConfig()
|
Usage:
|
||||||
do {
|
clawdbot-mac wizard [--url <ws://host:port>] [--token <token>] [--password <password>]
|
||||||
guard isatty(STDIN_FILENO) != 0 else {
|
[--mode <local|remote>] [--workspace <path>] [--json]
|
||||||
throw WizardCliError.gatewayError("Wizard requires an interactive TTY.")
|
|
||||||
}
|
Options:
|
||||||
let endpoint = try resolveGatewayEndpoint(opts: opts, config: config)
|
--url <url> Gateway WebSocket URL (overrides config)
|
||||||
let client = GatewayWizardClient(
|
--token <token> Gateway token (if required)
|
||||||
url: endpoint.url,
|
--password <pw> Gateway password (if required)
|
||||||
token: endpoint.token,
|
--mode <mode> Wizard mode (local|remote). Default: local
|
||||||
password: endpoint.password,
|
--workspace <path> Wizard workspace override
|
||||||
json: opts.json)
|
--json Print raw wizard responses
|
||||||
try await client.connect()
|
-h, --help Show help
|
||||||
defer { Task { await client.close() } }
|
""")
|
||||||
try await runWizard(client: client, opts: opts)
|
return
|
||||||
} catch {
|
}
|
||||||
fputs("wizard: \(error)\n", stderr)
|
|
||||||
exit(1)
|
let config = loadGatewayConfig()
|
||||||
|
do {
|
||||||
|
guard isatty(STDIN_FILENO) != 0 else {
|
||||||
|
throw WizardCliError.gatewayError("Wizard requires an interactive TTY.")
|
||||||
}
|
}
|
||||||
|
let endpoint = try resolveWizardGatewayEndpoint(opts: opts, config: config)
|
||||||
|
let client = GatewayWizardClient(
|
||||||
|
url: endpoint.url,
|
||||||
|
token: endpoint.token,
|
||||||
|
password: endpoint.password,
|
||||||
|
json: opts.json)
|
||||||
|
try await client.connect()
|
||||||
|
defer { Task { await client.close() } }
|
||||||
|
try await runWizard(client: client, opts: opts)
|
||||||
|
} catch {
|
||||||
|
fputs("wizard: \(error)\n", stderr)
|
||||||
|
exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct GatewayEndpoint {
|
private func resolveWizardGatewayEndpoint(opts: WizardCliOptions, config: GatewayConfig) throws -> GatewayEndpoint {
|
||||||
let url: URL
|
|
||||||
let token: String?
|
|
||||||
let password: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
private func printUsage() {
|
|
||||||
print("""
|
|
||||||
clawdbot-mac-wizard
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
clawdbot-mac-wizard [--url <ws://host:port>] [--token <token>] [--password <password>]
|
|
||||||
[--mode <local|remote>] [--workspace <path>] [--json]
|
|
||||||
|
|
||||||
Options:
|
|
||||||
--url <url> Gateway WebSocket URL (overrides config)
|
|
||||||
--token <token> Gateway token (if required)
|
|
||||||
--password <pw> Gateway password (if required)
|
|
||||||
--mode <mode> Wizard mode (local|remote). Default: local
|
|
||||||
--workspace <path> Wizard workspace override
|
|
||||||
--json Print raw wizard responses
|
|
||||||
-h, --help Show help
|
|
||||||
""")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resolveGatewayEndpoint(opts: WizardCliOptions, config: GatewayConfig) throws -> GatewayEndpoint {
|
|
||||||
if let raw = opts.url, !raw.isEmpty {
|
if let raw = opts.url, !raw.isEmpty {
|
||||||
guard let url = URL(string: raw) else { throw WizardCliError.invalidUrl(raw) }
|
guard let url = URL(string: raw) else { throw WizardCliError.invalidUrl(raw) }
|
||||||
return GatewayEndpoint(
|
return GatewayEndpoint(
|
||||||
url: url,
|
url: url,
|
||||||
token: resolvedToken(opts: opts, config: config),
|
token: resolvedToken(opts: opts, config: config),
|
||||||
password: resolvedPassword(opts: opts, config: config))
|
password: resolvedPassword(opts: opts, config: config),
|
||||||
|
mode: (config.mode ?? "local").lowercased())
|
||||||
}
|
}
|
||||||
|
|
||||||
let mode = (config.mode ?? "local").lowercased()
|
let mode = (config.mode ?? "local").lowercased()
|
||||||
@@ -150,7 +128,8 @@ private func resolveGatewayEndpoint(opts: WizardCliOptions, config: GatewayConfi
|
|||||||
return GatewayEndpoint(
|
return GatewayEndpoint(
|
||||||
url: url,
|
url: url,
|
||||||
token: resolvedToken(opts: opts, config: config),
|
token: resolvedToken(opts: opts, config: config),
|
||||||
password: resolvedPassword(opts: opts, config: config))
|
password: resolvedPassword(opts: opts, config: config),
|
||||||
|
mode: mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
let port = config.port ?? 18789
|
let port = config.port ?? 18789
|
||||||
@@ -161,7 +140,8 @@ private func resolveGatewayEndpoint(opts: WizardCliOptions, config: GatewayConfi
|
|||||||
return GatewayEndpoint(
|
return GatewayEndpoint(
|
||||||
url: url,
|
url: url,
|
||||||
token: resolvedToken(opts: opts, config: config),
|
token: resolvedToken(opts: opts, config: config),
|
||||||
password: resolvedPassword(opts: opts, config: config))
|
password: resolvedPassword(opts: opts, config: config),
|
||||||
|
mode: mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolvedToken(opts: WizardCliOptions, config: GatewayConfig) -> String? {
|
private func resolvedToken(opts: WizardCliOptions, config: GatewayConfig) -> String? {
|
||||||
@@ -186,48 +166,11 @@ private func resolvedPassword(opts: WizardCliOptions, config: GatewayConfig) ->
|
|||||||
return config.password
|
return config.password
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadGatewayConfig() -> GatewayConfig {
|
|
||||||
let url = FileManager.default.homeDirectoryForCurrentUser
|
|
||||||
.appendingPathComponent(".clawdbot")
|
|
||||||
.appendingPathComponent("clawdbot.json")
|
|
||||||
guard let data = try? Data(contentsOf: url) else { return GatewayConfig() }
|
|
||||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
||||||
return GatewayConfig()
|
|
||||||
}
|
|
||||||
|
|
||||||
var cfg = GatewayConfig()
|
|
||||||
if let gateway = json["gateway"] as? [String: Any] {
|
|
||||||
cfg.mode = gateway["mode"] as? String
|
|
||||||
cfg.bind = gateway["bind"] as? String
|
|
||||||
cfg.port = gateway["port"] as? Int ?? parseInt(gateway["port"])
|
|
||||||
|
|
||||||
if let auth = gateway["auth"] as? [String: Any] {
|
|
||||||
cfg.token = auth["token"] as? String
|
|
||||||
cfg.password = auth["password"] as? String
|
|
||||||
}
|
|
||||||
if let remote = gateway["remote"] as? [String: Any] {
|
|
||||||
cfg.remoteUrl = remote["url"] as? String
|
|
||||||
cfg.remoteToken = remote["token"] as? String
|
|
||||||
cfg.remotePassword = remote["password"] as? String
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
private func parseInt(_ value: Any?) -> Int? {
|
|
||||||
switch value {
|
|
||||||
case let number as Int:
|
|
||||||
number
|
|
||||||
case let number as Double:
|
|
||||||
Int(number)
|
|
||||||
case let raw as String:
|
|
||||||
Int(raw.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
||||||
default:
|
|
||||||
nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
actor GatewayWizardClient {
|
actor GatewayWizardClient {
|
||||||
|
private enum ConnectChallengeError: Error {
|
||||||
|
case timeout
|
||||||
|
}
|
||||||
|
|
||||||
private let url: URL
|
private let url: URL
|
||||||
private let token: String?
|
private let token: String?
|
||||||
private let password: String?
|
private let password: String?
|
||||||
@@ -235,6 +178,7 @@ actor GatewayWizardClient {
|
|||||||
private let encoder = JSONEncoder()
|
private let encoder = JSONEncoder()
|
||||||
private let decoder = JSONDecoder()
|
private let decoder = JSONDecoder()
|
||||||
private let session = URLSession(configuration: .default)
|
private let session = URLSession(configuration: .default)
|
||||||
|
private let connectChallengeTimeoutSeconds: Double = 0.75
|
||||||
private var task: URLSessionWebSocketTask?
|
private var task: URLSessionWebSocketTask?
|
||||||
|
|
||||||
init(url: URL, token: String?, password: String?, json: Bool) {
|
init(url: URL, token: String?, password: String?, json: Bool) {
|
||||||
@@ -257,7 +201,7 @@ actor GatewayWizardClient {
|
|||||||
self.task = nil
|
self.task = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func request(method: String, params: [String: AnyCodable]?) async throws -> ResponseFrame {
|
func request(method: String, params: [String: ProtoAnyCodable]?) async throws -> ResponseFrame {
|
||||||
guard let task = self.task else {
|
guard let task = self.task else {
|
||||||
throw WizardCliError.gatewayError("gateway not connected")
|
throw WizardCliError.gatewayError("gateway not connected")
|
||||||
}
|
}
|
||||||
@@ -266,7 +210,7 @@ actor GatewayWizardClient {
|
|||||||
type: "req",
|
type: "req",
|
||||||
id: id,
|
id: id,
|
||||||
method: method,
|
method: method,
|
||||||
params: params.map { AnyCodable($0) })
|
params: params.map { ProtoAnyCodable($0) })
|
||||||
let data = try self.encoder.encode(frame)
|
let data = try self.encoder.encode(frame)
|
||||||
try await task.send(.data(data))
|
try await task.send(.data(data))
|
||||||
|
|
||||||
@@ -309,28 +253,66 @@ actor GatewayWizardClient {
|
|||||||
}
|
}
|
||||||
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
|
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
|
||||||
let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
|
let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
|
||||||
let client: [String: AnyCodable] = [
|
let clientId = "clawdbot-macos"
|
||||||
"id": AnyCodable("clawdbot-macos"),
|
let clientMode = "ui"
|
||||||
"displayName": AnyCodable(Host.current().localizedName ?? "Clawdbot macOS Wizard CLI"),
|
let role = "operator"
|
||||||
"version": AnyCodable("dev"),
|
let scopes: [String] = []
|
||||||
"platform": AnyCodable(platform),
|
let client: [String: ProtoAnyCodable] = [
|
||||||
"deviceFamily": AnyCodable("Mac"),
|
"id": ProtoAnyCodable(clientId),
|
||||||
"mode": AnyCodable("ui"),
|
"displayName": ProtoAnyCodable(Host.current().localizedName ?? "Clawdbot macOS Wizard CLI"),
|
||||||
"instanceId": AnyCodable(UUID().uuidString),
|
"version": ProtoAnyCodable("dev"),
|
||||||
|
"platform": ProtoAnyCodable(platform),
|
||||||
|
"deviceFamily": ProtoAnyCodable("Mac"),
|
||||||
|
"mode": ProtoAnyCodable(clientMode),
|
||||||
|
"instanceId": ProtoAnyCodable(UUID().uuidString),
|
||||||
]
|
]
|
||||||
|
|
||||||
var params: [String: AnyCodable] = [
|
var params: [String: ProtoAnyCodable] = [
|
||||||
"minProtocol": AnyCodable(GATEWAY_PROTOCOL_VERSION),
|
"minProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
|
||||||
"maxProtocol": AnyCodable(GATEWAY_PROTOCOL_VERSION),
|
"maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
|
||||||
"client": AnyCodable(client),
|
"client": ProtoAnyCodable(client),
|
||||||
"caps": AnyCodable([String]()),
|
"caps": ProtoAnyCodable([String]()),
|
||||||
"locale": AnyCodable(Locale.preferredLanguages.first ?? Locale.current.identifier),
|
"locale": ProtoAnyCodable(Locale.preferredLanguages.first ?? Locale.current.identifier),
|
||||||
"userAgent": AnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
|
"userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
|
||||||
|
"role": ProtoAnyCodable(role),
|
||||||
|
"scopes": ProtoAnyCodable(scopes),
|
||||||
]
|
]
|
||||||
if let token = self.token {
|
if let token = self.token {
|
||||||
params["auth"] = AnyCodable(["token": AnyCodable(token)])
|
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)])
|
||||||
} else if let password = self.password {
|
} else if let password = self.password {
|
||||||
params["auth"] = AnyCodable(["password": AnyCodable(password)])
|
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
|
||||||
|
}
|
||||||
|
let connectNonce = try await self.waitForConnectChallenge()
|
||||||
|
let identity = DeviceIdentityStore.loadOrCreate()
|
||||||
|
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
|
||||||
|
let scopesValue = scopes.joined(separator: ",")
|
||||||
|
var payloadParts = [
|
||||||
|
connectNonce == nil ? "v1" : "v2",
|
||||||
|
identity.deviceId,
|
||||||
|
clientId,
|
||||||
|
clientMode,
|
||||||
|
role,
|
||||||
|
scopesValue,
|
||||||
|
String(signedAtMs),
|
||||||
|
self.token ?? "",
|
||||||
|
]
|
||||||
|
if let connectNonce {
|
||||||
|
payloadParts.append(connectNonce)
|
||||||
|
}
|
||||||
|
let payload = payloadParts.joined(separator: "|")
|
||||||
|
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
|
||||||
|
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity)
|
||||||
|
{
|
||||||
|
var device: [String: ProtoAnyCodable] = [
|
||||||
|
"id": ProtoAnyCodable(identity.deviceId),
|
||||||
|
"publicKey": ProtoAnyCodable(publicKey),
|
||||||
|
"signature": ProtoAnyCodable(signature),
|
||||||
|
"signedAt": ProtoAnyCodable(signedAtMs),
|
||||||
|
]
|
||||||
|
if let connectNonce {
|
||||||
|
device["nonce"] = ProtoAnyCodable(connectNonce)
|
||||||
|
}
|
||||||
|
params["device"] = ProtoAnyCodable(device)
|
||||||
}
|
}
|
||||||
|
|
||||||
let reqId = UUID().uuidString
|
let reqId = UUID().uuidString
|
||||||
@@ -338,31 +320,58 @@ actor GatewayWizardClient {
|
|||||||
type: "req",
|
type: "req",
|
||||||
id: reqId,
|
id: reqId,
|
||||||
method: "connect",
|
method: "connect",
|
||||||
params: AnyCodable(params))
|
params: ProtoAnyCodable(params))
|
||||||
let data = try self.encoder.encode(frame)
|
let data = try self.encoder.encode(frame)
|
||||||
try await task.send(.data(data))
|
try await task.send(.data(data))
|
||||||
|
|
||||||
let message = try await task.receive()
|
while true {
|
||||||
let frameResponse = try decodeFrame(message)
|
let message = try await task.receive()
|
||||||
guard case let .res(res) = frameResponse, res.id == reqId else {
|
let frameResponse = try decodeFrame(message)
|
||||||
throw WizardCliError.gatewayError("connect failed (unexpected response)")
|
if case let .res(res) = frameResponse, res.id == reqId {
|
||||||
|
if res.ok == false {
|
||||||
|
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
|
||||||
|
throw WizardCliError.gatewayError(msg)
|
||||||
|
}
|
||||||
|
_ = try self.decodePayload(res, as: HelloOk.self)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if res.ok == false {
|
}
|
||||||
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
|
|
||||||
throw WizardCliError.gatewayError(msg)
|
private func waitForConnectChallenge() async throws -> String? {
|
||||||
|
guard let task = self.task else { return nil }
|
||||||
|
do {
|
||||||
|
return try await AsyncTimeout.withTimeout(
|
||||||
|
seconds: self.connectChallengeTimeoutSeconds,
|
||||||
|
onTimeout: { ConnectChallengeError.timeout },
|
||||||
|
operation: {
|
||||||
|
while true {
|
||||||
|
let message = try await task.receive()
|
||||||
|
let frame = try await self.decodeFrame(message)
|
||||||
|
if case let .event(evt) = frame, evt.event == "connect.challenge" {
|
||||||
|
if let payload = evt.payload?.value as? [String: ProtoAnyCodable],
|
||||||
|
let nonce = payload["nonce"]?.value as? String
|
||||||
|
{
|
||||||
|
return nonce
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
if error is ConnectChallengeError { return nil }
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
_ = try self.decodePayload(res, as: HelloOk.self)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) async throws {
|
private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) async throws {
|
||||||
var params: [String: AnyCodable] = [:]
|
var params: [String: ProtoAnyCodable] = [:]
|
||||||
let mode = opts.mode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
let mode = opts.mode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
if mode == "local" || mode == "remote" {
|
if mode == "local" || mode == "remote" {
|
||||||
params["mode"] = AnyCodable(mode)
|
params["mode"] = ProtoAnyCodable(mode)
|
||||||
}
|
}
|
||||||
if let workspace = opts.workspace?.trimmingCharacters(in: .whitespacesAndNewlines), !workspace.isEmpty {
|
if let workspace = opts.workspace?.trimmingCharacters(in: .whitespacesAndNewlines), !workspace.isEmpty {
|
||||||
params["workspace"] = AnyCodable(workspace)
|
params["workspace"] = ProtoAnyCodable(workspace)
|
||||||
}
|
}
|
||||||
|
|
||||||
let startResponse = try await client.request(method: "wizard.start", params: params)
|
let startResponse = try await client.request(method: "wizard.start", params: params)
|
||||||
@@ -395,17 +404,17 @@ private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) asyn
|
|||||||
|
|
||||||
if let step = decodeWizardStep(nextResult.step) {
|
if let step = decodeWizardStep(nextResult.step) {
|
||||||
let answer = try promptAnswer(for: step)
|
let answer = try promptAnswer(for: step)
|
||||||
var answerPayload: [String: AnyCodable] = [
|
var answerPayload: [String: ProtoAnyCodable] = [
|
||||||
"stepId": AnyCodable(step.id),
|
"stepId": ProtoAnyCodable(step.id),
|
||||||
]
|
]
|
||||||
if !(answer is NSNull) {
|
if !(answer is NSNull) {
|
||||||
answerPayload["value"] = AnyCodable(answer)
|
answerPayload["value"] = ProtoAnyCodable(answer)
|
||||||
}
|
}
|
||||||
let response = try await client.request(
|
let response = try await client.request(
|
||||||
method: "wizard.next",
|
method: "wizard.next",
|
||||||
params: [
|
params: [
|
||||||
"sessionId": AnyCodable(sessionId),
|
"sessionId": ProtoAnyCodable(sessionId),
|
||||||
"answer": AnyCodable(answerPayload),
|
"answer": ProtoAnyCodable(answerPayload),
|
||||||
])
|
])
|
||||||
nextResult = try await client.decodePayload(response, as: WizardNextResult.self)
|
nextResult = try await client.decodePayload(response, as: WizardNextResult.self)
|
||||||
if opts.json {
|
if opts.json {
|
||||||
@@ -414,7 +423,7 @@ private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) asyn
|
|||||||
} else {
|
} else {
|
||||||
let response = try await client.request(
|
let response = try await client.request(
|
||||||
method: "wizard.next",
|
method: "wizard.next",
|
||||||
params: ["sessionId": AnyCodable(sessionId)])
|
params: ["sessionId": ProtoAnyCodable(sessionId)])
|
||||||
nextResult = try await client.decodePayload(response, as: WizardNextResult.self)
|
nextResult = try await client.decodePayload(response, as: WizardNextResult.self)
|
||||||
if opts.json {
|
if opts.json {
|
||||||
dumpResult(response)
|
dumpResult(response)
|
||||||
@@ -424,7 +433,7 @@ private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) asyn
|
|||||||
} catch WizardCliError.cancelled {
|
} catch WizardCliError.cancelled {
|
||||||
_ = try? await client.request(
|
_ = try? await client.request(
|
||||||
method: "wizard.cancel",
|
method: "wizard.cancel",
|
||||||
params: ["sessionId": AnyCodable(sessionId)])
|
params: ["sessionId": ProtoAnyCodable(sessionId)])
|
||||||
throw WizardCliError.cancelled
|
throw WizardCliError.cancelled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -6,7 +6,7 @@ import Testing
|
|||||||
struct AgentWorkspaceTests {
|
struct AgentWorkspaceTests {
|
||||||
@Test
|
@Test
|
||||||
func displayPathUsesTildeForHome() {
|
func displayPathUsesTildeForHome() {
|
||||||
let home = FileManager.default.homeDirectoryForCurrentUser
|
let home = FileManager().homeDirectoryForCurrentUser
|
||||||
#expect(AgentWorkspace.displayPath(for: home) == "~")
|
#expect(AgentWorkspace.displayPath(for: home) == "~")
|
||||||
|
|
||||||
let inside = home.appendingPathComponent("Projects", isDirectory: true)
|
let inside = home.appendingPathComponent("Projects", isDirectory: true)
|
||||||
@@ -28,12 +28,12 @@ struct AgentWorkspaceTests {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
func bootstrapCreatesAgentsFileWhenMissing() throws {
|
func bootstrapCreatesAgentsFileWhenMissing() throws {
|
||||||
let tmp = FileManager.default.temporaryDirectory
|
let tmp = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
|
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
|
||||||
defer { try? FileManager.default.removeItem(at: tmp) }
|
defer { try? FileManager().removeItem(at: tmp) }
|
||||||
|
|
||||||
let agentsURL = try AgentWorkspace.bootstrap(workspaceURL: tmp)
|
let agentsURL = try AgentWorkspace.bootstrap(workspaceURL: tmp)
|
||||||
#expect(FileManager.default.fileExists(atPath: agentsURL.path))
|
#expect(FileManager().fileExists(atPath: agentsURL.path))
|
||||||
|
|
||||||
let contents = try String(contentsOf: agentsURL, encoding: .utf8)
|
let contents = try String(contentsOf: agentsURL, encoding: .utf8)
|
||||||
#expect(contents.contains("# AGENTS.md"))
|
#expect(contents.contains("# AGENTS.md"))
|
||||||
@@ -41,9 +41,9 @@ struct AgentWorkspaceTests {
|
|||||||
let identityURL = tmp.appendingPathComponent(AgentWorkspace.identityFilename)
|
let identityURL = tmp.appendingPathComponent(AgentWorkspace.identityFilename)
|
||||||
let userURL = tmp.appendingPathComponent(AgentWorkspace.userFilename)
|
let userURL = tmp.appendingPathComponent(AgentWorkspace.userFilename)
|
||||||
let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename)
|
let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename)
|
||||||
#expect(FileManager.default.fileExists(atPath: identityURL.path))
|
#expect(FileManager().fileExists(atPath: identityURL.path))
|
||||||
#expect(FileManager.default.fileExists(atPath: userURL.path))
|
#expect(FileManager().fileExists(atPath: userURL.path))
|
||||||
#expect(FileManager.default.fileExists(atPath: bootstrapURL.path))
|
#expect(FileManager().fileExists(atPath: bootstrapURL.path))
|
||||||
|
|
||||||
let second = try AgentWorkspace.bootstrap(workspaceURL: tmp)
|
let second = try AgentWorkspace.bootstrap(workspaceURL: tmp)
|
||||||
#expect(second == agentsURL)
|
#expect(second == agentsURL)
|
||||||
@@ -51,10 +51,10 @@ struct AgentWorkspaceTests {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
func bootstrapSafetyRejectsNonEmptyFolderWithoutAgents() throws {
|
func bootstrapSafetyRejectsNonEmptyFolderWithoutAgents() throws {
|
||||||
let tmp = FileManager.default.temporaryDirectory
|
let tmp = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
|
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
|
||||||
defer { try? FileManager.default.removeItem(at: tmp) }
|
defer { try? FileManager().removeItem(at: tmp) }
|
||||||
try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
|
try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||||
let marker = tmp.appendingPathComponent("notes.txt")
|
let marker = tmp.appendingPathComponent("notes.txt")
|
||||||
try "hello".write(to: marker, atomically: true, encoding: .utf8)
|
try "hello".write(to: marker, atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
@@ -69,10 +69,10 @@ struct AgentWorkspaceTests {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
func bootstrapSafetyAllowsExistingAgentsFile() throws {
|
func bootstrapSafetyAllowsExistingAgentsFile() throws {
|
||||||
let tmp = FileManager.default.temporaryDirectory
|
let tmp = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
|
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
|
||||||
defer { try? FileManager.default.removeItem(at: tmp) }
|
defer { try? FileManager().removeItem(at: tmp) }
|
||||||
try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
|
try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||||
let agents = tmp.appendingPathComponent(AgentWorkspace.agentsFilename)
|
let agents = tmp.appendingPathComponent(AgentWorkspace.agentsFilename)
|
||||||
try "# AGENTS.md".write(to: agents, atomically: true, encoding: .utf8)
|
try "# AGENTS.md".write(to: agents, atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
@@ -87,25 +87,25 @@ struct AgentWorkspaceTests {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
func bootstrapSkipsBootstrapFileWhenWorkspaceHasContent() throws {
|
func bootstrapSkipsBootstrapFileWhenWorkspaceHasContent() throws {
|
||||||
let tmp = FileManager.default.temporaryDirectory
|
let tmp = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
|
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
|
||||||
defer { try? FileManager.default.removeItem(at: tmp) }
|
defer { try? FileManager().removeItem(at: tmp) }
|
||||||
try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
|
try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||||
let marker = tmp.appendingPathComponent("notes.txt")
|
let marker = tmp.appendingPathComponent("notes.txt")
|
||||||
try "hello".write(to: marker, atomically: true, encoding: .utf8)
|
try "hello".write(to: marker, atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
_ = try AgentWorkspace.bootstrap(workspaceURL: tmp)
|
_ = try AgentWorkspace.bootstrap(workspaceURL: tmp)
|
||||||
|
|
||||||
let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename)
|
let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename)
|
||||||
#expect(!FileManager.default.fileExists(atPath: bootstrapURL.path))
|
#expect(!FileManager().fileExists(atPath: bootstrapURL.path))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func needsBootstrapFalseWhenIdentityAlreadySet() throws {
|
func needsBootstrapFalseWhenIdentityAlreadySet() throws {
|
||||||
let tmp = FileManager.default.temporaryDirectory
|
let tmp = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
|
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
|
||||||
defer { try? FileManager.default.removeItem(at: tmp) }
|
defer { try? FileManager().removeItem(at: tmp) }
|
||||||
try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
|
try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||||
let identityURL = tmp.appendingPathComponent(AgentWorkspace.identityFilename)
|
let identityURL = tmp.appendingPathComponent(AgentWorkspace.identityFilename)
|
||||||
try """
|
try """
|
||||||
# IDENTITY.md - Agent Identity
|
# IDENTITY.md - Agent Identity
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import Testing
|
|||||||
struct AnthropicAuthResolverTests {
|
struct AnthropicAuthResolverTests {
|
||||||
@Test
|
@Test
|
||||||
func prefersOAuthFileOverEnv() throws {
|
func prefersOAuthFileOverEnv() throws {
|
||||||
let dir = FileManager.default.temporaryDirectory
|
let dir = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-oauth-\(UUID().uuidString)", isDirectory: true)
|
.appendingPathComponent("clawdbot-oauth-\(UUID().uuidString)", isDirectory: true)
|
||||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
let oauthFile = dir.appendingPathComponent("oauth.json")
|
let oauthFile = dir.appendingPathComponent("oauth.json")
|
||||||
let payload = [
|
let payload = [
|
||||||
"anthropic": [
|
"anthropic": [
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import Testing
|
|||||||
@MainActor
|
@MainActor
|
||||||
struct CLIInstallerTests {
|
struct CLIInstallerTests {
|
||||||
@Test func installedLocationFindsExecutable() throws {
|
@Test func installedLocationFindsExecutable() throws {
|
||||||
let fm = FileManager.default
|
let fm = FileManager()
|
||||||
let root = fm.temporaryDirectory.appendingPathComponent(
|
let root = fm.temporaryDirectory.appendingPathComponent(
|
||||||
"clawdbot-cli-installer-\(UUID().uuidString)")
|
"clawdbot-cli-installer-\(UUID().uuidString)")
|
||||||
defer { try? fm.removeItem(at: root) }
|
defer { try? fm.removeItem(at: root) }
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ import Testing
|
|||||||
private func makeTempDir() throws -> URL {
|
private func makeTempDir() throws -> URL {
|
||||||
let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||||
let dir = base.appendingPathComponent("clawdbot-canvaswatch-\(UUID().uuidString)", isDirectory: true)
|
let dir = base.appendingPathComponent("clawdbot-canvaswatch-\(UUID().uuidString)", isDirectory: true)
|
||||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
return dir
|
return dir
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func detectsInPlaceFileWrites() async throws {
|
@Test func detectsInPlaceFileWrites() async throws {
|
||||||
let dir = try self.makeTempDir()
|
let dir = try self.makeTempDir()
|
||||||
defer { try? FileManager.default.removeItem(at: dir) }
|
defer { try? FileManager().removeItem(at: dir) }
|
||||||
|
|
||||||
let file = dir.appendingPathComponent("index.html")
|
let file = dir.appendingPathComponent("index.html")
|
||||||
try "hello".write(to: file, atomically: false, encoding: .utf8)
|
try "hello".write(to: file, atomically: false, encoding: .utf8)
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import Testing
|
|||||||
@MainActor
|
@MainActor
|
||||||
struct CanvasWindowSmokeTests {
|
struct CanvasWindowSmokeTests {
|
||||||
@Test func panelControllerShowsAndHides() async throws {
|
@Test func panelControllerShowsAndHides() async throws {
|
||||||
let root = FileManager.default.temporaryDirectory
|
let root = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-canvas-test-\(UUID().uuidString)")
|
.appendingPathComponent("clawdbot-canvas-test-\(UUID().uuidString)")
|
||||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
try FileManager().createDirectory(at: root, withIntermediateDirectories: true)
|
||||||
defer { try? FileManager.default.removeItem(at: root) }
|
defer { try? FileManager().removeItem(at: root) }
|
||||||
|
|
||||||
let anchor = { NSRect(x: 200, y: 400, width: 40, height: 40) }
|
let anchor = { NSRect(x: 200, y: 400, width: 40, height: 40) }
|
||||||
let controller = try CanvasWindowController(
|
let controller = try CanvasWindowController(
|
||||||
@@ -31,10 +31,10 @@ struct CanvasWindowSmokeTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func windowControllerShowsAndCloses() async throws {
|
@Test func windowControllerShowsAndCloses() async throws {
|
||||||
let root = FileManager.default.temporaryDirectory
|
let root = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-canvas-test-\(UUID().uuidString)")
|
.appendingPathComponent("clawdbot-canvas-test-\(UUID().uuidString)")
|
||||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
try FileManager().createDirectory(at: root, withIntermediateDirectories: true)
|
||||||
defer { try? FileManager.default.removeItem(at: root) }
|
defer { try? FileManager().removeItem(at: root) }
|
||||||
|
|
||||||
let controller = try CanvasWindowController(
|
let controller = try CanvasWindowController(
|
||||||
sessionKey: "main",
|
sessionKey: "main",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import Testing
|
|||||||
struct ClawdbotConfigFileTests {
|
struct ClawdbotConfigFileTests {
|
||||||
@Test
|
@Test
|
||||||
func configPathRespectsEnvOverride() async {
|
func configPathRespectsEnvOverride() async {
|
||||||
let override = FileManager.default.temporaryDirectory
|
let override = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
|
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
|
||||||
.appendingPathComponent("clawdbot.json")
|
.appendingPathComponent("clawdbot.json")
|
||||||
.path
|
.path
|
||||||
@@ -19,7 +19,7 @@ struct ClawdbotConfigFileTests {
|
|||||||
@MainActor
|
@MainActor
|
||||||
@Test
|
@Test
|
||||||
func remoteGatewayPortParsesAndMatchesHost() async {
|
func remoteGatewayPortParsesAndMatchesHost() async {
|
||||||
let override = FileManager.default.temporaryDirectory
|
let override = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
|
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
|
||||||
.appendingPathComponent("clawdbot.json")
|
.appendingPathComponent("clawdbot.json")
|
||||||
.path
|
.path
|
||||||
@@ -42,7 +42,7 @@ struct ClawdbotConfigFileTests {
|
|||||||
@MainActor
|
@MainActor
|
||||||
@Test
|
@Test
|
||||||
func setRemoteGatewayUrlPreservesScheme() async {
|
func setRemoteGatewayUrlPreservesScheme() async {
|
||||||
let override = FileManager.default.temporaryDirectory
|
let override = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
|
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
|
||||||
.appendingPathComponent("clawdbot.json")
|
.appendingPathComponent("clawdbot.json")
|
||||||
.path
|
.path
|
||||||
@@ -64,7 +64,7 @@ struct ClawdbotConfigFileTests {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
func stateDirOverrideSetsConfigPath() async {
|
func stateDirOverrideSetsConfigPath() async {
|
||||||
let dir = FileManager.default.temporaryDirectory
|
let dir = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-state-\(UUID().uuidString)", isDirectory: true)
|
.appendingPathComponent("clawdbot-state-\(UUID().uuidString)", isDirectory: true)
|
||||||
.path
|
.path
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import Testing
|
|||||||
struct ClawdbotOAuthStoreTests {
|
struct ClawdbotOAuthStoreTests {
|
||||||
@Test
|
@Test
|
||||||
func returnsMissingWhenFileAbsent() {
|
func returnsMissingWhenFileAbsent() {
|
||||||
let url = FileManager.default.temporaryDirectory
|
let url = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-oauth-\(UUID().uuidString)")
|
.appendingPathComponent("clawdbot-oauth-\(UUID().uuidString)")
|
||||||
.appendingPathComponent("oauth.json")
|
.appendingPathComponent("oauth.json")
|
||||||
#expect(ClawdbotOAuthStore.anthropicOAuthStatus(at: url) == .missingFile)
|
#expect(ClawdbotOAuthStore.anthropicOAuthStatus(at: url) == .missingFile)
|
||||||
@@ -24,7 +24,7 @@ struct ClawdbotOAuthStoreTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let dir = FileManager.default.temporaryDirectory
|
let dir = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-oauth-\(UUID().uuidString)", isDirectory: true)
|
.appendingPathComponent("clawdbot-oauth-\(UUID().uuidString)", isDirectory: true)
|
||||||
setenv(key, dir.path, 1)
|
setenv(key, dir.path, 1)
|
||||||
|
|
||||||
@@ -85,9 +85,9 @@ struct ClawdbotOAuthStoreTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func writeOAuthFile(_ json: [String: Any]) throws -> URL {
|
private func writeOAuthFile(_ json: [String: Any]) throws -> URL {
|
||||||
let dir = FileManager.default.temporaryDirectory
|
let dir = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-oauth-\(UUID().uuidString)", isDirectory: true)
|
.appendingPathComponent("clawdbot-oauth-\(UUID().uuidString)", isDirectory: true)
|
||||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
|
||||||
let url = dir.appendingPathComponent("oauth.json")
|
let url = dir.appendingPathComponent("oauth.json")
|
||||||
let data = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys])
|
let data = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys])
|
||||||
|
|||||||
@@ -12,16 +12,16 @@ import Testing
|
|||||||
private func makeTempDir() throws -> URL {
|
private func makeTempDir() throws -> URL {
|
||||||
let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||||
let dir = base.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
let dir = base.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
return dir
|
return dir
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeExec(at path: URL) throws {
|
private func makeExec(at path: URL) throws {
|
||||||
try FileManager.default.createDirectory(
|
try FileManager().createDirectory(
|
||||||
at: path.deletingLastPathComponent(),
|
at: path.deletingLastPathComponent(),
|
||||||
withIntermediateDirectories: true)
|
withIntermediateDirectories: true)
|
||||||
FileManager.default.createFile(atPath: path.path, contents: Data("echo ok\n".utf8))
|
FileManager().createFile(atPath: path.path, contents: Data("echo ok\n".utf8))
|
||||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path)
|
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func prefersClawdbotBinary() async throws {
|
@Test func prefersClawdbotBinary() async throws {
|
||||||
@@ -49,7 +49,7 @@ import Testing
|
|||||||
let scriptPath = tmp.appendingPathComponent("bin/clawdbot.js")
|
let scriptPath = tmp.appendingPathComponent("bin/clawdbot.js")
|
||||||
try self.makeExec(at: nodePath)
|
try self.makeExec(at: nodePath)
|
||||||
try "#!/bin/sh\necho v22.0.0\n".write(to: nodePath, atomically: true, encoding: .utf8)
|
try "#!/bin/sh\necho v22.0.0\n".write(to: nodePath, atomically: true, encoding: .utf8)
|
||||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path)
|
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path)
|
||||||
try self.makeExec(at: scriptPath)
|
try self.makeExec(at: scriptPath)
|
||||||
|
|
||||||
let cmd = CommandResolver.clawdbotCommand(
|
let cmd = CommandResolver.clawdbotCommand(
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import Testing
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func swiftFiles(under root: URL) throws -> [URL] {
|
private static func swiftFiles(under root: URL) throws -> [URL] {
|
||||||
let fm = FileManager.default
|
let fm = FileManager()
|
||||||
guard let enumerator = fm.enumerator(at: root, includingPropertiesForKeys: [.isRegularFileKey]) else {
|
guard let enumerator = fm.enumerator(at: root, includingPropertiesForKeys: [.isRegularFileKey]) else {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import Testing
|
|||||||
#expect(GatewayAgentChannel.last.shouldDeliver(true) == true)
|
#expect(GatewayAgentChannel.last.shouldDeliver(true) == true)
|
||||||
#expect(GatewayAgentChannel.whatsapp.shouldDeliver(true) == true)
|
#expect(GatewayAgentChannel.whatsapp.shouldDeliver(true) == true)
|
||||||
#expect(GatewayAgentChannel.telegram.shouldDeliver(true) == true)
|
#expect(GatewayAgentChannel.telegram.shouldDeliver(true) == true)
|
||||||
|
#expect(GatewayAgentChannel.bluebubbles.shouldDeliver(true) == true)
|
||||||
#expect(GatewayAgentChannel.last.shouldDeliver(false) == false)
|
#expect(GatewayAgentChannel.last.shouldDeliver(false) == false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ import Testing
|
|||||||
#expect(GatewayAgentChannel(raw: nil) == .last)
|
#expect(GatewayAgentChannel(raw: nil) == .last)
|
||||||
#expect(GatewayAgentChannel(raw: " ") == .last)
|
#expect(GatewayAgentChannel(raw: " ") == .last)
|
||||||
#expect(GatewayAgentChannel(raw: "WEBCHAT") == .webchat)
|
#expect(GatewayAgentChannel(raw: "WEBCHAT") == .webchat)
|
||||||
|
#expect(GatewayAgentChannel(raw: "BLUEBUBBLES") == .bluebubbles)
|
||||||
#expect(GatewayAgentChannel(raw: "unknown") == .last)
|
#expect(GatewayAgentChannel(raw: "unknown") == .last)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,7 @@ import Testing
|
|||||||
|
|
||||||
@Suite struct GatewayLaunchAgentManagerTests {
|
@Suite struct GatewayLaunchAgentManagerTests {
|
||||||
@Test func launchAgentPlistSnapshotParsesArgsAndEnv() throws {
|
@Test func launchAgentPlistSnapshotParsesArgsAndEnv() throws {
|
||||||
let url = FileManager.default.temporaryDirectory
|
let url = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
|
.appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
|
||||||
let plist: [String: Any] = [
|
let plist: [String: Any] = [
|
||||||
"ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789", "--bind", "loopback"],
|
"ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789", "--bind", "loopback"],
|
||||||
@@ -15,7 +15,7 @@ import Testing
|
|||||||
]
|
]
|
||||||
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
||||||
try data.write(to: url, options: [.atomic])
|
try data.write(to: url, options: [.atomic])
|
||||||
defer { try? FileManager.default.removeItem(at: url) }
|
defer { try? FileManager().removeItem(at: url) }
|
||||||
|
|
||||||
let snapshot = try #require(LaunchAgentPlist.snapshot(url: url))
|
let snapshot = try #require(LaunchAgentPlist.snapshot(url: url))
|
||||||
#expect(snapshot.port == 18789)
|
#expect(snapshot.port == 18789)
|
||||||
@@ -25,14 +25,14 @@ import Testing
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func launchAgentPlistSnapshotAllowsMissingBind() throws {
|
@Test func launchAgentPlistSnapshotAllowsMissingBind() throws {
|
||||||
let url = FileManager.default.temporaryDirectory
|
let url = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
|
.appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
|
||||||
let plist: [String: Any] = [
|
let plist: [String: Any] = [
|
||||||
"ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789"],
|
"ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789"],
|
||||||
]
|
]
|
||||||
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
||||||
try data.write(to: url, options: [.atomic])
|
try data.write(to: url, options: [.atomic])
|
||||||
defer { try? FileManager.default.removeItem(at: url) }
|
defer { try? FileManager().removeItem(at: url) }
|
||||||
|
|
||||||
let snapshot = try #require(LaunchAgentPlist.snapshot(url: url))
|
let snapshot = try #require(LaunchAgentPlist.snapshot(url: url))
|
||||||
#expect(snapshot.port == 18789)
|
#expect(snapshot.port == 18789)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Testing
|
|||||||
|
|
||||||
@Suite struct LogLocatorTests {
|
@Suite struct LogLocatorTests {
|
||||||
@Test func launchdGatewayLogPathEnsuresTmpDirExists() throws {
|
@Test func launchdGatewayLogPathEnsuresTmpDirExists() throws {
|
||||||
let fm = FileManager.default
|
let fm = FileManager()
|
||||||
let baseDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
let baseDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||||
let logDir = baseDir.appendingPathComponent("clawdbot-tests-\(UUID().uuidString)")
|
let logDir = baseDir.appendingPathComponent("clawdbot-tests-\(UUID().uuidString)")
|
||||||
|
|
||||||
|
|||||||
@@ -77,9 +77,9 @@ struct LowCoverageHelperTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func pairedNodesStorePersists() async throws {
|
@Test func pairedNodesStorePersists() async throws {
|
||||||
let dir = FileManager.default.temporaryDirectory
|
let dir = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("paired-\(UUID().uuidString)", isDirectory: true)
|
.appendingPathComponent("paired-\(UUID().uuidString)", isDirectory: true)
|
||||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
let url = dir.appendingPathComponent("nodes.json")
|
let url = dir.appendingPathComponent("nodes.json")
|
||||||
let store = PairedNodesStore(fileURL: url)
|
let store = PairedNodesStore(fileURL: url)
|
||||||
await store.load()
|
await store.load()
|
||||||
@@ -143,12 +143,12 @@ struct LowCoverageHelperTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor func canvasSchemeHandlerResolvesFilesAndErrors() throws {
|
@Test @MainActor func canvasSchemeHandlerResolvesFilesAndErrors() throws {
|
||||||
let root = FileManager.default.temporaryDirectory
|
let root = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("canvas-\(UUID().uuidString)", isDirectory: true)
|
.appendingPathComponent("canvas-\(UUID().uuidString)", isDirectory: true)
|
||||||
defer { try? FileManager.default.removeItem(at: root) }
|
defer { try? FileManager().removeItem(at: root) }
|
||||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
try FileManager().createDirectory(at: root, withIntermediateDirectories: true)
|
||||||
let session = root.appendingPathComponent("main", isDirectory: true)
|
let session = root.appendingPathComponent("main", isDirectory: true)
|
||||||
try FileManager.default.createDirectory(at: session, withIntermediateDirectories: true)
|
try FileManager().createDirectory(at: session, withIntermediateDirectories: true)
|
||||||
|
|
||||||
let index = session.appendingPathComponent("index.html")
|
let index = session.appendingPathComponent("index.html")
|
||||||
try "<h1>Hello</h1>".write(to: index, atomically: true, encoding: .utf8)
|
try "<h1>Hello</h1>".write(to: index, atomically: true, encoding: .utf8)
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ struct MacNodeRuntimeTests {
|
|||||||
includeAudio: Bool?,
|
includeAudio: Bool?,
|
||||||
outPath: String?) async throws -> (path: String, hasAudio: Bool)
|
outPath: String?) async throws -> (path: String, hasAudio: Bool)
|
||||||
{
|
{
|
||||||
let url = FileManager.default.temporaryDirectory
|
let url = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-test-screen-record-\(UUID().uuidString).mp4")
|
.appendingPathComponent("clawdbot-test-screen-record-\(UUID().uuidString).mp4")
|
||||||
try Data("ok".utf8).write(to: url)
|
try Data("ok".utf8).write(to: url)
|
||||||
return (path: url.path, hasAudio: false)
|
return (path: url.path, hasAudio: false)
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ struct ModelCatalogLoaderTests {
|
|||||||
};
|
};
|
||||||
"""
|
"""
|
||||||
|
|
||||||
let tmp = FileManager.default.temporaryDirectory
|
let tmp = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("models-\(UUID().uuidString).ts")
|
.appendingPathComponent("models-\(UUID().uuidString).ts")
|
||||||
defer { try? FileManager.default.removeItem(at: tmp) }
|
defer { try? FileManager().removeItem(at: tmp) }
|
||||||
try src.write(to: tmp, atomically: true, encoding: .utf8)
|
try src.write(to: tmp, atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
let choices = try await ModelCatalogLoader.load(from: tmp.path)
|
let choices = try await ModelCatalogLoader.load(from: tmp.path)
|
||||||
@@ -42,9 +42,9 @@ struct ModelCatalogLoaderTests {
|
|||||||
@Test
|
@Test
|
||||||
func loadWithNoExportReturnsEmptyChoices() async throws {
|
func loadWithNoExportReturnsEmptyChoices() async throws {
|
||||||
let src = "const NOPE = 1;"
|
let src = "const NOPE = 1;"
|
||||||
let tmp = FileManager.default.temporaryDirectory
|
let tmp = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("models-\(UUID().uuidString).ts")
|
.appendingPathComponent("models-\(UUID().uuidString).ts")
|
||||||
defer { try? FileManager.default.removeItem(at: tmp) }
|
defer { try? FileManager().removeItem(at: tmp) }
|
||||||
try src.write(to: tmp, atomically: true, encoding: .utf8)
|
try src.write(to: tmp, atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
let choices = try await ModelCatalogLoader.load(from: tmp.path)
|
let choices = try await ModelCatalogLoader.load(from: tmp.path)
|
||||||
|
|||||||
@@ -6,16 +6,16 @@ import Testing
|
|||||||
private func makeTempDir() throws -> URL {
|
private func makeTempDir() throws -> URL {
|
||||||
let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||||
let dir = base.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
let dir = base.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
return dir
|
return dir
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeExec(at path: URL) throws {
|
private func makeExec(at path: URL) throws {
|
||||||
try FileManager.default.createDirectory(
|
try FileManager().createDirectory(
|
||||||
at: path.deletingLastPathComponent(),
|
at: path.deletingLastPathComponent(),
|
||||||
withIntermediateDirectories: true)
|
withIntermediateDirectories: true)
|
||||||
FileManager.default.createFile(atPath: path.path, contents: Data("echo ok\n".utf8))
|
FileManager().createFile(atPath: path.path, contents: Data("echo ok\n".utf8))
|
||||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path)
|
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func fnmNodeBinsPreferNewestInstalledVersion() throws {
|
@Test func fnmNodeBinsPreferNewestInstalledVersion() throws {
|
||||||
@@ -37,7 +37,7 @@ import Testing
|
|||||||
let home = try self.makeTempDir()
|
let home = try self.makeTempDir()
|
||||||
let missingNodeBin = home
|
let missingNodeBin = home
|
||||||
.appendingPathComponent(".local/share/fnm/node-versions/v99.0.0/installation/bin")
|
.appendingPathComponent(".local/share/fnm/node-versions/v99.0.0/installation/bin")
|
||||||
try FileManager.default.createDirectory(at: missingNodeBin, withIntermediateDirectories: true)
|
try FileManager().createDirectory(at: missingNodeBin, withIntermediateDirectories: true)
|
||||||
|
|
||||||
let bins = CommandResolver._testNodeManagerBinPaths(home: home)
|
let bins = CommandResolver._testNodeManagerBinPaths(home: home)
|
||||||
#expect(!bins.contains(missingNodeBin.path))
|
#expect(!bins.contains(missingNodeBin.path))
|
||||||
|
|||||||
Loaded 100 of 818 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user