Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcfde2822a | ||
|
|
bb568b2a45 |
No files matched your search
+3
-45
@@ -2,43 +2,24 @@
|
|||||||
|
|
||||||
## 2026.1.15 (unreleased)
|
## 2026.1.15 (unreleased)
|
||||||
|
|
||||||
- **BREAKING:** Microsoft Teams is now a plugin; install `@clawdbot/msteams` via `clawdbot plugins install @clawdbot/msteams`.
|
- Slack: add optional user token routing for read access with safe write fallback. (#981) — thanks @jalehman.
|
||||||
- CLI: set process titles to `clawdbot-<command>` for clearer process listings.
|
|
||||||
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
|
|
||||||
- Sessions/Security: add `session.dmScope` for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.
|
|
||||||
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
|
|
||||||
- Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker.
|
|
||||||
- TUI: show provider/model labels for the active session and default model.
|
|
||||||
- Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.
|
|
||||||
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
|
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
|
||||||
- Fix: persist `gateway.mode=local` after selecting Local run mode in `clawdbot configure`, even if no other sections are chosen.
|
|
||||||
- Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.
|
- Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.
|
||||||
- Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts.
|
- Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts.
|
||||||
- Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.
|
- Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.
|
||||||
- Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).
|
- Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).
|
||||||
- Agents: avoid false positives when logging unsupported Google tool schema keywords.
|
|
||||||
- Status: restore usage summary line for current provider when no OAuth profiles exist.
|
|
||||||
- Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields.
|
- Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields.
|
||||||
- Docs: add Date & Time guide and update prompt/timezone configuration docs.
|
- Docs: add Date & Time guide and update prompt/timezone configuration docs.
|
||||||
- Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.
|
- Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.
|
||||||
- Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.
|
|
||||||
- Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in `/status` and `clawdbot models status`, and update docs.
|
|
||||||
- Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.
|
- Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.
|
||||||
- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
|
- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
|
||||||
- Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.
|
|
||||||
- Fix: parse systemd ExecStart arguments when whitespace is present. (#995) — thanks @roshanasingh4.
|
|
||||||
- CLI: add `--json` output for `clawdbot daemon` lifecycle/install commands.
|
|
||||||
- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.
|
- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.
|
||||||
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`.
|
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`.
|
||||||
- Browser: `profile="chrome"` now defaults to host control and returns clearer “attach a tab” errors.
|
- Browser: `profile="chrome"` now defaults to host control and returns clearer “attach a tab” errors.
|
||||||
- Browser: extension mode recovers when only one tab is attached (stale targetId fallback).
|
- Browser: extension mode recovers when only one tab is attached (stale targetId fallback).
|
||||||
- Browser: prefer stable Chrome for auto-detect, with Brave/Edge fallbacks and updated docs. (#983) — thanks @cpojer.
|
|
||||||
- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page).
|
- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page).
|
||||||
- Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.
|
- Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.
|
||||||
- Telegram: skip `message_thread_id=1` for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.
|
|
||||||
- Discord: allow allowlisted guilds without channel lists to receive messages when `groupPolicy="allowlist"`. — thanks @thewilloftheshadow.
|
- Discord: allow allowlisted guilds without channel lists to receive messages when `groupPolicy="allowlist"`. — thanks @thewilloftheshadow.
|
||||||
- Fix: sanitize user-facing error text + strip `<final>` tags across reply pipelines. (#975) — thanks @ThomsenDrake.
|
|
||||||
- Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.
|
|
||||||
|
|
||||||
## 2026.1.14-1
|
## 2026.1.14-1
|
||||||
|
|
||||||
@@ -52,9 +33,8 @@
|
|||||||
- Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics.
|
- Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics.
|
||||||
- Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors.
|
- Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors.
|
||||||
- Agents: add optional auth-profile copy prompt on `agents add` and improve auth error messaging.
|
- Agents: add optional auth-profile copy prompt on `agents add` and improve auth error messaging.
|
||||||
- Security: expand `clawdbot security audit` checks (model hygiene, config includes, plugin allowlists, exposure matrix) and extend `--fix` to tighten more sensitive state paths.
|
- Security: add `clawdbot security audit` (`--deep`, `--fix`) and surface it in `status --all` and `doctor`.
|
||||||
- Security: add `SECURITY.md` reporting policy.
|
- Security: add `clawdbot security audit` (`--deep`, `--fix`) and surface it in `status --all` and `doctor` (includes browser control exposure checks).
|
||||||
- Channels: add Matrix plugin (external) with docs + onboarding hooks.
|
|
||||||
- Plugins: add Zalo channel plugin with gateway HTTP hooks and onboarding install prompt. (#854) — thanks @longmaba.
|
- Plugins: add Zalo channel plugin with gateway HTTP hooks and onboarding install prompt. (#854) — thanks @longmaba.
|
||||||
- Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`.
|
- Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`.
|
||||||
- Docs: expand gateway security hardening guidance and incident response checklist.
|
- Docs: expand gateway security hardening guidance and incident response checklist.
|
||||||
@@ -70,7 +50,6 @@
|
|||||||
- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.
|
- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.
|
||||||
- Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.
|
- Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.
|
||||||
- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.
|
- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.
|
||||||
- Agents: harden Antigravity Claude history/tool-call sanitization. (#968) — thanks @rdev.
|
|
||||||
- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.
|
- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.
|
||||||
- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.
|
- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.
|
||||||
- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.
|
- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.
|
||||||
@@ -97,28 +76,7 @@
|
|||||||
- UI: use application-defined WebSocket close code (browser compatibility). (#918) — thanks @rahthakor.
|
- UI: use application-defined WebSocket close code (browser compatibility). (#918) — thanks @rahthakor.
|
||||||
- TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank.
|
- TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank.
|
||||||
- TUI: add a bright spinner + elapsed time in the status line for send/stream/run states.
|
- TUI: add a bright spinner + elapsed time in the status line for send/stream/run states.
|
||||||
- TUI: show LLM error messages (rate limits, auth, etc.) instead of `(no output)`.
|
|
||||||
- Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.clawdbot-dev`).
|
- Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.clawdbot-dev`).
|
||||||
|
|
||||||
#### Agents / Auth / Tools / Sandbox
|
|
||||||
- Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.
|
|
||||||
- Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.
|
|
||||||
- Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.
|
|
||||||
- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.
|
|
||||||
- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.
|
|
||||||
- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.
|
|
||||||
- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.
|
|
||||||
- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.
|
|
||||||
- Sandbox: restore `docker.binds` config validation and preserve configured PATH for `docker exec`. (#873) — thanks @akonyer.
|
|
||||||
- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.
|
|
||||||
|
|
||||||
#### macOS / Apps
|
|
||||||
- macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.
|
|
||||||
- macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.
|
|
||||||
- macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.
|
|
||||||
- macOS: reuse launchd gateway auth and skip wizard when gateway config already exists. (#917)
|
|
||||||
- macOS: prefer the default bridge tunnel port in remote mode for node bridge connectivity; document macOS remote control + bridge tunnels. (#960, fixes #865) — thanks @kkarimi.
|
|
||||||
- Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare `main` sessions.
|
|
||||||
- macOS: fix cron preview/testing payload to use `channel` key. (#867) — thanks @wes-davis.
|
- macOS: fix cron preview/testing payload to use `channel` key. (#867) — thanks @wes-davis.
|
||||||
- Telegram: honor `channels.telegram.timeoutSeconds` for grammY API requests. (#863) — thanks @Snaver.
|
- Telegram: honor `channels.telegram.timeoutSeconds` for grammY API requests. (#863) — thanks @Snaver.
|
||||||
- Telegram: split long captions into media + follow-up text messages. (#907) - thanks @jalehman.
|
- Telegram: split long captions into media + follow-up text messages. (#907) - thanks @jalehman.
|
||||||
|
|||||||
+1
-1
Submodule Peekaboo updated: 5c195f5e46...95ad7532c1.
@@ -475,22 +475,22 @@ 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/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a>
|
<a href="https://github.com/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/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a>
|
||||||
<a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a>
|
<a href="https://github.com/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/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a>
|
||||||
<a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/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/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/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/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/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/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/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/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/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/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/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/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/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/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/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/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a>
|
<a href="https://github.com/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/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a>
|
||||||
<a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/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/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/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/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/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/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/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/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/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/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/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a>
|
||||||
<a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/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=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></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/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/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/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/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a>
|
||||||
<a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a>
|
<a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/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/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/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/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/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/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/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/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/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/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/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a>
|
||||||
<a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a>
|
<a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a>
|
||||||
<a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/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/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></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/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/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/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/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a>
|
||||||
<a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a>
|
<a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a>
|
||||||
<a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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=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/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/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a>
|
||||||
<a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/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=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></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=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></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>
|
||||||
</p>
|
</p>
|
||||||
@@ -16,7 +16,6 @@ import com.clawdbot.android.bridge.BridgeDiscovery
|
|||||||
import com.clawdbot.android.bridge.BridgeEndpoint
|
import com.clawdbot.android.bridge.BridgeEndpoint
|
||||||
import com.clawdbot.android.bridge.BridgePairingClient
|
import com.clawdbot.android.bridge.BridgePairingClient
|
||||||
import com.clawdbot.android.bridge.BridgeSession
|
import com.clawdbot.android.bridge.BridgeSession
|
||||||
import com.clawdbot.android.bridge.BridgeTlsParams
|
|
||||||
import com.clawdbot.android.node.CameraCaptureManager
|
import com.clawdbot.android.node.CameraCaptureManager
|
||||||
import com.clawdbot.android.node.LocationCaptureManager
|
import com.clawdbot.android.node.LocationCaptureManager
|
||||||
import com.clawdbot.android.BuildConfig
|
import com.clawdbot.android.BuildConfig
|
||||||
@@ -161,9 +160,6 @@ class NodeRuntime(context: Context) {
|
|||||||
onInvoke = { req ->
|
onInvoke = { req ->
|
||||||
handleInvoke(req.command, req.paramsJson)
|
handleInvoke(req.command, req.paramsJson)
|
||||||
},
|
},
|
||||||
onTlsFingerprint = { stableId, fingerprint ->
|
|
||||||
prefs.saveBridgeTlsFingerprint(stableId, fingerprint)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private val chat = ChatController(scope = scope, session = session, json = json)
|
private val chat = ChatController(scope = scope, session = session, json = json)
|
||||||
@@ -492,17 +488,12 @@ class NodeRuntime(context: Context) {
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
_statusText.value = "Connecting…"
|
_statusText.value = "Connecting…"
|
||||||
val storedToken = prefs.loadBridgeToken()
|
val storedToken = prefs.loadBridgeToken()
|
||||||
val tls = resolveTlsParams(endpoint)
|
|
||||||
val resolved =
|
val resolved =
|
||||||
if (storedToken.isNullOrBlank()) {
|
if (storedToken.isNullOrBlank()) {
|
||||||
_statusText.value = "Pairing…"
|
_statusText.value = "Pairing…"
|
||||||
BridgePairingClient().pairAndHello(
|
BridgePairingClient().pairAndHello(
|
||||||
endpoint = endpoint,
|
endpoint = endpoint,
|
||||||
hello = buildPairingHello(token = null),
|
hello = buildPairingHello(token = null),
|
||||||
tls = tls,
|
|
||||||
onTlsFingerprint = { fingerprint ->
|
|
||||||
prefs.saveBridgeTlsFingerprint(endpoint.stableId, fingerprint)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
BridgePairingClient.PairResult(ok = true, token = storedToken.trim())
|
BridgePairingClient.PairResult(ok = true, token = storedToken.trim())
|
||||||
@@ -519,7 +510,6 @@ class NodeRuntime(context: Context) {
|
|||||||
session.connect(
|
session.connect(
|
||||||
endpoint = endpoint,
|
endpoint = endpoint,
|
||||||
hello = buildSessionHello(token = authToken),
|
hello = buildSessionHello(token = authToken),
|
||||||
tls = tls,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -566,41 +556,6 @@ class NodeRuntime(context: Context) {
|
|||||||
session.disconnect()
|
session.disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resolveTlsParams(endpoint: BridgeEndpoint): BridgeTlsParams? {
|
|
||||||
val stored = prefs.loadBridgeTlsFingerprint(endpoint.stableId)
|
|
||||||
val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank()
|
|
||||||
val manual = endpoint.stableId.startsWith("manual|")
|
|
||||||
|
|
||||||
if (hinted) {
|
|
||||||
return BridgeTlsParams(
|
|
||||||
required = true,
|
|
||||||
expectedFingerprint = endpoint.tlsFingerprintSha256 ?: stored,
|
|
||||||
allowTOFU = stored == null,
|
|
||||||
stableId = endpoint.stableId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!stored.isNullOrBlank()) {
|
|
||||||
return BridgeTlsParams(
|
|
||||||
required = true,
|
|
||||||
expectedFingerprint = stored,
|
|
||||||
allowTOFU = false,
|
|
||||||
stableId = endpoint.stableId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (manual) {
|
|
||||||
return BridgeTlsParams(
|
|
||||||
required = false,
|
|
||||||
expectedFingerprint = null,
|
|
||||||
allowTOFU = true,
|
|
||||||
stableId = endpoint.stableId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
|
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val trimmed = payloadJson.trim()
|
val trimmed = payloadJson.trim()
|
||||||
|
|||||||
@@ -147,16 +147,6 @@ class SecurePrefs(context: Context) {
|
|||||||
prefs.edit { putString(key, token.trim()) }
|
prefs.edit { putString(key, token.trim()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadBridgeTlsFingerprint(stableId: String): String? {
|
|
||||||
val key = "bridge.tls.$stableId"
|
|
||||||
return prefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveBridgeTlsFingerprint(stableId: String, fingerprint: String) {
|
|
||||||
val key = "bridge.tls.$stableId"
|
|
||||||
prefs.edit { putString(key, fingerprint.trim()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -143,8 +143,6 @@ class BridgeDiscovery(
|
|||||||
val gatewayPort = txtInt(resolved, "gatewayPort")
|
val gatewayPort = txtInt(resolved, "gatewayPort")
|
||||||
val bridgePort = txtInt(resolved, "bridgePort")
|
val bridgePort = txtInt(resolved, "bridgePort")
|
||||||
val canvasPort = txtInt(resolved, "canvasPort")
|
val canvasPort = txtInt(resolved, "canvasPort")
|
||||||
val tlsEnabled = txtBool(resolved, "bridgeTls")
|
|
||||||
val tlsFingerprint = txt(resolved, "bridgeTlsSha256")
|
|
||||||
val id = stableId(serviceName, "local.")
|
val id = stableId(serviceName, "local.")
|
||||||
localById[id] =
|
localById[id] =
|
||||||
BridgeEndpoint(
|
BridgeEndpoint(
|
||||||
@@ -157,8 +155,6 @@ class BridgeDiscovery(
|
|||||||
gatewayPort = gatewayPort,
|
gatewayPort = gatewayPort,
|
||||||
bridgePort = bridgePort,
|
bridgePort = bridgePort,
|
||||||
canvasPort = canvasPort,
|
canvasPort = canvasPort,
|
||||||
tlsEnabled = tlsEnabled,
|
|
||||||
tlsFingerprintSha256 = tlsFingerprint,
|
|
||||||
)
|
)
|
||||||
publish()
|
publish()
|
||||||
}
|
}
|
||||||
@@ -213,11 +209,6 @@ class BridgeDiscovery(
|
|||||||
return txt(info, key)?.toIntOrNull()
|
return txt(info, key)?.toIntOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun txtBool(info: NsdServiceInfo, key: String): Boolean {
|
|
||||||
val raw = txt(info, key)?.trim()?.lowercase() ?: return false
|
|
||||||
return raw == "1" || raw == "true" || raw == "yes"
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun refreshUnicast(domain: String) {
|
private suspend fun refreshUnicast(domain: String) {
|
||||||
val ptrName = "${serviceType}${domain}"
|
val ptrName = "${serviceType}${domain}"
|
||||||
val ptrMsg = lookupUnicastMessage(ptrName, Type.PTR) ?: return
|
val ptrMsg = lookupUnicastMessage(ptrName, Type.PTR) ?: return
|
||||||
@@ -261,8 +252,6 @@ class BridgeDiscovery(
|
|||||||
val gatewayPort = txtIntValue(txt, "gatewayPort")
|
val gatewayPort = txtIntValue(txt, "gatewayPort")
|
||||||
val bridgePort = txtIntValue(txt, "bridgePort")
|
val bridgePort = txtIntValue(txt, "bridgePort")
|
||||||
val canvasPort = txtIntValue(txt, "canvasPort")
|
val canvasPort = txtIntValue(txt, "canvasPort")
|
||||||
val tlsEnabled = txtBoolValue(txt, "bridgeTls")
|
|
||||||
val tlsFingerprint = txtValue(txt, "bridgeTlsSha256")
|
|
||||||
val id = stableId(instanceName, domain)
|
val id = stableId(instanceName, domain)
|
||||||
next[id] =
|
next[id] =
|
||||||
BridgeEndpoint(
|
BridgeEndpoint(
|
||||||
@@ -275,8 +264,6 @@ class BridgeDiscovery(
|
|||||||
gatewayPort = gatewayPort,
|
gatewayPort = gatewayPort,
|
||||||
bridgePort = bridgePort,
|
bridgePort = bridgePort,
|
||||||
canvasPort = canvasPort,
|
canvasPort = canvasPort,
|
||||||
tlsEnabled = tlsEnabled,
|
|
||||||
tlsFingerprintSha256 = tlsFingerprint,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,11 +474,6 @@ class BridgeDiscovery(
|
|||||||
return txtValue(records, key)?.toIntOrNull()
|
return txtValue(records, key)?.toIntOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun txtBoolValue(records: List<TXTRecord>, key: String): Boolean {
|
|
||||||
val raw = txtValue(records, key)?.trim()?.lowercase() ?: return false
|
|
||||||
return raw == "1" || raw == "true" || raw == "yes"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun decodeDnsTxtString(raw: String): String {
|
private fun decodeDnsTxtString(raw: String): String {
|
||||||
// dnsjava treats TXT as opaque bytes and decodes as ISO-8859-1 to preserve bytes.
|
// dnsjava treats TXT as opaque bytes and decodes as ISO-8859-1 to preserve bytes.
|
||||||
// Our TXT payload is UTF-8 (written by the gateway), so re-decode when possible.
|
// Our TXT payload is UTF-8 (written by the gateway), so re-decode when possible.
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ data class BridgeEndpoint(
|
|||||||
val gatewayPort: Int? = null,
|
val gatewayPort: Int? = null,
|
||||||
val bridgePort: Int? = null,
|
val bridgePort: Int? = null,
|
||||||
val canvasPort: Int? = null,
|
val canvasPort: Int? = null,
|
||||||
val tlsEnabled: Boolean = false,
|
|
||||||
val tlsFingerprintSha256: String? = null,
|
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun manual(host: String, port: Int): BridgeEndpoint =
|
fun manual(host: String, port: Int): BridgeEndpoint =
|
||||||
@@ -20,8 +18,6 @@ data class BridgeEndpoint(
|
|||||||
name = "$host:$port",
|
name = "$host:$port",
|
||||||
host = host,
|
host = host,
|
||||||
port = port,
|
port = port,
|
||||||
tlsEnabled = false,
|
|
||||||
tlsFingerprintSha256 = null,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,7 @@ import java.io.BufferedWriter
|
|||||||
import java.io.InputStreamReader
|
import java.io.InputStreamReader
|
||||||
import java.io.OutputStreamWriter
|
import java.io.OutputStreamWriter
|
||||||
import java.net.InetSocketAddress
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.Socket
|
||||||
|
|
||||||
class BridgePairingClient {
|
class BridgePairingClient {
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
@@ -32,38 +33,13 @@ class BridgePairingClient {
|
|||||||
|
|
||||||
data class PairResult(val ok: Boolean, val token: String?, val error: String? = null)
|
data class PairResult(val ok: Boolean, val token: String?, val error: String? = null)
|
||||||
|
|
||||||
suspend fun pairAndHello(
|
suspend fun pairAndHello(endpoint: BridgeEndpoint, hello: Hello): PairResult =
|
||||||
endpoint: BridgeEndpoint,
|
|
||||||
hello: Hello,
|
|
||||||
tls: BridgeTlsParams? = null,
|
|
||||||
onTlsFingerprint: ((String) -> Unit)? = null,
|
|
||||||
): PairResult =
|
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
if (tls != null) {
|
val socket = Socket()
|
||||||
try {
|
|
||||||
return@withContext pairAndHelloWithTls(endpoint, hello, tls, onTlsFingerprint)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
if (tls.required) throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pairAndHelloWithTls(endpoint, hello, null, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun pairAndHelloWithTls(
|
|
||||||
endpoint: BridgeEndpoint,
|
|
||||||
hello: Hello,
|
|
||||||
tls: BridgeTlsParams?,
|
|
||||||
onTlsFingerprint: ((String) -> Unit)?,
|
|
||||||
): PairResult {
|
|
||||||
val socket =
|
|
||||||
createBridgeSocket(tls) { fingerprint ->
|
|
||||||
onTlsFingerprint?.invoke(fingerprint)
|
|
||||||
}
|
|
||||||
socket.tcpNoDelay = true
|
socket.tcpNoDelay = true
|
||||||
try {
|
try {
|
||||||
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
||||||
socket.soTimeout = 60_000
|
socket.soTimeout = 60_000
|
||||||
startTlsHandshakeIfNeeded(socket)
|
|
||||||
|
|
||||||
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
||||||
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
||||||
@@ -92,14 +68,14 @@ class BridgePairingClient {
|
|||||||
)
|
)
|
||||||
|
|
||||||
val firstObj = json.parseToJsonElement(reader.readLine()).asObjectOrNull()
|
val firstObj = json.parseToJsonElement(reader.readLine()).asObjectOrNull()
|
||||||
?: return PairResult(ok = false, token = null, error = "unexpected bridge response")
|
?: return@withContext PairResult(ok = false, token = null, error = "unexpected bridge response")
|
||||||
return when (firstObj["type"].asStringOrNull()) {
|
when (firstObj["type"].asStringOrNull()) {
|
||||||
"hello-ok" -> PairResult(ok = true, token = hello.token)
|
"hello-ok" -> PairResult(ok = true, token = hello.token)
|
||||||
"error" -> {
|
"error" -> {
|
||||||
val code = firstObj["code"].asStringOrNull() ?: "UNAVAILABLE"
|
val code = firstObj["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||||
val message = firstObj["message"].asStringOrNull() ?: "pairing required"
|
val message = firstObj["message"].asStringOrNull() ?: "pairing required"
|
||||||
if (code != "NOT_PAIRED" && code != "UNAUTHORIZED") {
|
if (code != "NOT_PAIRED" && code != "UNAUTHORIZED") {
|
||||||
return PairResult(ok = false, token = null, error = "$code: $message")
|
return@withContext PairResult(ok = false, token = null, error = "$code: $message")
|
||||||
}
|
}
|
||||||
|
|
||||||
sendJson(
|
sendJson(
|
||||||
@@ -122,12 +98,12 @@ class BridgePairingClient {
|
|||||||
when (next["type"].asStringOrNull()) {
|
when (next["type"].asStringOrNull()) {
|
||||||
"pair-ok" -> {
|
"pair-ok" -> {
|
||||||
val token = next["token"].asStringOrNull()
|
val token = next["token"].asStringOrNull()
|
||||||
return PairResult(ok = !token.isNullOrBlank(), token = token)
|
return@withContext PairResult(ok = !token.isNullOrBlank(), token = token)
|
||||||
}
|
}
|
||||||
"error" -> {
|
"error" -> {
|
||||||
val c = next["code"].asStringOrNull() ?: "UNAVAILABLE"
|
val c = next["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||||
val m = next["message"].asStringOrNull() ?: "pairing failed"
|
val m = next["message"].asStringOrNull() ?: "pairing failed"
|
||||||
return PairResult(ok = false, token = null, error = "$c: $m")
|
return@withContext PairResult(ok = false, token = null, error = "$c: $m")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,7 +113,7 @@ class BridgePairingClient {
|
|||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
val message = e.message?.trim().orEmpty().ifEmpty { "gateway unreachable" }
|
val message = e.message?.trim().orEmpty().ifEmpty { "gateway unreachable" }
|
||||||
return PairResult(ok = false, token = null, error = message)
|
PairResult(ok = false, token = null, error = message)
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
socket.close()
|
socket.close()
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ class BridgeSession(
|
|||||||
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,
|
||||||
private val onInvoke: suspend (InvokeRequest) -> InvokeResult,
|
private val onInvoke: suspend (InvokeRequest) -> InvokeResult,
|
||||||
private val onTlsFingerprint: ((stableId: String, fingerprint: String) -> Unit)? = null,
|
|
||||||
) {
|
) {
|
||||||
data class Hello(
|
data class Hello(
|
||||||
val nodeId: String,
|
val nodeId: String,
|
||||||
@@ -67,17 +66,11 @@ class BridgeSession(
|
|||||||
@Volatile private var canvasHostUrl: String? = null
|
@Volatile private var canvasHostUrl: String? = null
|
||||||
@Volatile private var mainSessionKey: String? = null
|
@Volatile private var mainSessionKey: String? = null
|
||||||
|
|
||||||
private data class DesiredConnection(
|
private var desired: Pair<BridgeEndpoint, Hello>? = null
|
||||||
val endpoint: BridgeEndpoint,
|
|
||||||
val hello: Hello,
|
|
||||||
val tls: BridgeTlsParams?,
|
|
||||||
)
|
|
||||||
|
|
||||||
private var desired: DesiredConnection? = null
|
|
||||||
private var job: Job? = null
|
private var job: Job? = null
|
||||||
|
|
||||||
fun connect(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams? = null) {
|
fun connect(endpoint: BridgeEndpoint, hello: Hello) {
|
||||||
desired = DesiredConnection(endpoint, hello, tls)
|
desired = endpoint to hello
|
||||||
if (job == null) {
|
if (job == null) {
|
||||||
job = scope.launch(Dispatchers.IO) { runLoop() }
|
job = scope.launch(Dispatchers.IO) { runLoop() }
|
||||||
}
|
}
|
||||||
@@ -85,7 +78,7 @@ class BridgeSession(
|
|||||||
|
|
||||||
suspend fun updateHello(hello: Hello) {
|
suspend fun updateHello(hello: Hello) {
|
||||||
val target = desired ?: return
|
val target = desired ?: return
|
||||||
desired = target.copy(hello = hello)
|
desired = target.first to hello
|
||||||
val conn = currentConnection ?: return
|
val conn = currentConnection ?: return
|
||||||
conn.sendJson(buildHelloJson(hello))
|
conn.sendJson(buildHelloJson(hello))
|
||||||
}
|
}
|
||||||
@@ -172,10 +165,10 @@ class BridgeSession(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
val (endpoint, hello, tls) = target
|
val (endpoint, hello) = target
|
||||||
try {
|
try {
|
||||||
onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…")
|
onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…")
|
||||||
connectOnce(endpoint, hello, tls)
|
connectOnce(endpoint, hello)
|
||||||
attempt = 0
|
attempt = 0
|
||||||
} catch (err: Throwable) {
|
} catch (err: Throwable) {
|
||||||
attempt += 1
|
attempt += 1
|
||||||
@@ -199,28 +192,12 @@ class BridgeSession(
|
|||||||
return InvokeResult.error(code = "UNAVAILABLE", message = msg)
|
return InvokeResult.error(code = "UNAVAILABLE", message = msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun connectOnce(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams?) =
|
private suspend fun connectOnce(endpoint: BridgeEndpoint, hello: Hello) =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
if (tls != null) {
|
val socket = Socket()
|
||||||
try {
|
|
||||||
connectWithSocket(endpoint, hello, tls)
|
|
||||||
return@withContext
|
|
||||||
} catch (err: Throwable) {
|
|
||||||
if (tls.required) throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
connectWithSocket(endpoint, hello, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun connectWithSocket(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams?) {
|
|
||||||
val socket =
|
|
||||||
createBridgeSocket(tls) { fingerprint ->
|
|
||||||
onTlsFingerprint?.invoke(tls?.stableId ?: endpoint.stableId, fingerprint)
|
|
||||||
}
|
|
||||||
socket.tcpNoDelay = true
|
socket.tcpNoDelay = true
|
||||||
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
||||||
socket.soTimeout = 0
|
socket.soTimeout = 0
|
||||||
startTlsHandshakeIfNeeded(socket)
|
|
||||||
|
|
||||||
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
||||||
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
package com.clawdbot.android.bridge
|
|
||||||
|
|
||||||
import java.net.Socket
|
|
||||||
import java.security.MessageDigest
|
|
||||||
import java.security.SecureRandom
|
|
||||||
import java.security.cert.CertificateException
|
|
||||||
import java.security.cert.X509Certificate
|
|
||||||
import javax.net.ssl.SSLContext
|
|
||||||
import javax.net.ssl.SSLSocket
|
|
||||||
import javax.net.ssl.TrustManagerFactory
|
|
||||||
import javax.net.ssl.X509TrustManager
|
|
||||||
|
|
||||||
data class BridgeTlsParams(
|
|
||||||
val required: Boolean,
|
|
||||||
val expectedFingerprint: String?,
|
|
||||||
val allowTOFU: Boolean,
|
|
||||||
val stableId: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun createBridgeSocket(params: BridgeTlsParams?, onStore: ((String) -> Unit)? = null): Socket {
|
|
||||||
if (params == null) return Socket()
|
|
||||||
val expected = params.expectedFingerprint?.let(::normalizeFingerprint)
|
|
||||||
val defaultTrust = defaultTrustManager()
|
|
||||||
val trustManager =
|
|
||||||
object : X509TrustManager {
|
|
||||||
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {
|
|
||||||
defaultTrust.checkClientTrusted(chain, authType)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
|
|
||||||
if (chain.isEmpty()) throw CertificateException("empty certificate chain")
|
|
||||||
val fingerprint = sha256Hex(chain[0].encoded)
|
|
||||||
if (expected != null) {
|
|
||||||
if (fingerprint != expected) {
|
|
||||||
throw CertificateException("bridge TLS fingerprint mismatch")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (params.allowTOFU) {
|
|
||||||
onStore?.invoke(fingerprint)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defaultTrust.checkServerTrusted(chain, authType)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getAcceptedIssuers(): Array<X509Certificate> = defaultTrust.acceptedIssuers
|
|
||||||
}
|
|
||||||
|
|
||||||
val context = SSLContext.getInstance("TLS")
|
|
||||||
context.init(null, arrayOf(trustManager), SecureRandom())
|
|
||||||
return context.socketFactory.createSocket()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun startTlsHandshakeIfNeeded(socket: Socket) {
|
|
||||||
if (socket is SSLSocket) {
|
|
||||||
socket.startHandshake()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun defaultTrustManager(): X509TrustManager {
|
|
||||||
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
|
||||||
factory.init(null as java.security.KeyStore?)
|
|
||||||
val trust =
|
|
||||||
factory.trustManagers.firstOrNull { it is X509TrustManager } as? X509TrustManager
|
|
||||||
return trust ?: throw IllegalStateException("No default X509TrustManager found")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sha256Hex(data: ByteArray): String {
|
|
||||||
val digest = MessageDigest.getInstance("SHA-256").digest(data)
|
|
||||||
val out = StringBuilder(digest.size * 2)
|
|
||||||
for (byte in digest) {
|
|
||||||
out.append(String.format("%02x", byte))
|
|
||||||
}
|
|
||||||
return out.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun normalizeFingerprint(raw: String): String {
|
|
||||||
return raw.lowercase().filter { it in '0'..'9' || it in 'a'..'f' }
|
|
||||||
}
|
|
||||||
@@ -10,36 +10,10 @@ actor BridgeClient {
|
|||||||
func pairAndHello(
|
func pairAndHello(
|
||||||
endpoint: NWEndpoint,
|
endpoint: NWEndpoint,
|
||||||
hello: BridgeHello,
|
hello: BridgeHello,
|
||||||
tls: BridgeTLSParams? = nil,
|
|
||||||
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
|
|
||||||
{
|
|
||||||
do {
|
|
||||||
return try await self.pairAndHelloOnce(
|
|
||||||
endpoint: endpoint,
|
|
||||||
hello: hello,
|
|
||||||
tls: tls,
|
|
||||||
onStatus: onStatus)
|
|
||||||
} catch {
|
|
||||||
if let tls, !tls.required {
|
|
||||||
return try await self.pairAndHelloOnce(
|
|
||||||
endpoint: endpoint,
|
|
||||||
hello: hello,
|
|
||||||
tls: nil,
|
|
||||||
onStatus: onStatus)
|
|
||||||
}
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func pairAndHelloOnce(
|
|
||||||
endpoint: NWEndpoint,
|
|
||||||
hello: BridgeHello,
|
|
||||||
tls: BridgeTLSParams?,
|
|
||||||
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
|
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
|
||||||
{
|
{
|
||||||
self.lineBuffer = Data()
|
self.lineBuffer = Data()
|
||||||
let params = self.makeParameters(tls: tls)
|
let connection = NWConnection(to: endpoint, using: .tcp)
|
||||||
let connection = NWConnection(to: endpoint, using: params)
|
|
||||||
let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-client")
|
let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-client")
|
||||||
defer { connection.cancel() }
|
defer { connection.cancel() }
|
||||||
try await self.withTimeout(seconds: 8, purpose: "connect") {
|
try await self.withTimeout(seconds: 8, purpose: "connect") {
|
||||||
@@ -168,18 +142,6 @@ actor BridgeClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeParameters(tls: BridgeTLSParams?) -> NWParameters {
|
|
||||||
if let tlsOptions = makeBridgeTLSOptions(tls) {
|
|
||||||
let tcpOptions = NWProtocolTCP.Options()
|
|
||||||
let params = NWParameters(tls: tlsOptions, tcp: tcpOptions)
|
|
||||||
params.includePeerToPeer = true
|
|
||||||
return params
|
|
||||||
}
|
|
||||||
let params = NWParameters.tcp
|
|
||||||
params.includePeerToPeer = true
|
|
||||||
return params
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct TimeoutError: LocalizedError, Sendable {
|
private struct TimeoutError: LocalizedError, Sendable {
|
||||||
var purpose: String
|
var purpose: String
|
||||||
var seconds: Int
|
var seconds: Int
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ protocol BridgePairingClient: Sendable {
|
|||||||
func pairAndHello(
|
func pairAndHello(
|
||||||
endpoint: NWEndpoint,
|
endpoint: NWEndpoint,
|
||||||
hello: BridgeHello,
|
hello: BridgeHello,
|
||||||
tls: BridgeTLSParams?,
|
|
||||||
onStatus: (@Sendable (String) -> Void)?) async throws -> String
|
onStatus: (@Sendable (String) -> Void)?) async throws -> String
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,12 +115,9 @@ final class BridgeConnectionController {
|
|||||||
|
|
||||||
self.didAutoConnect = true
|
self.didAutoConnect = true
|
||||||
let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(manualHost), port: port)
|
let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(manualHost), port: port)
|
||||||
let stableID = BridgeEndpointID.stableID(endpoint)
|
|
||||||
let tlsParams = self.resolveManualTLSParams(stableID: stableID)
|
|
||||||
self.startAutoConnect(
|
self.startAutoConnect(
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
bridgeStableID: stableID,
|
bridgeStableID: BridgeEndpointID.stableID(endpoint),
|
||||||
tls: tlsParams,
|
|
||||||
token: token,
|
token: token,
|
||||||
instanceId: instanceId)
|
instanceId: instanceId)
|
||||||
return
|
return
|
||||||
@@ -139,12 +135,10 @@ final class BridgeConnectionController {
|
|||||||
|
|
||||||
guard let target = self.bridges.first(where: { $0.stableID == targetStableID }) else { return }
|
guard let target = self.bridges.first(where: { $0.stableID == targetStableID }) else { return }
|
||||||
|
|
||||||
let tlsParams = self.resolveDiscoveredTLSParams(bridge: target)
|
|
||||||
self.didAutoConnect = true
|
self.didAutoConnect = true
|
||||||
self.startAutoConnect(
|
self.startAutoConnect(
|
||||||
endpoint: target.endpoint,
|
endpoint: target.endpoint,
|
||||||
bridgeStableID: target.stableID,
|
bridgeStableID: target.stableID,
|
||||||
tls: tlsParams,
|
|
||||||
token: token,
|
token: token,
|
||||||
instanceId: instanceId)
|
instanceId: instanceId)
|
||||||
}
|
}
|
||||||
@@ -188,7 +182,6 @@ final class BridgeConnectionController {
|
|||||||
private func startAutoConnect(
|
private func startAutoConnect(
|
||||||
endpoint: NWEndpoint,
|
endpoint: NWEndpoint,
|
||||||
bridgeStableID: String,
|
bridgeStableID: String,
|
||||||
tls: BridgeTLSParams?,
|
|
||||||
token: String,
|
token: String,
|
||||||
instanceId: String)
|
instanceId: String)
|
||||||
{
|
{
|
||||||
@@ -200,7 +193,6 @@ final class BridgeConnectionController {
|
|||||||
let refreshed = try await self.bridgeClientFactory().pairAndHello(
|
let refreshed = try await self.bridgeClientFactory().pairAndHello(
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
hello: hello,
|
hello: hello,
|
||||||
tls: tls,
|
|
||||||
onStatus: { status in
|
onStatus: { status in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
appModel.bridgeStatusText = status
|
appModel.bridgeStatusText = status
|
||||||
@@ -216,7 +208,6 @@ final class BridgeConnectionController {
|
|||||||
appModel.connectToBridge(
|
appModel.connectToBridge(
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
bridgeStableID: bridgeStableID,
|
bridgeStableID: bridgeStableID,
|
||||||
tls: tls,
|
|
||||||
hello: self.makeHello(token: resolvedToken))
|
hello: self.makeHello(token: resolvedToken))
|
||||||
} catch {
|
} catch {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
@@ -226,47 +217,6 @@ final class BridgeConnectionController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveDiscoveredTLSParams(
|
|
||||||
bridge: BridgeDiscoveryModel.DiscoveredBridge) -> BridgeTLSParams?
|
|
||||||
{
|
|
||||||
let stableID = bridge.stableID
|
|
||||||
let stored = BridgeTLSStore.loadFingerprint(stableID: stableID)
|
|
||||||
|
|
||||||
if bridge.tlsEnabled || bridge.tlsFingerprintSha256 != nil {
|
|
||||||
return BridgeTLSParams(
|
|
||||||
required: true,
|
|
||||||
expectedFingerprint: bridge.tlsFingerprintSha256 ?? stored,
|
|
||||||
allowTOFU: stored == nil,
|
|
||||||
storeKey: stableID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let stored {
|
|
||||||
return BridgeTLSParams(
|
|
||||||
required: true,
|
|
||||||
expectedFingerprint: stored,
|
|
||||||
allowTOFU: false,
|
|
||||||
storeKey: stableID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resolveManualTLSParams(stableID: String) -> BridgeTLSParams? {
|
|
||||||
if let stored = BridgeTLSStore.loadFingerprint(stableID: stableID) {
|
|
||||||
return BridgeTLSParams(
|
|
||||||
required: true,
|
|
||||||
expectedFingerprint: stored,
|
|
||||||
allowTOFU: false,
|
|
||||||
storeKey: stableID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return BridgeTLSParams(
|
|
||||||
required: false,
|
|
||||||
expectedFingerprint: nil,
|
|
||||||
allowTOFU: true,
|
|
||||||
storeKey: stableID)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resolvedDisplayName(defaults: UserDefaults) -> String {
|
private func resolvedDisplayName(defaults: UserDefaults) -> String {
|
||||||
let key = "node.displayName"
|
let key = "node.displayName"
|
||||||
let existing = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let existing = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ final class BridgeDiscoveryModel {
|
|||||||
var gatewayPort: Int?
|
var gatewayPort: Int?
|
||||||
var bridgePort: Int?
|
var bridgePort: Int?
|
||||||
var canvasPort: Int?
|
var canvasPort: Int?
|
||||||
var tlsEnabled: Bool
|
|
||||||
var tlsFingerprintSha256: String?
|
|
||||||
var cliPath: String?
|
var cliPath: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,8 +90,6 @@ final class BridgeDiscoveryModel {
|
|||||||
gatewayPort: Self.txtIntValue(txt, key: "gatewayPort"),
|
gatewayPort: Self.txtIntValue(txt, key: "gatewayPort"),
|
||||||
bridgePort: Self.txtIntValue(txt, key: "bridgePort"),
|
bridgePort: Self.txtIntValue(txt, key: "bridgePort"),
|
||||||
canvasPort: Self.txtIntValue(txt, key: "canvasPort"),
|
canvasPort: Self.txtIntValue(txt, key: "canvasPort"),
|
||||||
tlsEnabled: Self.txtBoolValue(txt, key: "bridgeTls"),
|
|
||||||
tlsFingerprintSha256: Self.txtValue(txt, key: "bridgeTlsSha256"),
|
|
||||||
cliPath: Self.txtValue(txt, key: "cliPath"))
|
cliPath: Self.txtValue(txt, key: "cliPath"))
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
@@ -218,9 +214,4 @@ final class BridgeDiscoveryModel {
|
|||||||
guard let raw = self.txtValue(dict, key: key) else { return nil }
|
guard let raw = self.txtValue(dict, key: key) else { return nil }
|
||||||
return Int(raw)
|
return Int(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func txtBoolValue(_ dict: [String: String], key: String) -> Bool {
|
|
||||||
guard let raw = self.txtValue(dict, key: key)?.lowercased() else { return false }
|
|
||||||
return raw == "1" || raw == "true" || raw == "yes"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -69,42 +69,15 @@ actor BridgeSession {
|
|||||||
func connect(
|
func connect(
|
||||||
endpoint: NWEndpoint,
|
endpoint: NWEndpoint,
|
||||||
hello: BridgeHello,
|
hello: BridgeHello,
|
||||||
tls: BridgeTLSParams? = nil,
|
|
||||||
onConnected: (@Sendable (String, String?) async -> Void)? = nil,
|
onConnected: (@Sendable (String, String?) async -> Void)? = nil,
|
||||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
|
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
|
||||||
async throws
|
async throws
|
||||||
{
|
{
|
||||||
await self.disconnect()
|
await self.disconnect()
|
||||||
self.state = .connecting
|
self.state = .connecting
|
||||||
do {
|
|
||||||
try await self.connectOnce(
|
|
||||||
endpoint: endpoint,
|
|
||||||
hello: hello,
|
|
||||||
tls: tls,
|
|
||||||
onConnected: onConnected,
|
|
||||||
onInvoke: onInvoke)
|
|
||||||
} catch {
|
|
||||||
if let tls, !tls.required {
|
|
||||||
try await self.connectOnce(
|
|
||||||
endpoint: endpoint,
|
|
||||||
hello: hello,
|
|
||||||
tls: nil,
|
|
||||||
onConnected: onConnected,
|
|
||||||
onInvoke: onInvoke)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func connectOnce(
|
let params = NWParameters.tcp
|
||||||
endpoint: NWEndpoint,
|
params.includePeerToPeer = true
|
||||||
hello: BridgeHello,
|
|
||||||
tls: BridgeTLSParams?,
|
|
||||||
onConnected: (@Sendable (String, String?) async -> Void)?,
|
|
||||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async throws
|
|
||||||
{
|
|
||||||
let params = self.makeParameters(tls: tls)
|
|
||||||
let connection = NWConnection(to: endpoint, using: params)
|
let connection = NWConnection(to: endpoint, using: params)
|
||||||
let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-session")
|
let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-session")
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
@@ -282,18 +255,6 @@ actor BridgeSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeParameters(tls: BridgeTLSParams?) -> NWParameters {
|
|
||||||
if let tlsOptions = makeBridgeTLSOptions(tls) {
|
|
||||||
let tcpOptions = NWProtocolTCP.Options()
|
|
||||||
let params = NWParameters(tls: tlsOptions, tcp: tcpOptions)
|
|
||||||
params.includePeerToPeer = true
|
|
||||||
return params
|
|
||||||
}
|
|
||||||
let params = NWParameters.tcp
|
|
||||||
params.includePeerToPeer = true
|
|
||||||
return params
|
|
||||||
}
|
|
||||||
|
|
||||||
private func timeoutRPC(id: String) async {
|
private func timeoutRPC(id: String) async {
|
||||||
guard let cont = self.pendingRPC.removeValue(forKey: id) else { return }
|
guard let cont = self.pendingRPC.removeValue(forKey: id) else { return }
|
||||||
cont.resume(throwing: NSError(domain: "Bridge", code: 15, userInfo: [
|
cont.resume(throwing: NSError(domain: "Bridge", code: 15, userInfo: [
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
import CryptoKit
|
|
||||||
import Foundation
|
|
||||||
import Network
|
|
||||||
import Security
|
|
||||||
|
|
||||||
struct BridgeTLSParams: Sendable {
|
|
||||||
let required: Bool
|
|
||||||
let expectedFingerprint: String?
|
|
||||||
let allowTOFU: Bool
|
|
||||||
let storeKey: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
enum BridgeTLSStore {
|
|
||||||
private static let service = "com.clawdbot.bridge.tls"
|
|
||||||
|
|
||||||
static func loadFingerprint(stableID: String) -> String? {
|
|
||||||
KeychainStore.loadString(service: service, account: stableID)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func saveFingerprint(_ value: String, stableID: String) {
|
|
||||||
_ = KeychainStore.saveString(value, service: service, account: stableID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeBridgeTLSOptions(_ params: BridgeTLSParams?) -> NWProtocolTLS.Options? {
|
|
||||||
guard let params else { return nil }
|
|
||||||
let options = NWProtocolTLS.Options()
|
|
||||||
let expected = params.expectedFingerprint.map(normalizeBridgeFingerprint)
|
|
||||||
let allowTOFU = params.allowTOFU
|
|
||||||
let storeKey = params.storeKey
|
|
||||||
|
|
||||||
sec_protocol_options_set_verify_block(
|
|
||||||
options.securityProtocolOptions,
|
|
||||||
{ _, trust, complete in
|
|
||||||
guard let trust else {
|
|
||||||
complete(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if let cert = SecTrustGetCertificateAtIndex(trust, 0) {
|
|
||||||
let data = SecCertificateCopyData(cert) as Data
|
|
||||||
let fingerprint = sha256Hex(data)
|
|
||||||
if let expected {
|
|
||||||
complete(fingerprint == expected)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if allowTOFU {
|
|
||||||
if let storeKey { BridgeTLSStore.saveFingerprint(fingerprint, stableID: storeKey) }
|
|
||||||
complete(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let ok = SecTrustEvaluateWithError(trust, nil)
|
|
||||||
complete(ok)
|
|
||||||
},
|
|
||||||
DispatchQueue(label: "com.clawdbot.bridge.tls.verify"))
|
|
||||||
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
|
|
||||||
private func sha256Hex(_ data: Data) -> String {
|
|
||||||
let digest = SHA256.hash(data: data)
|
|
||||||
return digest.map { String(format: "%02x", $0) }.joined()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func normalizeBridgeFingerprint(_ raw: String) -> String {
|
|
||||||
raw.lowercased().filter { $0.isHexDigit }
|
|
||||||
}
|
|
||||||
@@ -205,7 +205,6 @@ final class NodeAppModel {
|
|||||||
func connectToBridge(
|
func connectToBridge(
|
||||||
endpoint: NWEndpoint,
|
endpoint: NWEndpoint,
|
||||||
bridgeStableID: String,
|
bridgeStableID: String,
|
||||||
tls: BridgeTLSParams?,
|
|
||||||
hello: BridgeHello)
|
hello: BridgeHello)
|
||||||
{
|
{
|
||||||
self.bridgeTask?.cancel()
|
self.bridgeTask?.cancel()
|
||||||
@@ -233,7 +232,6 @@ final class NodeAppModel {
|
|||||||
try await self.bridge.connect(
|
try await self.bridge.connect(
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
hello: hello,
|
hello: hello,
|
||||||
tls: tls,
|
|
||||||
onConnected: { [weak self] serverName, mainSessionKey in
|
onConnected: { [weak self] serverName, mainSessionKey in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
|
|||||||
@@ -407,11 +407,9 @@ struct SettingsTab: View {
|
|||||||
modelIdentifier: self.modelIdentifier(),
|
modelIdentifier: self.modelIdentifier(),
|
||||||
caps: self.currentCaps(),
|
caps: self.currentCaps(),
|
||||||
commands: self.currentCommands())
|
commands: self.currentCommands())
|
||||||
let tlsParams = self.resolveDiscoveredTLSParams(bridge: bridge)
|
|
||||||
let token = try await BridgeClient().pairAndHello(
|
let token = try await BridgeClient().pairAndHello(
|
||||||
endpoint: bridge.endpoint,
|
endpoint: bridge.endpoint,
|
||||||
hello: hello,
|
hello: hello,
|
||||||
tls: tlsParams,
|
|
||||||
onStatus: { status in
|
onStatus: { status in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
statusStore.text = status
|
statusStore.text = status
|
||||||
@@ -428,7 +426,6 @@ struct SettingsTab: View {
|
|||||||
self.appModel.connectToBridge(
|
self.appModel.connectToBridge(
|
||||||
endpoint: bridge.endpoint,
|
endpoint: bridge.endpoint,
|
||||||
bridgeStableID: bridge.stableID,
|
bridgeStableID: bridge.stableID,
|
||||||
tls: tlsParams,
|
|
||||||
hello: BridgeHello(
|
hello: BridgeHello(
|
||||||
nodeId: self.instanceId,
|
nodeId: self.instanceId,
|
||||||
displayName: self.displayName,
|
displayName: self.displayName,
|
||||||
@@ -465,8 +462,6 @@ struct SettingsTab: View {
|
|||||||
defer { self.connectingBridgeID = nil }
|
defer { self.connectingBridgeID = nil }
|
||||||
|
|
||||||
let endpoint: NWEndpoint = .hostPort(host: NWEndpoint.Host(host), port: port)
|
let endpoint: NWEndpoint = .hostPort(host: NWEndpoint.Host(host), port: port)
|
||||||
let stableID = BridgeEndpointID.stableID(endpoint)
|
|
||||||
let tlsParams = self.resolveManualTLSParams(stableID: stableID)
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let statusStore = self.connectStatus
|
let statusStore = self.connectStatus
|
||||||
@@ -490,7 +485,6 @@ struct SettingsTab: View {
|
|||||||
let token = try await BridgeClient().pairAndHello(
|
let token = try await BridgeClient().pairAndHello(
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
hello: hello,
|
hello: hello,
|
||||||
tls: tlsParams,
|
|
||||||
onStatus: { status in
|
onStatus: { status in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
statusStore.text = status
|
statusStore.text = status
|
||||||
@@ -506,8 +500,7 @@ struct SettingsTab: View {
|
|||||||
|
|
||||||
self.appModel.connectToBridge(
|
self.appModel.connectToBridge(
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
bridgeStableID: stableID,
|
bridgeStableID: BridgeEndpointID.stableID(endpoint),
|
||||||
tls: tlsParams,
|
|
||||||
hello: BridgeHello(
|
hello: BridgeHello(
|
||||||
nodeId: self.instanceId,
|
nodeId: self.instanceId,
|
||||||
displayName: self.displayName,
|
displayName: self.displayName,
|
||||||
@@ -524,47 +517,6 @@ struct SettingsTab: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveDiscoveredTLSParams(
|
|
||||||
bridge: BridgeDiscoveryModel.DiscoveredBridge) -> BridgeTLSParams?
|
|
||||||
{
|
|
||||||
let stableID = bridge.stableID
|
|
||||||
let stored = BridgeTLSStore.loadFingerprint(stableID: stableID)
|
|
||||||
|
|
||||||
if bridge.tlsEnabled || bridge.tlsFingerprintSha256 != nil {
|
|
||||||
return BridgeTLSParams(
|
|
||||||
required: true,
|
|
||||||
expectedFingerprint: bridge.tlsFingerprintSha256 ?? stored,
|
|
||||||
allowTOFU: stored == nil,
|
|
||||||
storeKey: stableID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let stored {
|
|
||||||
return BridgeTLSParams(
|
|
||||||
required: true,
|
|
||||||
expectedFingerprint: stored,
|
|
||||||
allowTOFU: false,
|
|
||||||
storeKey: stableID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resolveManualTLSParams(stableID: String) -> BridgeTLSParams? {
|
|
||||||
if let stored = BridgeTLSStore.loadFingerprint(stableID: stableID) {
|
|
||||||
return BridgeTLSParams(
|
|
||||||
required: true,
|
|
||||||
expectedFingerprint: stored,
|
|
||||||
allowTOFU: false,
|
|
||||||
storeKey: stableID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return BridgeTLSParams(
|
|
||||||
required: false,
|
|
||||||
expectedFingerprint: nil,
|
|
||||||
allowTOFU: true,
|
|
||||||
storeKey: stableID)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func primaryIPv4Address() -> String? {
|
private static func primaryIPv4Address() -> String? {
|
||||||
var addrList: UnsafeMutablePointer<ifaddrs>?
|
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ private actor MockBridgePairingClient: BridgePairingClient {
|
|||||||
func pairAndHello(
|
func pairAndHello(
|
||||||
endpoint: NWEndpoint,
|
endpoint: NWEndpoint,
|
||||||
hello: BridgeHello,
|
hello: BridgeHello,
|
||||||
tls: BridgeTLSParams?,
|
|
||||||
onStatus: (@Sendable (String) -> Void)?) async throws -> String
|
onStatus: (@Sendable (String) -> Void)?) async throws -> String
|
||||||
{
|
{
|
||||||
self.lastToken = hello.token
|
self.lastToken = hello.token
|
||||||
@@ -245,8 +244,6 @@ private func withKeychainValues<T>(
|
|||||||
gatewayPort: 18789,
|
gatewayPort: 18789,
|
||||||
bridgePort: 18790,
|
bridgePort: 18790,
|
||||||
canvasPort: 18793,
|
canvasPort: 18793,
|
||||||
tlsEnabled: false,
|
|
||||||
tlsFingerprintSha256: nil,
|
|
||||||
cliPath: nil)
|
cliPath: nil)
|
||||||
let mock = MockBridgePairingClient(resultToken: "new-token")
|
let mock = MockBridgePairingClient(resultToken: "new-token")
|
||||||
let account = "bridge-token.ios-test"
|
let account = "bridge-token.ios-test"
|
||||||
@@ -295,8 +292,6 @@ private func withKeychainValues<T>(
|
|||||||
gatewayPort: 18789,
|
gatewayPort: 18789,
|
||||||
bridgePort: 18790,
|
bridgePort: 18790,
|
||||||
canvasPort: 18793,
|
canvasPort: 18793,
|
||||||
tlsEnabled: false,
|
|
||||||
tlsFingerprintSha256: nil,
|
|
||||||
cliPath: nil)
|
cliPath: nil)
|
||||||
let bridgeB = BridgeDiscoveryModel.DiscoveredBridge(
|
let bridgeB = BridgeDiscoveryModel.DiscoveredBridge(
|
||||||
name: "Gateway B",
|
name: "Gateway B",
|
||||||
@@ -308,8 +303,6 @@ private func withKeychainValues<T>(
|
|||||||
gatewayPort: 28789,
|
gatewayPort: 28789,
|
||||||
bridgePort: 28790,
|
bridgePort: 28790,
|
||||||
canvasPort: 28793,
|
canvasPort: 28793,
|
||||||
tlsEnabled: false,
|
|
||||||
tlsFingerprintSha256: nil,
|
|
||||||
cliPath: nil)
|
cliPath: nil)
|
||||||
|
|
||||||
let mock = MockBridgePairingClient(resultToken: "token-ok")
|
let mock = MockBridgePairingClient(resultToken: "token-ok")
|
||||||
|
|||||||
@@ -170,10 +170,6 @@ final class AppState {
|
|||||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
|
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
var systemRunPolicy: SystemRunPolicy {
|
|
||||||
didSet { self.ifNotPreview { MacNodeConfigFile.setSystemRunPolicy(self.systemRunPolicy) } }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Tracks whether the Canvas panel is currently visible (not persisted).
|
/// Tracks whether the Canvas panel is currently visible (not persisted).
|
||||||
var canvasPanelVisible: Bool = false
|
var canvasPanelVisible: Bool = false
|
||||||
|
|
||||||
@@ -296,7 +292,6 @@ final class AppState {
|
|||||||
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
||||||
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
|
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
|
||||||
self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
||||||
self.systemRunPolicy = SystemRunPolicy.load()
|
|
||||||
self.peekabooBridgeEnabled = UserDefaults.standard
|
self.peekabooBridgeEnabled = UserDefaults.standard
|
||||||
.object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true
|
.object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true
|
||||||
if !self.isPreview {
|
if !self.isPreview {
|
||||||
|
|||||||
@@ -26,9 +26,6 @@ let remoteProjectRootKey = "clawdbot.remoteProjectRoot"
|
|||||||
let remoteCliPathKey = "clawdbot.remoteCliPath"
|
let remoteCliPathKey = "clawdbot.remoteCliPath"
|
||||||
let canvasEnabledKey = "clawdbot.canvasEnabled"
|
let canvasEnabledKey = "clawdbot.canvasEnabled"
|
||||||
let cameraEnabledKey = "clawdbot.cameraEnabled"
|
let cameraEnabledKey = "clawdbot.cameraEnabled"
|
||||||
let systemRunPolicyKey = "clawdbot.systemRunPolicy"
|
|
||||||
let systemRunAllowlistKey = "clawdbot.systemRunAllowlist"
|
|
||||||
let systemRunEnabledKey = "clawdbot.systemRunEnabled"
|
|
||||||
let locationModeKey = "clawdbot.locationMode"
|
let locationModeKey = "clawdbot.locationMode"
|
||||||
let locationPreciseKey = "clawdbot.locationPreciseEnabled"
|
let locationPreciseKey = "clawdbot.locationPreciseEnabled"
|
||||||
let peekabooBridgeEnabledKey = "clawdbot.peekabooBridgeEnabled"
|
let peekabooBridgeEnabledKey = "clawdbot.peekabooBridgeEnabled"
|
||||||
|
|||||||
@@ -2,16 +2,77 @@ import Foundation
|
|||||||
|
|
||||||
enum GatewayLaunchAgentManager {
|
enum GatewayLaunchAgentManager {
|
||||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.launchd")
|
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.launchd")
|
||||||
|
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
|
||||||
|
private static let legacyGatewayLaunchdLabel = "com.steipete.clawdbot.gateway"
|
||||||
private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent"
|
private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent"
|
||||||
|
|
||||||
|
private enum GatewayProgramArgumentsError: LocalizedError {
|
||||||
|
case message(String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case let .message(message):
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static var plistURL: URL {
|
private static var plistURL: URL {
|
||||||
FileManager.default.homeDirectoryForCurrentUser
|
FileManager.default.homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist")
|
.appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static var legacyPlistURL: URL {
|
||||||
|
FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
.appendingPathComponent("Library/LaunchAgents/\(legacyGatewayLaunchdLabel).plist")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func gatewayProgramArguments(
|
||||||
|
port: Int,
|
||||||
|
bind: String) -> Result<[String], GatewayProgramArgumentsError>
|
||||||
|
{
|
||||||
|
let projectRoot = CommandResolver.projectRoot()
|
||||||
|
#if DEBUG
|
||||||
|
if let localBin = CommandResolver.projectClawdbotExecutable(projectRoot: projectRoot) {
|
||||||
|
return .success([localBin, "gateway-daemon", "--port", "\(port)", "--bind", bind])
|
||||||
|
}
|
||||||
|
if let entry = CommandResolver.gatewayEntrypoint(in: projectRoot) {
|
||||||
|
switch CommandResolver.runtimeResolution() {
|
||||||
|
case let .success(runtime):
|
||||||
|
let cmd = CommandResolver.makeRuntimeCommand(
|
||||||
|
runtime: runtime,
|
||||||
|
entrypoint: entry,
|
||||||
|
subcommand: "gateway-daemon",
|
||||||
|
extraArgs: ["--port", "\(port)", "--bind", bind])
|
||||||
|
return .success(cmd)
|
||||||
|
case .failure:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
let searchPaths = CommandResolver.preferredPaths()
|
||||||
|
if let gatewayBin = CommandResolver.clawdbotExecutable(searchPaths: searchPaths) {
|
||||||
|
return .success([gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind])
|
||||||
|
}
|
||||||
|
|
||||||
|
if let entry = CommandResolver.gatewayEntrypoint(in: projectRoot),
|
||||||
|
case let .success(runtime) = CommandResolver.runtimeResolution(searchPaths: searchPaths)
|
||||||
|
{
|
||||||
|
let cmd = CommandResolver.makeRuntimeCommand(
|
||||||
|
runtime: runtime,
|
||||||
|
entrypoint: entry,
|
||||||
|
subcommand: "gateway-daemon",
|
||||||
|
extraArgs: ["--port", "\(port)", "--bind", bind])
|
||||||
|
return .success(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
return .failure(.message("clawdbot CLI not found in PATH; install the CLI."))
|
||||||
|
}
|
||||||
|
|
||||||
static func isLoaded() async -> Bool {
|
static func isLoaded() async -> Bool {
|
||||||
guard let loaded = await self.readDaemonLoaded() else { return false }
|
guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false }
|
||||||
return loaded
|
let result = await Launchctl.run(["print", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||||
|
return result.status == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
|
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
|
||||||
@@ -20,44 +81,255 @@ enum GatewayLaunchAgentManager {
|
|||||||
self.logger.info("launchd enable skipped (disable marker set)")
|
self.logger.info("launchd enable skipped (disable marker set)")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if enabled {
|
if enabled {
|
||||||
self.logger.info("launchd enable requested via CLI port=\(port)")
|
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"])
|
||||||
return await self.runDaemonCommand([
|
try? FileManager.default.removeItem(at: self.legacyPlistURL)
|
||||||
"install",
|
|
||||||
"--force",
|
let desiredBind = self.preferredGatewayBind() ?? "loopback"
|
||||||
"--port",
|
let desiredToken = self.preferredGatewayToken()
|
||||||
"\(port)",
|
let desiredPassword = self.preferredGatewayPassword()
|
||||||
"--runtime",
|
let desiredConfig = DesiredConfig(
|
||||||
"node",
|
port: port,
|
||||||
])
|
bind: desiredBind,
|
||||||
|
token: desiredToken,
|
||||||
|
password: desiredPassword)
|
||||||
|
let programArgumentsResult = self.gatewayProgramArguments(port: port, bind: desiredBind)
|
||||||
|
guard case let .success(programArguments) = programArgumentsResult else {
|
||||||
|
if case let .failure(error) = programArgumentsResult {
|
||||||
|
let message = error.localizedDescription
|
||||||
|
self.logger.error("launchd enable failed: \(message)")
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
return "Failed to resolve gateway command."
|
||||||
}
|
}
|
||||||
|
|
||||||
self.logger.info("launchd disable requested via CLI")
|
// If launchd already loaded the job (common on login), avoid `bootout` unless we must
|
||||||
return await self.runDaemonCommand(["uninstall"])
|
// change the config. `bootout` can kill a just-started gateway and cause attach loops.
|
||||||
|
let loaded = await self.isLoaded()
|
||||||
|
if loaded {
|
||||||
|
if let existing = self.readPlistConfig(), existing.matches(desiredConfig) {
|
||||||
|
self.logger.info("launchd job already loaded with desired config; skipping bootout")
|
||||||
|
await self.ensureEnabled()
|
||||||
|
_ = await Launchctl.run(["kickstart", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.logger.info("launchd enable requested port=\(port) bind=\(desiredBind)")
|
||||||
|
self.writePlist(programArguments: programArguments)
|
||||||
|
|
||||||
|
await self.ensureEnabled()
|
||||||
|
if loaded {
|
||||||
|
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||||
|
}
|
||||||
|
let bootstrap = await Launchctl.run(["bootstrap", "gui/\(getuid())", self.plistURL.path])
|
||||||
|
if bootstrap.status != 0 {
|
||||||
|
let msg = bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
self.logger.error("launchd bootstrap failed: \(msg)")
|
||||||
|
return bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
? "Failed to bootstrap gateway launchd job"
|
||||||
|
: bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
await self.ensureEnabled()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
self.logger.info("launchd disable requested")
|
||||||
|
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||||
|
await self.ensureDisabled()
|
||||||
|
try? FileManager.default.removeItem(at: self.plistURL)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
static func kickstart() async {
|
static func kickstart() async {
|
||||||
_ = await self.runDaemonCommand(["restart"], timeout: 20)
|
_ = await Launchctl.run(["kickstart", "-k", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func writePlist(programArguments: [String]) {
|
||||||
|
let preferredPath = CommandResolver.preferredPaths().joined(separator: ":")
|
||||||
|
let token = self.preferredGatewayToken()
|
||||||
|
let password = self.preferredGatewayPassword()
|
||||||
|
var envEntries = """
|
||||||
|
<key>PATH</key>
|
||||||
|
<string>\(preferredPath)</string>
|
||||||
|
"""
|
||||||
|
if let token {
|
||||||
|
let escapedToken = self.escapePlistValue(token)
|
||||||
|
envEntries += """
|
||||||
|
<key>CLAWDBOT_GATEWAY_TOKEN</key>
|
||||||
|
<string>\(escapedToken)</string>
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
if let password {
|
||||||
|
let escapedPassword = self.escapePlistValue(password)
|
||||||
|
envEntries += """
|
||||||
|
<key>CLAWDBOT_GATEWAY_PASSWORD</key>
|
||||||
|
<string>\(escapedPassword)</string>
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
let argsXml = programArguments
|
||||||
|
.map { "<string>\(self.escapePlistValue($0))</string>" }
|
||||||
|
.joined(separator: "\n ")
|
||||||
|
let plist = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>\(gatewayLaunchdLabel)</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
\(argsXml)
|
||||||
|
</array>
|
||||||
|
<key>WorkingDirectory</key>
|
||||||
|
<string>\(FileManager.default.homeDirectoryForCurrentUser.path)</string>
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<true/>
|
||||||
|
<key>EnvironmentVariables</key>
|
||||||
|
<dict>
|
||||||
|
\(envEntries)
|
||||||
|
</dict>
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>\(LogLocator.launchdGatewayLogPath)</string>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>\(LogLocator.launchdGatewayLogPath)</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
"""
|
||||||
|
do {
|
||||||
|
try plist.write(to: self.plistURL, atomically: true, encoding: .utf8)
|
||||||
|
} catch {
|
||||||
|
self.logger.error("launchd plist write failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func preferredGatewayBind() -> String? {
|
||||||
|
if CommandResolver.connectionModeIsRemote() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if let env = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_BIND"] {
|
||||||
|
let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
if self.supportedBindModes.contains(trimmed) {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let root = ClawdbotConfigFile.loadDict()
|
||||||
|
if let gateway = root["gateway"] as? [String: Any],
|
||||||
|
let bind = gateway["bind"] as? String
|
||||||
|
{
|
||||||
|
let trimmed = bind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
if self.supportedBindModes.contains(trimmed) {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func preferredGatewayToken() -> String? {
|
||||||
|
let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_TOKEN"] ?? ""
|
||||||
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !trimmed.isEmpty {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
let root = ClawdbotConfigFile.loadDict()
|
||||||
|
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)
|
||||||
|
if !value.isEmpty {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func preferredGatewayPassword() -> String? {
|
||||||
|
// First check environment variable
|
||||||
|
let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_PASSWORD"] ?? ""
|
||||||
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !trimmed.isEmpty {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
// Then check config file (gateway.auth.password)
|
||||||
|
let root = ClawdbotConfigFile.loadDict()
|
||||||
|
if let gateway = root["gateway"] as? [String: Any],
|
||||||
|
let auth = gateway["auth"] as? [String: Any],
|
||||||
|
let password = auth["password"] as? String
|
||||||
|
{
|
||||||
|
return password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func escapePlistValue(_ raw: String) -> String {
|
||||||
|
raw
|
||||||
|
.replacingOccurrences(of: "&", with: "&")
|
||||||
|
.replacingOccurrences(of: "<", with: "<")
|
||||||
|
.replacingOccurrences(of: ">", with: ">")
|
||||||
|
.replacingOccurrences(of: "\"", with: """)
|
||||||
|
.replacingOccurrences(of: "'", with: "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DesiredConfig: Equatable {
|
||||||
|
let port: Int
|
||||||
|
let bind: String
|
||||||
|
let token: String?
|
||||||
|
let password: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct InstalledConfig: Equatable {
|
||||||
|
let port: Int?
|
||||||
|
let bind: String?
|
||||||
|
let token: String?
|
||||||
|
let password: String?
|
||||||
|
|
||||||
|
func matches(_ desired: DesiredConfig) -> Bool {
|
||||||
|
guard self.port == desired.port else { return false }
|
||||||
|
guard (self.bind ?? "loopback") == desired.bind else { return false }
|
||||||
|
guard self.token == desired.token else { return false }
|
||||||
|
guard self.password == desired.password else { return false }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func readPlistConfig() -> InstalledConfig? {
|
||||||
|
guard let snapshot = LaunchAgentPlist.snapshot(url: self.plistURL) else { return nil }
|
||||||
|
return InstalledConfig(
|
||||||
|
port: snapshot.port,
|
||||||
|
bind: snapshot.bind,
|
||||||
|
token: snapshot.token,
|
||||||
|
password: snapshot.password)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func launchdConfigSnapshot() -> LaunchAgentPlistSnapshot? {
|
static func launchdConfigSnapshot() -> LaunchAgentPlistSnapshot? {
|
||||||
LaunchAgentPlist.snapshot(url: self.plistURL)
|
LaunchAgentPlist.snapshot(url: self.plistURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func launchdGatewayLogPath() -> String {
|
private static func ensureEnabled() async {
|
||||||
let snapshot = self.launchdConfigSnapshot()
|
let result = await Launchctl.run(["enable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||||
if let stdout = snapshot?.stdoutPath?.trimmingCharacters(in: .whitespacesAndNewlines),
|
guard result.status != 0 else { return }
|
||||||
!stdout.isEmpty
|
let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
{
|
if msg.isEmpty {
|
||||||
return stdout
|
self.logger.warning("launchd enable failed")
|
||||||
|
} else {
|
||||||
|
self.logger.warning("launchd enable failed: \(msg)")
|
||||||
}
|
}
|
||||||
if let stderr = snapshot?.stderrPath?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
||||||
!stderr.isEmpty
|
|
||||||
{
|
|
||||||
return stderr
|
|
||||||
}
|
}
|
||||||
return LogLocator.launchdGatewayLogPath
|
|
||||||
|
private static func ensureDisabled() async {
|
||||||
|
let result = await Launchctl.run(["disable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||||
|
guard result.status != 0 else { return }
|
||||||
|
let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if msg.isEmpty {
|
||||||
|
self.logger.warning("launchd disable failed")
|
||||||
|
} else {
|
||||||
|
self.logger.warning("launchd disable failed: \(msg)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,99 +339,20 @@ extension GatewayLaunchAgentManager {
|
|||||||
.appendingPathComponent(self.disableLaunchAgentMarker)
|
.appendingPathComponent(self.disableLaunchAgentMarker)
|
||||||
return FileManager.default.fileExists(atPath: marker.path)
|
return FileManager.default.fileExists(atPath: marker.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func readDaemonLoaded() async -> Bool? {
|
|
||||||
let result = await self.runDaemonCommand(["status", "--json", "--no-probe"], timeout: 15, quiet: true)
|
|
||||||
guard result.success, let payload = result.payload else { return nil }
|
|
||||||
guard
|
|
||||||
let json = try? JSONSerialization.jsonObject(with: payload) as? [String: Any],
|
|
||||||
let service = json["service"] as? [String: Any],
|
|
||||||
let loaded = service["loaded"] as? Bool
|
|
||||||
else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return loaded
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct CommandResult {
|
#if DEBUG
|
||||||
let success: Bool
|
extension GatewayLaunchAgentManager {
|
||||||
let payload: Data?
|
static func _testPreferredGatewayBind() -> String? {
|
||||||
let message: String?
|
self.preferredGatewayBind()
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ParsedDaemonJson {
|
static func _testPreferredGatewayToken() -> String? {
|
||||||
let text: String
|
self.preferredGatewayToken()
|
||||||
let object: [String: Any]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func runDaemonCommand(
|
static func _testEscapePlistValue(_ raw: String) -> String {
|
||||||
_ args: [String],
|
self.escapePlistValue(raw)
|
||||||
timeout: Double = 15,
|
|
||||||
quiet: Bool = false) async -> String?
|
|
||||||
{
|
|
||||||
let result = await self.runDaemonCommandResult(args, timeout: timeout, quiet: quiet)
|
|
||||||
if result.success { return nil }
|
|
||||||
return result.message ?? "Gateway daemon command failed"
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func runDaemonCommandResult(
|
|
||||||
_ args: [String],
|
|
||||||
timeout: Double,
|
|
||||||
quiet: Bool) async -> CommandResult
|
|
||||||
{
|
|
||||||
let command = CommandResolver.clawdbotCommand(
|
|
||||||
subcommand: "daemon",
|
|
||||||
extraArgs: self.withJsonFlag(args))
|
|
||||||
var env = ProcessInfo.processInfo.environment
|
|
||||||
env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":")
|
|
||||||
let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout)
|
|
||||||
let parsed = self.parseDaemonJson(from: response.stdout) ?? self.parseDaemonJson(from: response.stderr)
|
|
||||||
let ok = parsed?.object["ok"] as? Bool
|
|
||||||
let message = (parsed?.object["error"] as? String) ?? (parsed?.object["message"] as? String)
|
|
||||||
let payload = parsed?.text.data(using: .utf8)
|
|
||||||
?? (response.stdout.isEmpty ? response.stderr : response.stdout).data(using: .utf8)
|
|
||||||
let success = ok ?? response.success
|
|
||||||
if success {
|
|
||||||
return CommandResult(success: true, payload: payload, message: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
if quiet {
|
|
||||||
return CommandResult(success: false, payload: payload, message: message)
|
|
||||||
}
|
|
||||||
|
|
||||||
let detail = message ?? self.summarize(response.stderr) ?? self.summarize(response.stdout)
|
|
||||||
let exit = response.exitCode.map { "exit \($0)" } ?? (response.errorMessage ?? "failed")
|
|
||||||
let fullMessage = detail.map { "Gateway daemon command failed (\(exit)): \($0)" }
|
|
||||||
?? "Gateway daemon command failed (\(exit))"
|
|
||||||
self.logger.error("\(fullMessage, privacy: .public)")
|
|
||||||
return CommandResult(success: false, payload: payload, message: detail)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func withJsonFlag(_ args: [String]) -> [String] {
|
|
||||||
if args.contains("--json") { return args }
|
|
||||||
return args + ["--json"]
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func parseDaemonJson(from raw: String) -> ParsedDaemonJson? {
|
|
||||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard let start = trimmed.firstIndex(of: "{"),
|
|
||||||
let end = trimmed.lastIndex(of: "}")
|
|
||||||
else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let jsonText = String(trimmed[start...end])
|
|
||||||
guard let data = jsonText.data(using: .utf8) else { return nil }
|
|
||||||
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
|
|
||||||
return ParsedDaemonJson(text: jsonText, object: object)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func summarize(_ text: String) -> String? {
|
|
||||||
let lines = text
|
|
||||||
.split(whereSeparator: \.isNewline)
|
|
||||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
||||||
.filter { !$0.isEmpty }
|
|
||||||
guard let last = lines.last else { return nil }
|
|
||||||
let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
|
||||||
return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
@@ -140,7 +140,7 @@ final class GatewayProcessManager {
|
|||||||
|
|
||||||
func refreshLog() {
|
func refreshLog() {
|
||||||
guard self.logRefreshTask == nil else { return }
|
guard self.logRefreshTask == nil else { return }
|
||||||
let path = GatewayLaunchAgentManager.launchdGatewayLogPath()
|
let path = LogLocator.launchdGatewayLogPath
|
||||||
let limit = self.logLimit
|
let limit = self.logLimit
|
||||||
self.logRefreshTask = Task { [weak self] in
|
self.logRefreshTask = Task { [weak self] in
|
||||||
let log = await Task.detached(priority: .utility) {
|
let log = await Task.detached(priority: .utility) {
|
||||||
@@ -354,7 +354,7 @@ final class GatewayProcessManager {
|
|||||||
|
|
||||||
func clearLog() {
|
func clearLog() {
|
||||||
self.log = ""
|
self.log = ""
|
||||||
try? FileManager.default.removeItem(atPath: GatewayLaunchAgentManager.launchdGatewayLogPath())
|
try? FileManager.default.removeItem(atPath: LogLocator.launchdGatewayLogPath)
|
||||||
self.logger.debug("gateway log cleared")
|
self.logger.debug("gateway log cleared")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,25 +83,6 @@ struct GeneralSettings: View {
|
|||||||
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
|
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
|
||||||
binding: self.$cameraEnabled)
|
binding: self.$cameraEnabled)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
Text("Node Run Commands")
|
|
||||||
.font(.body)
|
|
||||||
|
|
||||||
Picker("", selection: self.$state.systemRunPolicy) {
|
|
||||||
ForEach(SystemRunPolicy.allCases) { policy in
|
|
||||||
Text(policy.title).tag(policy)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.pickerStyle(.segmented)
|
|
||||||
|
|
||||||
Text("""
|
|
||||||
Controls remote command execution on this Mac when it is paired as a node. "Always Ask" prompts on each command; "Always Allow" runs without prompts; "Never" disables `system.run`.
|
|
||||||
""")
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text("Location Access")
|
Text("Location Access")
|
||||||
.font(.body)
|
.font(.body)
|
||||||
|
|||||||
@@ -31,8 +31,6 @@ enum Launchctl {
|
|||||||
struct LaunchAgentPlistSnapshot: Equatable, Sendable {
|
struct LaunchAgentPlistSnapshot: Equatable, Sendable {
|
||||||
let programArguments: [String]
|
let programArguments: [String]
|
||||||
let environment: [String: String]
|
let environment: [String: String]
|
||||||
let stdoutPath: String?
|
|
||||||
let stderrPath: String?
|
|
||||||
|
|
||||||
let port: Int?
|
let port: Int?
|
||||||
let bind: String?
|
let bind: String?
|
||||||
@@ -55,10 +53,6 @@ enum LaunchAgentPlist {
|
|||||||
guard let root = rootAny as? [String: Any] else { return nil }
|
guard let root = rootAny as? [String: Any] else { return nil }
|
||||||
let programArguments = root["ProgramArguments"] as? [String] ?? []
|
let programArguments = root["ProgramArguments"] as? [String] ?? []
|
||||||
let env = root["EnvironmentVariables"] as? [String: String] ?? [:]
|
let env = root["EnvironmentVariables"] as? [String: String] ?? [:]
|
||||||
let stdoutPath = (root["StandardOutPath"] as? String)?
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
|
||||||
let stderrPath = (root["StandardErrorPath"] as? String)?
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
|
||||||
let port = Self.extractFlagInt(programArguments, flag: "--port")
|
let port = Self.extractFlagInt(programArguments, flag: "--port")
|
||||||
let bind = Self.extractFlagString(programArguments, flag: "--bind")?.lowercased()
|
let bind = Self.extractFlagString(programArguments, flag: "--bind")?.lowercased()
|
||||||
let token = env["CLAWDBOT_GATEWAY_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
let token = env["CLAWDBOT_GATEWAY_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||||
@@ -66,8 +60,6 @@ enum LaunchAgentPlist {
|
|||||||
return LaunchAgentPlistSnapshot(
|
return LaunchAgentPlistSnapshot(
|
||||||
programArguments: programArguments,
|
programArguments: programArguments,
|
||||||
environment: env,
|
environment: env,
|
||||||
stdoutPath: stdoutPath,
|
|
||||||
stderrPath: stderrPath,
|
|
||||||
port: port,
|
port: port,
|
||||||
bind: bind,
|
bind: bind,
|
||||||
token: token,
|
token: token,
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import OSLog
|
|
||||||
|
|
||||||
enum MacNodeConfigFile {
|
|
||||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "mac-node-config")
|
|
||||||
|
|
||||||
static func url() -> URL {
|
|
||||||
ClawdbotPaths.stateDirURL.appendingPathComponent("macos-node.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
static func loadDict() -> [String: Any] {
|
|
||||||
let url = self.url()
|
|
||||||
guard FileManager.default.fileExists(atPath: url.path) else { return [:] }
|
|
||||||
do {
|
|
||||||
let data = try Data(contentsOf: url)
|
|
||||||
guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
||||||
self.logger.warning("mac node config JSON root invalid")
|
|
||||||
return [:]
|
|
||||||
}
|
|
||||||
return root
|
|
||||||
} catch {
|
|
||||||
self.logger.warning("mac node config read failed: \(error.localizedDescription, privacy: .public)")
|
|
||||||
return [:]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func saveDict(_ dict: [String: Any]) {
|
|
||||||
do {
|
|
||||||
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
|
|
||||||
let url = self.url()
|
|
||||||
try FileManager.default.createDirectory(
|
|
||||||
at: url.deletingLastPathComponent(),
|
|
||||||
withIntermediateDirectories: true)
|
|
||||||
try data.write(to: url, options: [.atomic])
|
|
||||||
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
|
||||||
} catch {
|
|
||||||
self.logger.error("mac node config save failed: \(error.localizedDescription, privacy: .public)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func systemRunPolicy() -> SystemRunPolicy? {
|
|
||||||
let root = self.loadDict()
|
|
||||||
let systemRun = root["systemRun"] as? [String: Any]
|
|
||||||
let raw = systemRun?["policy"] as? String
|
|
||||||
guard let raw, let policy = SystemRunPolicy(rawValue: raw) else { return nil }
|
|
||||||
return policy
|
|
||||||
}
|
|
||||||
|
|
||||||
static func setSystemRunPolicy(_ policy: SystemRunPolicy) {
|
|
||||||
var root = self.loadDict()
|
|
||||||
var systemRun = root["systemRun"] as? [String: Any] ?? [:]
|
|
||||||
systemRun["policy"] = policy.rawValue
|
|
||||||
root["systemRun"] = systemRun
|
|
||||||
self.saveDict(root)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func systemRunAllowlist() -> [String]? {
|
|
||||||
let root = self.loadDict()
|
|
||||||
let systemRun = root["systemRun"] as? [String: Any]
|
|
||||||
return systemRun?["allowlist"] as? [String]
|
|
||||||
}
|
|
||||||
|
|
||||||
static func setSystemRunAllowlist(_ allowlist: [String]) {
|
|
||||||
let cleaned = allowlist
|
|
||||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
||||||
.filter { !$0.isEmpty }
|
|
||||||
var root = self.loadDict()
|
|
||||||
var systemRun = root["systemRun"] as? [String: Any] ?? [:]
|
|
||||||
if cleaned.isEmpty {
|
|
||||||
systemRun.removeValue(forKey: "allowlist")
|
|
||||||
} else {
|
|
||||||
systemRun["allowlist"] = cleaned
|
|
||||||
}
|
|
||||||
if systemRun.isEmpty {
|
|
||||||
root.removeValue(forKey: "systemRun")
|
|
||||||
} else {
|
|
||||||
root["systemRun"] = systemRun
|
|
||||||
}
|
|
||||||
self.saveDict(root)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -31,12 +31,6 @@ struct MenuContent: View {
|
|||||||
self._updateStatus = Bindable(wrappedValue: updater?.updateStatus ?? UpdateStatus.disabled)
|
self._updateStatus = Bindable(wrappedValue: updater?.updateStatus ?? UpdateStatus.disabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var systemRunPolicyBinding: Binding<SystemRunPolicy> {
|
|
||||||
Binding(
|
|
||||||
get: { self.state.systemRunPolicy },
|
|
||||||
set: { self.state.systemRunPolicy = $0 })
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Toggle(isOn: self.activeBinding) {
|
Toggle(isOn: self.activeBinding) {
|
||||||
@@ -74,13 +68,6 @@ struct MenuContent: View {
|
|||||||
Toggle(isOn: self.$cameraEnabled) {
|
Toggle(isOn: self.$cameraEnabled) {
|
||||||
Label("Allow Camera", systemImage: "camera")
|
Label("Allow Camera", systemImage: "camera")
|
||||||
}
|
}
|
||||||
Picker(selection: self.systemRunPolicyBinding) {
|
|
||||||
ForEach(SystemRunPolicy.allCases) { policy in
|
|
||||||
Text(policy.title).tag(policy)
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Label("Node Run Commands", systemImage: "terminal")
|
|
||||||
}
|
|
||||||
Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) {
|
Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) {
|
||||||
Label("Allow Canvas", systemImage: "rectangle.and.pencil.and.ellipsis")
|
Label("Allow Canvas", systemImage: "rectangle.and.pencil.and.ellipsis")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,7 +103,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
|
|
||||||
extension MenuSessionsInjector {
|
extension MenuSessionsInjector {
|
||||||
// MARK: - Injection
|
// MARK: - Injection
|
||||||
|
|
||||||
private var mainSessionKey: String { WorkActivityStore.shared.mainSessionKey }
|
private var mainSessionKey: String { WorkActivityStore.shared.mainSessionKey }
|
||||||
|
|
||||||
private func inject(into menu: NSMenu) {
|
private func inject(into menu: NSMenu) {
|
||||||
|
|||||||
@@ -11,39 +11,10 @@ actor MacNodeBridgePairingClient {
|
|||||||
endpoint: NWEndpoint,
|
endpoint: NWEndpoint,
|
||||||
hello: BridgeHello,
|
hello: BridgeHello,
|
||||||
silent: Bool,
|
silent: Bool,
|
||||||
tls: MacNodeBridgeTLSParams? = nil,
|
|
||||||
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
|
|
||||||
{
|
|
||||||
do {
|
|
||||||
return try await self.pairAndHelloOnce(
|
|
||||||
endpoint: endpoint,
|
|
||||||
hello: hello,
|
|
||||||
silent: silent,
|
|
||||||
tls: tls,
|
|
||||||
onStatus: onStatus)
|
|
||||||
} catch {
|
|
||||||
if let tls, !tls.required {
|
|
||||||
return try await self.pairAndHelloOnce(
|
|
||||||
endpoint: endpoint,
|
|
||||||
hello: hello,
|
|
||||||
silent: silent,
|
|
||||||
tls: nil,
|
|
||||||
onStatus: onStatus)
|
|
||||||
}
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func pairAndHelloOnce(
|
|
||||||
endpoint: NWEndpoint,
|
|
||||||
hello: BridgeHello,
|
|
||||||
silent: Bool,
|
|
||||||
tls: MacNodeBridgeTLSParams?,
|
|
||||||
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
|
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
|
||||||
{
|
{
|
||||||
self.lineBuffer = Data()
|
self.lineBuffer = Data()
|
||||||
let params = self.makeParameters(tls: tls)
|
let connection = NWConnection(to: endpoint, using: .tcp)
|
||||||
let connection = NWConnection(to: endpoint, using: params)
|
|
||||||
let queue = DispatchQueue(label: "com.clawdbot.macos.bridge-client")
|
let queue = DispatchQueue(label: "com.clawdbot.macos.bridge-client")
|
||||||
defer { connection.cancel() }
|
defer { connection.cancel() }
|
||||||
try await AsyncTimeout.withTimeout(
|
try await AsyncTimeout.withTimeout(
|
||||||
@@ -193,18 +164,6 @@ actor MacNodeBridgePairingClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeParameters(tls: MacNodeBridgeTLSParams?) -> NWParameters {
|
|
||||||
let tcpOptions = NWProtocolTCP.Options()
|
|
||||||
if let tlsOptions = makeMacNodeTLSOptions(tls) {
|
|
||||||
let params = NWParameters(tls: tlsOptions, tcp: tcpOptions)
|
|
||||||
params.includePeerToPeer = true
|
|
||||||
return params
|
|
||||||
}
|
|
||||||
let params = NWParameters.tcp
|
|
||||||
params.includePeerToPeer = true
|
|
||||||
return params
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startAndWaitForReady(
|
private func startAndWaitForReady(
|
||||||
_ connection: NWConnection,
|
_ connection: NWConnection,
|
||||||
queue: DispatchQueue) async throws
|
queue: DispatchQueue) async throws
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ actor MacNodeBridgeSession {
|
|||||||
func connect(
|
func connect(
|
||||||
endpoint: NWEndpoint,
|
endpoint: NWEndpoint,
|
||||||
hello: BridgeHello,
|
hello: BridgeHello,
|
||||||
tls: MacNodeBridgeTLSParams? = nil,
|
|
||||||
onConnected: (@Sendable (String, String?) async -> Void)? = nil,
|
onConnected: (@Sendable (String, String?) async -> Void)? = nil,
|
||||||
onDisconnected: (@Sendable (String) async -> Void)? = nil,
|
onDisconnected: (@Sendable (String) async -> Void)? = nil,
|
||||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
|
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
|
||||||
@@ -45,35 +44,15 @@ actor MacNodeBridgeSession {
|
|||||||
await self.disconnect()
|
await self.disconnect()
|
||||||
self.disconnectHandler = onDisconnected
|
self.disconnectHandler = onDisconnected
|
||||||
self.state = .connecting
|
self.state = .connecting
|
||||||
do {
|
|
||||||
try await self.connectOnce(
|
|
||||||
endpoint: endpoint,
|
|
||||||
hello: hello,
|
|
||||||
tls: tls,
|
|
||||||
onConnected: onConnected,
|
|
||||||
onInvoke: onInvoke)
|
|
||||||
} catch {
|
|
||||||
if let tls, !tls.required {
|
|
||||||
try await self.connectOnce(
|
|
||||||
endpoint: endpoint,
|
|
||||||
hello: hello,
|
|
||||||
tls: nil,
|
|
||||||
onConnected: onConnected,
|
|
||||||
onInvoke: onInvoke)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func connectOnce(
|
let params = NWParameters.tcp
|
||||||
endpoint: NWEndpoint,
|
params.includePeerToPeer = true
|
||||||
hello: BridgeHello,
|
let tcpOptions = NWProtocolTCP.Options()
|
||||||
tls: MacNodeBridgeTLSParams?,
|
tcpOptions.enableKeepalive = true
|
||||||
onConnected: (@Sendable (String, String?) async -> Void)? = nil,
|
tcpOptions.keepaliveIdle = 30
|
||||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async throws
|
tcpOptions.keepaliveInterval = 15
|
||||||
{
|
tcpOptions.keepaliveCount = 3
|
||||||
let params = self.makeParameters(tls: tls)
|
params.defaultProtocolStack.transportProtocol = tcpOptions
|
||||||
let connection = NWConnection(to: endpoint, using: params)
|
let connection = NWConnection(to: endpoint, using: params)
|
||||||
let queue = DispatchQueue(label: "com.clawdbot.macos.bridge-session")
|
let queue = DispatchQueue(label: "com.clawdbot.macos.bridge-session")
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
@@ -283,25 +262,6 @@ actor MacNodeBridgeSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeParameters(tls: MacNodeBridgeTLSParams?) -> NWParameters {
|
|
||||||
let tcpOptions = NWProtocolTCP.Options()
|
|
||||||
tcpOptions.enableKeepalive = true
|
|
||||||
tcpOptions.keepaliveIdle = 30
|
|
||||||
tcpOptions.keepaliveInterval = 15
|
|
||||||
tcpOptions.keepaliveCount = 3
|
|
||||||
|
|
||||||
if let tlsOptions = makeMacNodeTLSOptions(tls) {
|
|
||||||
let params = NWParameters(tls: tlsOptions, tcp: tcpOptions)
|
|
||||||
params.includePeerToPeer = true
|
|
||||||
return params
|
|
||||||
}
|
|
||||||
|
|
||||||
let params = NWParameters.tcp
|
|
||||||
params.includePeerToPeer = true
|
|
||||||
params.defaultProtocolStack.transportProtocol = tcpOptions
|
|
||||||
return params
|
|
||||||
}
|
|
||||||
|
|
||||||
private func failRPC(id: String, error: Error) async {
|
private func failRPC(id: String, error: Error) async {
|
||||||
if let cont = self.pendingRPC.removeValue(forKey: id) {
|
if let cont = self.pendingRPC.removeValue(forKey: id) {
|
||||||
cont.resume(throwing: error)
|
cont.resume(throwing: error)
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
import CryptoKit
|
|
||||||
import Foundation
|
|
||||||
import Network
|
|
||||||
import Security
|
|
||||||
|
|
||||||
struct MacNodeBridgeTLSParams: Sendable {
|
|
||||||
let required: Bool
|
|
||||||
let expectedFingerprint: String?
|
|
||||||
let allowTOFU: Bool
|
|
||||||
let storeKey: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
enum MacNodeBridgeTLSStore {
|
|
||||||
private static let suiteName = "com.clawdbot.shared"
|
|
||||||
private static let keyPrefix = "mac.node.bridge.tls."
|
|
||||||
|
|
||||||
private static var defaults: UserDefaults {
|
|
||||||
UserDefaults(suiteName: suiteName) ?? .standard
|
|
||||||
}
|
|
||||||
|
|
||||||
static func loadFingerprint(stableID: String) -> String? {
|
|
||||||
let key = keyPrefix + stableID
|
|
||||||
let raw = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
return raw?.isEmpty == false ? raw : nil
|
|
||||||
}
|
|
||||||
|
|
||||||
static func saveFingerprint(_ value: String, stableID: String) {
|
|
||||||
let key = keyPrefix + stableID
|
|
||||||
defaults.set(value, forKey: key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeMacNodeTLSOptions(_ params: MacNodeBridgeTLSParams?) -> NWProtocolTLS.Options? {
|
|
||||||
guard let params else { return nil }
|
|
||||||
let options = NWProtocolTLS.Options()
|
|
||||||
let expected = params.expectedFingerprint.map(normalizeMacNodeFingerprint)
|
|
||||||
let allowTOFU = params.allowTOFU
|
|
||||||
let storeKey = params.storeKey
|
|
||||||
|
|
||||||
sec_protocol_options_set_verify_block(
|
|
||||||
options.securityProtocolOptions,
|
|
||||||
{ _, trust, complete in
|
|
||||||
guard let trust else {
|
|
||||||
complete(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if let cert = SecTrustGetCertificateAtIndex(trust, 0) {
|
|
||||||
let data = SecCertificateCopyData(cert) as Data
|
|
||||||
let fingerprint = sha256Hex(data)
|
|
||||||
if let expected {
|
|
||||||
complete(fingerprint == expected)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if allowTOFU {
|
|
||||||
if let storeKey { MacNodeBridgeTLSStore.saveFingerprint(fingerprint, stableID: storeKey) }
|
|
||||||
complete(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let ok = SecTrustEvaluateWithError(trust, nil)
|
|
||||||
complete(ok)
|
|
||||||
},
|
|
||||||
DispatchQueue(label: "com.clawdbot.macos.bridge.tls.verify"))
|
|
||||||
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
|
|
||||||
private func sha256Hex(_ data: Data) -> String {
|
|
||||||
let digest = SHA256.hash(data: data)
|
|
||||||
return digest.map { String(format: "%02x", $0) }.joined()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func normalizeMacNodeFingerprint(_ raw: String) -> String {
|
|
||||||
raw.lowercased().filter { $0.isHexDigit }
|
|
||||||
}
|
|
||||||
@@ -4,12 +4,6 @@ import Foundation
|
|||||||
import Network
|
import Network
|
||||||
import OSLog
|
import OSLog
|
||||||
|
|
||||||
private struct BridgeTarget {
|
|
||||||
let endpoint: NWEndpoint
|
|
||||||
let stableID: String
|
|
||||||
let tls: MacNodeBridgeTLSParams?
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class MacNodeModeCoordinator {
|
final class MacNodeModeCoordinator {
|
||||||
static let shared = MacNodeModeCoordinator()
|
static let shared = MacNodeModeCoordinator()
|
||||||
@@ -43,7 +37,6 @@ final class MacNodeModeCoordinator {
|
|||||||
private func run() async {
|
private func run() async {
|
||||||
var retryDelay: UInt64 = 1_000_000_000
|
var retryDelay: UInt64 = 1_000_000_000
|
||||||
var lastCameraEnabled: Bool?
|
var lastCameraEnabled: Bool?
|
||||||
var lastSystemRunPolicy: SystemRunPolicy?
|
|
||||||
let defaults = UserDefaults.standard
|
let defaults = UserDefaults.standard
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
if await MainActor.run(body: { AppStateStore.shared.isPaused }) {
|
if await MainActor.run(body: { AppStateStore.shared.isPaused }) {
|
||||||
@@ -60,16 +53,7 @@ final class MacNodeModeCoordinator {
|
|||||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||||
}
|
}
|
||||||
|
|
||||||
let systemRunPolicy = SystemRunPolicy.load()
|
guard let endpoint = await self.resolveBridgeEndpoint(timeoutSeconds: 5) else {
|
||||||
if lastSystemRunPolicy == nil {
|
|
||||||
lastSystemRunPolicy = systemRunPolicy
|
|
||||||
} else if lastSystemRunPolicy != systemRunPolicy {
|
|
||||||
lastSystemRunPolicy = systemRunPolicy
|
|
||||||
await self.session.disconnect()
|
|
||||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let target = await self.resolveBridgeEndpoint(timeoutSeconds: 5) else {
|
|
||||||
try? await Task.sleep(nanoseconds: min(retryDelay, 5_000_000_000))
|
try? await Task.sleep(nanoseconds: min(retryDelay, 5_000_000_000))
|
||||||
retryDelay = min(retryDelay * 2, 10_000_000_000)
|
retryDelay = min(retryDelay * 2, 10_000_000_000)
|
||||||
continue
|
continue
|
||||||
@@ -79,11 +63,10 @@ final class MacNodeModeCoordinator {
|
|||||||
do {
|
do {
|
||||||
let hello = await self.makeHello()
|
let hello = await self.makeHello()
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
"mac node bridge connecting endpoint=\(target.endpoint, privacy: .public)")
|
"mac node bridge connecting endpoint=\(endpoint, privacy: .public)")
|
||||||
try await self.session.connect(
|
try await self.session.connect(
|
||||||
endpoint: target.endpoint,
|
endpoint: endpoint,
|
||||||
hello: hello,
|
hello: hello,
|
||||||
tls: target.tls,
|
|
||||||
onConnected: { [weak self] serverName, mainSessionKey in
|
onConnected: { [weak self] serverName, mainSessionKey in
|
||||||
self?.logger.info("mac node connected to \(serverName, privacy: .public)")
|
self?.logger.info("mac node connected to \(serverName, privacy: .public)")
|
||||||
if let mainSessionKey {
|
if let mainSessionKey {
|
||||||
@@ -103,7 +86,7 @@ final class MacNodeModeCoordinator {
|
|||||||
return await self.runtime.handleInvoke(req)
|
return await self.runtime.handleInvoke(req)
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
if await self.tryPair(target: target, error: error) {
|
if await self.tryPair(endpoint: endpoint, error: error) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
@@ -160,13 +143,10 @@ final class MacNodeModeCoordinator {
|
|||||||
ClawdbotCanvasA2UICommand.pushJSONL.rawValue,
|
ClawdbotCanvasA2UICommand.pushJSONL.rawValue,
|
||||||
ClawdbotCanvasA2UICommand.reset.rawValue,
|
ClawdbotCanvasA2UICommand.reset.rawValue,
|
||||||
MacNodeScreenCommand.record.rawValue,
|
MacNodeScreenCommand.record.rawValue,
|
||||||
|
ClawdbotSystemCommand.run.rawValue,
|
||||||
ClawdbotSystemCommand.notify.rawValue,
|
ClawdbotSystemCommand.notify.rawValue,
|
||||||
]
|
]
|
||||||
|
|
||||||
if SystemRunPolicy.load() != .never {
|
|
||||||
commands.append(ClawdbotSystemCommand.run.rawValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
let capsSet = Set(caps)
|
let capsSet = Set(caps)
|
||||||
if capsSet.contains(ClawdbotCapability.camera.rawValue) {
|
if capsSet.contains(ClawdbotCapability.camera.rawValue) {
|
||||||
commands.append(ClawdbotCameraCommand.list.rawValue)
|
commands.append(ClawdbotCameraCommand.list.rawValue)
|
||||||
@@ -180,7 +160,7 @@ final class MacNodeModeCoordinator {
|
|||||||
return commands
|
return commands
|
||||||
}
|
}
|
||||||
|
|
||||||
private func tryPair(target: BridgeTarget, error: Error) async -> Bool {
|
private func tryPair(endpoint: NWEndpoint, error: Error) async -> Bool {
|
||||||
let text = error.localizedDescription.uppercased()
|
let text = error.localizedDescription.uppercased()
|
||||||
guard text.contains("NOT_PAIRED") || text.contains("UNAUTHORIZED") else { return false }
|
guard text.contains("NOT_PAIRED") || text.contains("UNAUTHORIZED") else { return false }
|
||||||
|
|
||||||
@@ -190,10 +170,9 @@ final class MacNodeModeCoordinator {
|
|||||||
}
|
}
|
||||||
let hello = await self.makeHello()
|
let hello = await self.makeHello()
|
||||||
let token = try await MacNodeBridgePairingClient().pairAndHello(
|
let token = try await MacNodeBridgePairingClient().pairAndHello(
|
||||||
endpoint: target.endpoint,
|
endpoint: endpoint,
|
||||||
hello: hello,
|
hello: hello,
|
||||||
silent: shouldSilent,
|
silent: shouldSilent,
|
||||||
tls: target.tls,
|
|
||||||
onStatus: { [weak self] status in
|
onStatus: { [weak self] status in
|
||||||
self?.logger.info("mac node pairing: \(status, privacy: .public)")
|
self?.logger.info("mac node pairing: \(status, privacy: .public)")
|
||||||
})
|
})
|
||||||
@@ -211,7 +190,7 @@ final class MacNodeModeCoordinator {
|
|||||||
"mac-\(InstanceIdentity.instanceId)"
|
"mac-\(InstanceIdentity.instanceId)"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveLoopbackBridgeEndpoint(timeoutSeconds: Double) async -> BridgeTarget? {
|
private func resolveLoopbackBridgeEndpoint(timeoutSeconds: Double) async -> NWEndpoint? {
|
||||||
guard let port = Self.loopbackBridgePort(),
|
guard let port = Self.loopbackBridgePort(),
|
||||||
let endpointPort = NWEndpoint.Port(rawValue: port)
|
let endpointPort = NWEndpoint.Port(rawValue: port)
|
||||||
else {
|
else {
|
||||||
@@ -219,10 +198,7 @@ final class MacNodeModeCoordinator {
|
|||||||
}
|
}
|
||||||
let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: endpointPort)
|
let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: endpointPort)
|
||||||
let reachable = await Self.probeEndpoint(endpoint, timeoutSeconds: timeoutSeconds)
|
let reachable = await Self.probeEndpoint(endpoint, timeoutSeconds: timeoutSeconds)
|
||||||
guard reachable else { return nil }
|
return reachable ? endpoint : nil
|
||||||
let stableID = BridgeEndpointID.stableID(endpoint)
|
|
||||||
let tlsParams = Self.resolveManualTLSParams(stableID: stableID)
|
|
||||||
return BridgeTarget(endpoint: endpoint, stableID: stableID, tls: tlsParams)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func loopbackBridgePort() -> UInt16? {
|
static func loopbackBridgePort() -> UInt16? {
|
||||||
@@ -315,7 +291,7 @@ final class MacNodeModeCoordinator {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveBridgeEndpoint(timeoutSeconds: Double) async -> BridgeTarget? {
|
private func resolveBridgeEndpoint(timeoutSeconds: Double) async -> NWEndpoint? {
|
||||||
let mode = await MainActor.run(body: { AppStateStore.shared.connectionMode })
|
let mode = await MainActor.run(body: { AppStateStore.shared.connectionMode })
|
||||||
if mode == .remote {
|
if mode == .remote {
|
||||||
do {
|
do {
|
||||||
@@ -327,10 +303,7 @@ final class MacNodeModeCoordinator {
|
|||||||
if healthy, let port = NWEndpoint.Port(rawValue: localPort) {
|
if healthy, let port = NWEndpoint.Port(rawValue: localPort) {
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
"reusing mac node bridge tunnel localPort=\(localPort, privacy: .public)")
|
"reusing mac node bridge tunnel localPort=\(localPort, privacy: .public)")
|
||||||
let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: port)
|
return .hostPort(host: "127.0.0.1", port: port)
|
||||||
let stableID = BridgeEndpointID.stableID(endpoint)
|
|
||||||
let tlsParams = Self.resolveManualTLSParams(stableID: stableID)
|
|
||||||
return BridgeTarget(endpoint: endpoint, stableID: stableID, tls: tlsParams)
|
|
||||||
}
|
}
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
"mac node bridge tunnel unhealthy localPort=\(localPort, privacy: .public); restarting")
|
"mac node bridge tunnel unhealthy localPort=\(localPort, privacy: .public); restarting")
|
||||||
@@ -339,23 +312,9 @@ final class MacNodeModeCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let remotePort = Self.remoteBridgePort()
|
let remotePort = Self.remoteBridgePort()
|
||||||
let preferredLocalPort = Self.loopbackBridgePort()
|
|
||||||
if let preferredLocalPort {
|
|
||||||
self.logger.info(
|
|
||||||
"mac node bridge tunnel starting " +
|
|
||||||
"preferredLocalPort=\(preferredLocalPort, privacy: .public) " +
|
|
||||||
"remotePort=\(remotePort, privacy: .public)")
|
|
||||||
} else {
|
|
||||||
self.logger.info(
|
|
||||||
"mac node bridge tunnel starting " +
|
|
||||||
"preferredLocalPort=none " +
|
|
||||||
"remotePort=\(remotePort, privacy: .public)")
|
|
||||||
}
|
|
||||||
self.tunnel = try await RemotePortTunnel.create(
|
self.tunnel = try await RemotePortTunnel.create(
|
||||||
remotePort: remotePort,
|
remotePort: remotePort,
|
||||||
preferredLocalPort: preferredLocalPort,
|
allowRemoteUrlOverride: false)
|
||||||
allowRemoteUrlOverride: false,
|
|
||||||
allowRandomLocalPort: true)
|
|
||||||
if let localPort = self.tunnel?.localPort,
|
if let localPort = self.tunnel?.localPort,
|
||||||
let port = NWEndpoint.Port(rawValue: localPort)
|
let port = NWEndpoint.Port(rawValue: localPort)
|
||||||
{
|
{
|
||||||
@@ -363,10 +322,7 @@ final class MacNodeModeCoordinator {
|
|||||||
"mac node bridge tunnel ready " +
|
"mac node bridge tunnel ready " +
|
||||||
"localPort=\(localPort, privacy: .public) " +
|
"localPort=\(localPort, privacy: .public) " +
|
||||||
"remotePort=\(remotePort, privacy: .public)")
|
"remotePort=\(remotePort, privacy: .public)")
|
||||||
let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: port)
|
return .hostPort(host: "127.0.0.1", port: port)
|
||||||
let stableID = BridgeEndpointID.stableID(endpoint)
|
|
||||||
let tlsParams = Self.resolveManualTLSParams(stableID: stableID)
|
|
||||||
return BridgeTarget(endpoint: endpoint, stableID: stableID, tls: tlsParams)
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
self.logger.error("mac node bridge tunnel failed: \(error.localizedDescription, privacy: .public)")
|
self.logger.error("mac node bridge tunnel failed: \(error.localizedDescription, privacy: .public)")
|
||||||
@@ -377,8 +333,8 @@ final class MacNodeModeCoordinator {
|
|||||||
tunnel.terminate()
|
tunnel.terminate()
|
||||||
self.tunnel = nil
|
self.tunnel = nil
|
||||||
}
|
}
|
||||||
if mode == .local, let target = await self.resolveLoopbackBridgeEndpoint(timeoutSeconds: 0.4) {
|
if mode == .local, let endpoint = await self.resolveLoopbackBridgeEndpoint(timeoutSeconds: 0.4) {
|
||||||
return target
|
return endpoint
|
||||||
}
|
}
|
||||||
return await Self.discoverBridgeEndpoint(timeoutSeconds: timeoutSeconds)
|
return await Self.discoverBridgeEndpoint(timeoutSeconds: timeoutSeconds)
|
||||||
}
|
}
|
||||||
@@ -398,14 +354,14 @@ final class MacNodeModeCoordinator {
|
|||||||
return await Self.probeEndpoint(.hostPort(host: "127.0.0.1", port: port), timeoutSeconds: timeoutSeconds)
|
return await Self.probeEndpoint(.hostPort(host: "127.0.0.1", port: port), timeoutSeconds: timeoutSeconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func discoverBridgeEndpoint(timeoutSeconds: Double) async -> BridgeTarget? {
|
private static func discoverBridgeEndpoint(timeoutSeconds: Double) async -> NWEndpoint? {
|
||||||
final class DiscoveryState: @unchecked Sendable {
|
final class DiscoveryState: @unchecked Sendable {
|
||||||
let lock = NSLock()
|
let lock = NSLock()
|
||||||
var resolved = false
|
var resolved = false
|
||||||
var browsers: [NWBrowser] = []
|
var browsers: [NWBrowser] = []
|
||||||
var continuation: CheckedContinuation<BridgeTarget?, Never>?
|
var continuation: CheckedContinuation<NWEndpoint?, Never>?
|
||||||
|
|
||||||
func finish(_ target: BridgeTarget?) {
|
func finish(_ endpoint: NWEndpoint?) {
|
||||||
self.lock.lock()
|
self.lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
if self.resolved { return }
|
if self.resolved { return }
|
||||||
@@ -413,7 +369,7 @@ final class MacNodeModeCoordinator {
|
|||||||
for browser in self.browsers {
|
for browser in self.browsers {
|
||||||
browser.cancel()
|
browser.cancel()
|
||||||
}
|
}
|
||||||
self.continuation?.resume(returning: target)
|
self.continuation?.resume(returning: endpoint)
|
||||||
self.continuation = nil
|
self.continuation = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -439,12 +395,12 @@ final class MacNodeModeCoordinator {
|
|||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
state.finish(Self.targetFromResult(match))
|
state.finish(match.endpoint)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if let result = results.first(where: { if case .service = $0.endpoint { true } else { false } }) {
|
if let result = results.first(where: { if case .service = $0.endpoint { true } else { false } }) {
|
||||||
state.finish(Self.targetFromResult(result))
|
state.finish(result.endpoint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
browser.stateUpdateHandler = { browserState in
|
browser.stateUpdateHandler = { browserState in
|
||||||
@@ -462,72 +418,6 @@ final class MacNodeModeCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func targetFromResult(_ result: NWBrowser.Result) -> BridgeTarget? {
|
|
||||||
let endpoint = result.endpoint
|
|
||||||
guard case .service = endpoint else { return nil }
|
|
||||||
let stableID = BridgeEndpointID.stableID(endpoint)
|
|
||||||
let txt = result.endpoint.txtRecord?.dictionary ?? [:]
|
|
||||||
let tlsEnabled = Self.txtBoolValue(txt, key: "bridgeTls")
|
|
||||||
let tlsFingerprint = Self.txtValue(txt, key: "bridgeTlsSha256")
|
|
||||||
let tlsParams = Self.resolveDiscoveredTLSParams(
|
|
||||||
stableID: stableID,
|
|
||||||
tlsEnabled: tlsEnabled,
|
|
||||||
tlsFingerprintSha256: tlsFingerprint)
|
|
||||||
return BridgeTarget(endpoint: endpoint, stableID: stableID, tls: tlsParams)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func resolveDiscoveredTLSParams(
|
|
||||||
stableID: String,
|
|
||||||
tlsEnabled: Bool,
|
|
||||||
tlsFingerprintSha256: String?) -> MacNodeBridgeTLSParams?
|
|
||||||
{
|
|
||||||
let stored = MacNodeBridgeTLSStore.loadFingerprint(stableID: stableID)
|
|
||||||
|
|
||||||
if tlsEnabled || tlsFingerprintSha256 != nil {
|
|
||||||
return MacNodeBridgeTLSParams(
|
|
||||||
required: true,
|
|
||||||
expectedFingerprint: tlsFingerprintSha256 ?? stored,
|
|
||||||
allowTOFU: stored == nil,
|
|
||||||
storeKey: stableID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let stored {
|
|
||||||
return MacNodeBridgeTLSParams(
|
|
||||||
required: true,
|
|
||||||
expectedFingerprint: stored,
|
|
||||||
allowTOFU: false,
|
|
||||||
storeKey: stableID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func resolveManualTLSParams(stableID: String) -> MacNodeBridgeTLSParams? {
|
|
||||||
if let stored = MacNodeBridgeTLSStore.loadFingerprint(stableID: stableID) {
|
|
||||||
return MacNodeBridgeTLSParams(
|
|
||||||
required: true,
|
|
||||||
expectedFingerprint: stored,
|
|
||||||
allowTOFU: false,
|
|
||||||
storeKey: stableID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return MacNodeBridgeTLSParams(
|
|
||||||
required: false,
|
|
||||||
expectedFingerprint: nil,
|
|
||||||
allowTOFU: true,
|
|
||||||
storeKey: stableID)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func txtValue(_ dict: [String: String], key: String) -> String? {
|
|
||||||
let raw = dict[key]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
return raw.isEmpty ? nil : raw
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func txtBoolValue(_ dict: [String: String], key: String) -> Bool {
|
|
||||||
guard let raw = self.txtValue(dict, key: key)?.lowercased() else { return false }
|
|
||||||
return raw == "1" || raw == "true" || raw == "yes"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum MacNodeTokenStore {
|
enum MacNodeTokenStore {
|
||||||
|
|||||||
@@ -426,37 +426,6 @@ actor MacNodeRuntime {
|
|||||||
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required")
|
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required")
|
||||||
}
|
}
|
||||||
|
|
||||||
let wasAllowlisted = SystemRunAllowlist.contains(command)
|
|
||||||
switch Self.systemRunPolicy() {
|
|
||||||
case .never:
|
|
||||||
return Self.errorResponse(
|
|
||||||
req,
|
|
||||||
code: .unavailable,
|
|
||||||
message: "SYSTEM_RUN_DISABLED: policy=never")
|
|
||||||
case .always:
|
|
||||||
break
|
|
||||||
case .ask:
|
|
||||||
if !wasAllowlisted {
|
|
||||||
let services = await self.mainActorServices()
|
|
||||||
let decision = await services.confirmSystemRun(
|
|
||||||
command: SystemRunAllowlist.displayString(for: command),
|
|
||||||
cwd: params.cwd)
|
|
||||||
switch decision {
|
|
||||||
case .allowOnce:
|
|
||||||
break
|
|
||||||
case .allowAlways:
|
|
||||||
SystemRunAllowlist.add(command)
|
|
||||||
case .deny:
|
|
||||||
return Self.errorResponse(
|
|
||||||
req,
|
|
||||||
code: .unavailable,
|
|
||||||
message: "SYSTEM_RUN_DENIED: user denied")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let env = Self.sanitizedEnv(params.env)
|
|
||||||
|
|
||||||
if params.needsScreenRecording == true {
|
if params.needsScreenRecording == true {
|
||||||
let authorized = await PermissionManager
|
let authorized = await PermissionManager
|
||||||
.status([.screenRecording])[.screenRecording] ?? false
|
.status([.screenRecording])[.screenRecording] ?? false
|
||||||
@@ -472,7 +441,7 @@ actor MacNodeRuntime {
|
|||||||
let result = await ShellExecutor.runDetailed(
|
let result = await ShellExecutor.runDetailed(
|
||||||
command: command,
|
command: command,
|
||||||
cwd: params.cwd,
|
cwd: params.cwd,
|
||||||
env: env,
|
env: params.env,
|
||||||
timeout: timeoutSec)
|
timeout: timeoutSec)
|
||||||
|
|
||||||
struct RunPayload: Encodable {
|
struct RunPayload: Encodable {
|
||||||
@@ -560,39 +529,6 @@ actor MacNodeRuntime {
|
|||||||
UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false
|
UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
private nonisolated static func systemRunPolicy() -> SystemRunPolicy {
|
|
||||||
SystemRunPolicy.load()
|
|
||||||
}
|
|
||||||
|
|
||||||
private static let blockedEnvKeys: Set<String> = [
|
|
||||||
"PATH",
|
|
||||||
"NODE_OPTIONS",
|
|
||||||
"PYTHONHOME",
|
|
||||||
"PYTHONPATH",
|
|
||||||
"PERL5LIB",
|
|
||||||
"PERL5OPT",
|
|
||||||
"RUBYOPT",
|
|
||||||
]
|
|
||||||
|
|
||||||
private static let blockedEnvPrefixes: [String] = [
|
|
||||||
"DYLD_",
|
|
||||||
"LD_",
|
|
||||||
]
|
|
||||||
|
|
||||||
private static func sanitizedEnv(_ overrides: [String: String]?) -> [String: String]? {
|
|
||||||
guard let overrides else { return nil }
|
|
||||||
var merged = ProcessInfo.processInfo.environment
|
|
||||||
for (rawKey, value) in overrides {
|
|
||||||
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !key.isEmpty else { continue }
|
|
||||||
let upper = key.uppercased()
|
|
||||||
if blockedEnvKeys.contains(upper) { continue }
|
|
||||||
if blockedEnvPrefixes.contains(where: { upper.hasPrefix($0) }) { continue }
|
|
||||||
merged[key] = value
|
|
||||||
}
|
|
||||||
return merged
|
|
||||||
}
|
|
||||||
|
|
||||||
private nonisolated static func locationMode() -> ClawdbotLocationMode {
|
private nonisolated static func locationMode() -> ClawdbotLocationMode {
|
||||||
let raw = UserDefaults.standard.string(forKey: locationModeKey) ?? "off"
|
let raw = UserDefaults.standard.string(forKey: locationModeKey) ?? "off"
|
||||||
return ClawdbotLocationMode(rawValue: raw) ?? .off
|
return ClawdbotLocationMode(rawValue: raw) ?? .off
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
import AppKit
|
|
||||||
import ClawdbotKit
|
import ClawdbotKit
|
||||||
import CoreLocation
|
import CoreLocation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum SystemRunDecision: Sendable {
|
|
||||||
case allowOnce
|
|
||||||
case allowAlways
|
|
||||||
case deny
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol MacNodeRuntimeMainActorServices: Sendable {
|
protocol MacNodeRuntimeMainActorServices: Sendable {
|
||||||
func recordScreen(
|
func recordScreen(
|
||||||
@@ -24,8 +17,6 @@ protocol MacNodeRuntimeMainActorServices: Sendable {
|
|||||||
desiredAccuracy: ClawdbotLocationAccuracy,
|
desiredAccuracy: ClawdbotLocationAccuracy,
|
||||||
maxAgeMs: Int?,
|
maxAgeMs: Int?,
|
||||||
timeoutMs: Int?) async throws -> CLLocation
|
timeoutMs: Int?) async throws -> CLLocation
|
||||||
|
|
||||||
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -66,31 +57,4 @@ final class LiveMacNodeRuntimeMainActorServices: MacNodeRuntimeMainActorServices
|
|||||||
maxAgeMs: maxAgeMs,
|
maxAgeMs: maxAgeMs,
|
||||||
timeoutMs: timeoutMs)
|
timeoutMs: timeoutMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision {
|
|
||||||
let alert = NSAlert()
|
|
||||||
alert.alertStyle = .warning
|
|
||||||
alert.messageText = "Allow this command?"
|
|
||||||
|
|
||||||
var details = "Clawdbot wants to run:\n\n\(command)"
|
|
||||||
let trimmedCwd = cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
if !trimmedCwd.isEmpty {
|
|
||||||
details += "\n\nWorking directory:\n\(trimmedCwd)"
|
|
||||||
}
|
|
||||||
details += "\n\nThis runs on this Mac via node mode."
|
|
||||||
alert.informativeText = details
|
|
||||||
|
|
||||||
alert.addButton(withTitle: "Allow Once")
|
|
||||||
alert.addButton(withTitle: "Always Allow")
|
|
||||||
alert.addButton(withTitle: "Don't Allow")
|
|
||||||
|
|
||||||
switch alert.runModal() {
|
|
||||||
case .alertFirstButtonReturn:
|
|
||||||
return .allowOnce
|
|
||||||
case .alertSecondButtonReturn:
|
|
||||||
return .allowAlways
|
|
||||||
default:
|
|
||||||
return .deny
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
enum SystemRunPolicy: String, CaseIterable, Identifiable {
|
|
||||||
case never
|
|
||||||
case ask
|
|
||||||
case always
|
|
||||||
|
|
||||||
var id: String { self.rawValue }
|
|
||||||
|
|
||||||
var title: String {
|
|
||||||
switch self {
|
|
||||||
case .never:
|
|
||||||
return "Never"
|
|
||||||
case .ask:
|
|
||||||
return "Always Ask"
|
|
||||||
case .always:
|
|
||||||
return "Always Allow"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func load(from defaults: UserDefaults = .standard) -> SystemRunPolicy {
|
|
||||||
if let policy = MacNodeConfigFile.systemRunPolicy() {
|
|
||||||
return policy
|
|
||||||
}
|
|
||||||
if let raw = defaults.string(forKey: systemRunPolicyKey),
|
|
||||||
let policy = SystemRunPolicy(rawValue: raw)
|
|
||||||
{
|
|
||||||
MacNodeConfigFile.setSystemRunPolicy(policy)
|
|
||||||
return policy
|
|
||||||
}
|
|
||||||
if let legacy = defaults.object(forKey: systemRunEnabledKey) as? Bool {
|
|
||||||
let policy: SystemRunPolicy = legacy ? .ask : .never
|
|
||||||
MacNodeConfigFile.setSystemRunPolicy(policy)
|
|
||||||
return policy
|
|
||||||
}
|
|
||||||
let fallback: SystemRunPolicy = .ask
|
|
||||||
MacNodeConfigFile.setSystemRunPolicy(fallback)
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum SystemRunAllowlist {
|
|
||||||
static func key(for argv: [String]) -> String {
|
|
||||||
let trimmed = argv.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
||||||
guard !trimmed.isEmpty else { return "" }
|
|
||||||
if let data = try? JSONEncoder().encode(trimmed),
|
|
||||||
let json = String(data: data, encoding: .utf8)
|
|
||||||
{
|
|
||||||
return json
|
|
||||||
}
|
|
||||||
return trimmed.joined(separator: " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
static func displayString(for argv: [String]) -> String {
|
|
||||||
argv.map { arg in
|
|
||||||
let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !trimmed.isEmpty else { return "\"\"" }
|
|
||||||
let needsQuotes = trimmed.contains { $0.isWhitespace || $0 == "\"" }
|
|
||||||
if !needsQuotes { return trimmed }
|
|
||||||
let escaped = trimmed.replacingOccurrences(of: "\"", with: "\\\"")
|
|
||||||
return "\"\(escaped)\""
|
|
||||||
}.joined(separator: " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
static func load(from defaults: UserDefaults = .standard) -> Set<String> {
|
|
||||||
if let allowlist = MacNodeConfigFile.systemRunAllowlist() {
|
|
||||||
return Set(allowlist)
|
|
||||||
}
|
|
||||||
if let legacy = defaults.stringArray(forKey: systemRunAllowlistKey), !legacy.isEmpty {
|
|
||||||
MacNodeConfigFile.setSystemRunAllowlist(legacy)
|
|
||||||
return Set(legacy)
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
static func contains(_ argv: [String], defaults: UserDefaults = .standard) -> Bool {
|
|
||||||
let key = key(for: argv)
|
|
||||||
return load(from: defaults).contains(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func add(_ argv: [String], defaults: UserDefaults = .standard) {
|
|
||||||
let key = key(for: argv)
|
|
||||||
guard !key.isEmpty else { return }
|
|
||||||
var allowlist = load(from: defaults)
|
|
||||||
if allowlist.insert(key).inserted {
|
|
||||||
MacNodeConfigFile.setSystemRunAllowlist(Array(allowlist).sorted())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1679,27 +1679,6 @@ public struct ChatAbortParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ChatInjectParams: Codable, Sendable {
|
|
||||||
public let sessionkey: String
|
|
||||||
public let message: String
|
|
||||||
public let label: String?
|
|
||||||
|
|
||||||
public init(
|
|
||||||
sessionkey: String,
|
|
||||||
message: String,
|
|
||||||
label: String?
|
|
||||||
) {
|
|
||||||
self.sessionkey = sessionkey
|
|
||||||
self.message = message
|
|
||||||
self.label = label
|
|
||||||
}
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case sessionkey = "sessionKey"
|
|
||||||
case message
|
|
||||||
case label
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct ChatEvent: Codable, Sendable {
|
public struct ChatEvent: Codable, Sendable {
|
||||||
public let runid: String
|
public let runid: String
|
||||||
public let sessionkey: String
|
public let sessionkey: String
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import Testing
|
|||||||
let snapshot = LaunchAgentPlistSnapshot(
|
let snapshot = LaunchAgentPlistSnapshot(
|
||||||
programArguments: [],
|
programArguments: [],
|
||||||
environment: ["CLAWDBOT_GATEWAY_TOKEN": "launchd-token"],
|
environment: ["CLAWDBOT_GATEWAY_TOKEN": "launchd-token"],
|
||||||
stdoutPath: nil,
|
|
||||||
stderrPath: nil,
|
|
||||||
port: nil,
|
port: nil,
|
||||||
bind: nil,
|
bind: nil,
|
||||||
token: "launchd-token",
|
token: "launchd-token",
|
||||||
@@ -33,8 +31,6 @@ import Testing
|
|||||||
let snapshot = LaunchAgentPlistSnapshot(
|
let snapshot = LaunchAgentPlistSnapshot(
|
||||||
programArguments: [],
|
programArguments: [],
|
||||||
environment: ["CLAWDBOT_GATEWAY_TOKEN": "launchd-token"],
|
environment: ["CLAWDBOT_GATEWAY_TOKEN": "launchd-token"],
|
||||||
stdoutPath: nil,
|
|
||||||
stderrPath: nil,
|
|
||||||
port: nil,
|
port: nil,
|
||||||
bind: nil,
|
bind: nil,
|
||||||
token: "launchd-token",
|
token: "launchd-token",
|
||||||
@@ -52,8 +48,6 @@ import Testing
|
|||||||
let snapshot = LaunchAgentPlistSnapshot(
|
let snapshot = LaunchAgentPlistSnapshot(
|
||||||
programArguments: [],
|
programArguments: [],
|
||||||
environment: ["CLAWDBOT_GATEWAY_PASSWORD": "launchd-pass"],
|
environment: ["CLAWDBOT_GATEWAY_PASSWORD": "launchd-pass"],
|
||||||
stdoutPath: nil,
|
|
||||||
stderrPath: nil,
|
|
||||||
port: nil,
|
port: nil,
|
||||||
bind: nil,
|
bind: nil,
|
||||||
token: nil,
|
token: nil,
|
||||||
|
|||||||
@@ -112,6 +112,20 @@ struct LowCoverageHelperTests {
|
|||||||
_ = PresenceReporter._testPrimaryIPv4Address()
|
_ = PresenceReporter._testPrimaryIPv4Address()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func gatewayLaunchAgentHelpers() async throws {
|
||||||
|
await TestIsolation.withEnvValues(
|
||||||
|
[
|
||||||
|
"CLAWDBOT_GATEWAY_BIND": "Lan",
|
||||||
|
"CLAWDBOT_GATEWAY_TOKEN": " secret ",
|
||||||
|
]) {
|
||||||
|
#expect(GatewayLaunchAgentManager._testPreferredGatewayBind() == "lan")
|
||||||
|
#expect(GatewayLaunchAgentManager._testPreferredGatewayToken() == "secret")
|
||||||
|
#expect(
|
||||||
|
GatewayLaunchAgentManager._testEscapePlistValue("a&b<c>\"'") ==
|
||||||
|
"a&b<c>"'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test func portGuardianParsesListenersAndBuildsReports() {
|
@Test func portGuardianParsesListenersAndBuildsReports() {
|
||||||
let output = """
|
let output = """
|
||||||
p123
|
p123
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ Text is supported everywhere; media and reactions vary by channel.
|
|||||||
- [Slack](/channels/slack) — Bolt SDK; workspace apps.
|
- [Slack](/channels/slack) — Bolt SDK; workspace apps.
|
||||||
- [Signal](/channels/signal) — signal-cli; privacy-focused.
|
- [Signal](/channels/signal) — signal-cli; privacy-focused.
|
||||||
- [iMessage](/channels/imessage) — macOS only; native integration.
|
- [iMessage](/channels/imessage) — macOS only; native integration.
|
||||||
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately).
|
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support.
|
||||||
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
|
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
|
||||||
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
|
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
|
||||||
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
|
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
|
||||||
|
|||||||
+17
-45
@@ -3,43 +3,20 @@ summary: "Microsoft Teams bot support status, capabilities, and configuration"
|
|||||||
read_when:
|
read_when:
|
||||||
- Working on MS Teams channel features
|
- Working on MS Teams channel features
|
||||||
---
|
---
|
||||||
# Microsoft Teams (plugin)
|
# Microsoft Teams (Bot Framework)
|
||||||
|
|
||||||
> "Abandon all hope, ye who enter here."
|
> "Abandon all hope, ye who enter here."
|
||||||
|
|
||||||
|
|
||||||
Updated: 2026-01-16
|
Updated: 2026-01-08
|
||||||
|
|
||||||
Status: text + DM attachments are supported; channel/group attachments require Microsoft Graph permissions. Polls are sent via Adaptive Cards.
|
Status: text + DM attachments are supported; channel/group attachments require Microsoft Graph permissions. Polls are sent via Adaptive Cards.
|
||||||
|
|
||||||
## Plugin required
|
|
||||||
Microsoft Teams ships as a plugin and is not bundled with the core install.
|
|
||||||
|
|
||||||
**Breaking change (2026.1.15):** MS Teams moved out of core. If you use it, you must install the plugin.
|
|
||||||
|
|
||||||
Explainable: keeps core installs lighter and lets MS Teams dependencies update independently.
|
|
||||||
|
|
||||||
Install via CLI (npm registry):
|
|
||||||
```bash
|
|
||||||
clawdbot plugins install @clawdbot/msteams
|
|
||||||
```
|
|
||||||
|
|
||||||
Local checkout (when running from a git repo):
|
|
||||||
```bash
|
|
||||||
clawdbot plugins install ./extensions/msteams
|
|
||||||
```
|
|
||||||
|
|
||||||
If you choose Teams during configure/onboarding and a git checkout is detected,
|
|
||||||
Clawdbot will offer the local install path automatically.
|
|
||||||
|
|
||||||
Details: [Plugins](/plugin)
|
|
||||||
|
|
||||||
## Quick setup (beginner)
|
## Quick setup (beginner)
|
||||||
1) Install the Microsoft Teams plugin.
|
1) Create an **Azure Bot** (App ID + client secret + tenant ID).
|
||||||
2) Create an **Azure Bot** (App ID + client secret + tenant ID).
|
2) Configure Clawdbot with those credentials.
|
||||||
3) Configure Clawdbot with those credentials.
|
3) Expose `/api/messages` (port 3978 by default) via a public URL or tunnel.
|
||||||
4) Expose `/api/messages` (port 3978 by default) via a public URL or tunnel.
|
4) Install the Teams app package and start the gateway.
|
||||||
5) Install the Teams app package and start the gateway.
|
|
||||||
|
|
||||||
Minimal config:
|
Minimal config:
|
||||||
```json5
|
```json5
|
||||||
@@ -96,12 +73,11 @@ Example:
|
|||||||
```
|
```
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
1. Install the Microsoft Teams plugin.
|
1. Create an **Azure Bot** (App ID + secret + tenant ID).
|
||||||
2. Create an **Azure Bot** (App ID + secret + tenant ID).
|
2. Build a **Teams app package** that references the bot and includes the RSC permissions below.
|
||||||
3. Build a **Teams app package** that references the bot and includes the RSC permissions below.
|
3. Upload/install the Teams app into a team (or personal scope for DMs).
|
||||||
4. Upload/install the Teams app into a team (or personal scope for DMs).
|
4. Configure `msteams` in `~/.clawdbot/clawdbot.json` (or env vars) and start the gateway.
|
||||||
5. Configure `msteams` in `~/.clawdbot/clawdbot.json` (or env vars) and start the gateway.
|
5. The gateway listens for Bot Framework webhook traffic on `/api/messages` by default.
|
||||||
6. The gateway listens for Bot Framework webhook traffic on `/api/messages` by default.
|
|
||||||
|
|
||||||
## Azure Bot Setup (Prerequisites)
|
## Azure Bot Setup (Prerequisites)
|
||||||
|
|
||||||
@@ -190,17 +166,13 @@ This is often easier than hand-editing JSON manifests.
|
|||||||
3. Check gateway logs for incoming activity
|
3. Check gateway logs for incoming activity
|
||||||
|
|
||||||
## Setup (minimal text-only)
|
## Setup (minimal text-only)
|
||||||
1. **Install the Microsoft Teams plugin**
|
1. **Bot registration**
|
||||||
- From npm: `clawdbot plugins install @clawdbot/msteams`
|
|
||||||
- From a local checkout: `clawdbot plugins install ./extensions/msteams`
|
|
||||||
|
|
||||||
2. **Bot registration**
|
|
||||||
- Create an Azure Bot (see above) and note:
|
- Create an Azure Bot (see above) and note:
|
||||||
- App ID
|
- App ID
|
||||||
- Client secret (App password)
|
- Client secret (App password)
|
||||||
- Tenant ID (single-tenant)
|
- Tenant ID (single-tenant)
|
||||||
|
|
||||||
3. **Teams app manifest**
|
2. **Teams app manifest**
|
||||||
- Include a `bot` entry with `botId = <App ID>`.
|
- Include a `bot` entry with `botId = <App ID>`.
|
||||||
- Scopes: `personal`, `team`, `groupChat`.
|
- Scopes: `personal`, `team`, `groupChat`.
|
||||||
- `supportsFiles: true` (required for personal scope file handling).
|
- `supportsFiles: true` (required for personal scope file handling).
|
||||||
@@ -208,7 +180,7 @@ This is often easier than hand-editing JSON manifests.
|
|||||||
- Create icons: `outline.png` (32x32) and `color.png` (192x192).
|
- Create icons: `outline.png` (32x32) and `color.png` (192x192).
|
||||||
- Zip all three files together: `manifest.json`, `outline.png`, `color.png`.
|
- Zip all three files together: `manifest.json`, `outline.png`, `color.png`.
|
||||||
|
|
||||||
4. **Configure Clawdbot**
|
3. **Configure Clawdbot**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"msteams": {
|
"msteams": {
|
||||||
@@ -226,12 +198,12 @@ This is often easier than hand-editing JSON manifests.
|
|||||||
- `MSTEAMS_APP_PASSWORD`
|
- `MSTEAMS_APP_PASSWORD`
|
||||||
- `MSTEAMS_TENANT_ID`
|
- `MSTEAMS_TENANT_ID`
|
||||||
|
|
||||||
5. **Bot endpoint**
|
4. **Bot endpoint**
|
||||||
- Set the Azure Bot Messaging Endpoint to:
|
- Set the Azure Bot Messaging Endpoint to:
|
||||||
- `https://<host>:3978/api/messages` (or your chosen path/port).
|
- `https://<host>:3978/api/messages` (or your chosen path/port).
|
||||||
|
|
||||||
6. **Run the gateway**
|
5. **Run the gateway**
|
||||||
- The Teams channel starts automatically when the plugin is installed and `msteams` config exists with credentials.
|
- The Teams channel starts automatically when `msteams` config exists and credentials are set.
|
||||||
|
|
||||||
## History context
|
## History context
|
||||||
- `channels.msteams.historyLimit` controls how many recent channel/group messages are wrapped into the prompt.
|
- `channels.msteams.historyLimit` controls how many recent channel/group messages are wrapped into the prompt.
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ Minimal config:
|
|||||||
- `app_mention`
|
- `app_mention`
|
||||||
- `reaction_added`, `reaction_removed`
|
- `reaction_added`, `reaction_removed`
|
||||||
- `member_joined_channel`, `member_left_channel`
|
- `member_joined_channel`, `member_left_channel`
|
||||||
|
- `channel_id_changed`
|
||||||
- `channel_rename`
|
- `channel_rename`
|
||||||
- `pin_added`, `pin_removed`
|
- `pin_added`, `pin_removed`
|
||||||
6) Invite the bot to channels you want it to read.
|
6) Invite the bot to channels you want it to read.
|
||||||
@@ -112,6 +113,21 @@ Example with userTokenReadOnly explicitly set (allow user token writes):
|
|||||||
## History context
|
## History context
|
||||||
- `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt.
|
- `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt.
|
||||||
- Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
|
- Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
|
||||||
|
- DM history can be limited with `channels.slack.dmHistoryLimit` (user turns). Per-user overrides: `channels.slack.dms["<user_id>"].historyLimit`.
|
||||||
|
|
||||||
|
## Config writes
|
||||||
|
By default, Slack is allowed to write config updates triggered by channel events or `/config set|unset`.
|
||||||
|
|
||||||
|
This happens when:
|
||||||
|
- Slack emits `channel_id_changed` (e.g. Slack Connect channel ID changes). Clawdbot can migrate `channels.slack.channels` automatically.
|
||||||
|
- You run `/config set` or `/config unset` in Slack (requires `commands.config: true`).
|
||||||
|
|
||||||
|
Disable with:
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: { slack: { configWrites: false } }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Manifest (optional)
|
## Manifest (optional)
|
||||||
Use this Slack app manifest to create the app quickly (adjust the name/command if you want). Include the
|
Use this Slack app manifest to create the app quickly (adjust the name/command if you want). Include the
|
||||||
@@ -196,6 +212,7 @@ user scopes if you plan to configure a user token.
|
|||||||
"reaction_removed",
|
"reaction_removed",
|
||||||
"member_joined_channel",
|
"member_joined_channel",
|
||||||
"member_left_channel",
|
"member_left_channel",
|
||||||
|
"channel_id_changed",
|
||||||
"channel_rename",
|
"channel_rename",
|
||||||
"pin_added",
|
"pin_added",
|
||||||
"pin_removed"
|
"pin_removed"
|
||||||
@@ -326,6 +343,11 @@ By default, Clawdbot replies in the main channel. Use `channels.slack.replyToMod
|
|||||||
|
|
||||||
The mode applies to both auto-replies and agent tool calls (`slack sendMessage`).
|
The mode applies to both auto-replies and agent tool calls (`slack sendMessage`).
|
||||||
|
|
||||||
|
### Thread session isolation
|
||||||
|
Slack thread sessions are isolated by default. Configure with:
|
||||||
|
- `channels.slack.thread.historyScope`: `thread` (default) keeps per-thread history; `channel` shares history across the channel.
|
||||||
|
- `channels.slack.thread.inheritParent`: `false` (default) starts a clean thread session; `true` copies the parent channel transcript into the thread session.
|
||||||
|
|
||||||
### Manual threading tags
|
### Manual threading tags
|
||||||
For fine-grained control, use these tags in agent responses:
|
For fine-grained control, use these tags in agent responses:
|
||||||
- `[[reply_to_current]]` — reply to the triggering message (start/continue thread).
|
- `[[reply_to_current]]` — reply to the triggering message (start/continue thread).
|
||||||
@@ -390,4 +412,5 @@ Slack tool actions can be gated with `channels.slack.actions.*`:
|
|||||||
- Bot-authored messages are ignored by default; enable via `channels.slack.allowBots` or `channels.slack.channels.<id>.allowBots`.
|
- Bot-authored messages are ignored by default; enable via `channels.slack.allowBots` or `channels.slack.channels.<id>.allowBots`.
|
||||||
- Warning: If you allow replies to other bots (`channels.slack.allowBots=true` or `channels.slack.channels.<id>.allowBots=true`), prevent bot-to-bot reply loops with `requireMention`, `channels.slack.channels.<id>.users` allowlists, and/or clear guardrails in `AGENTS.md` and `SOUL.md`.
|
- Warning: If you allow replies to other bots (`channels.slack.allowBots=true` or `channels.slack.channels.<id>.allowBots=true`), prevent bot-to-bot reply loops with `requireMention`, `channels.slack.channels.<id>.users` allowlists, and/or clear guardrails in `AGENTS.md` and `SOUL.md`.
|
||||||
- For the Slack tool, reaction removal semantics are in [/tools/reactions](/tools/reactions).
|
- For the Slack tool, reaction removal semantics are in [/tools/reactions](/tools/reactions).
|
||||||
|
- Read/pin tool payloads include normalized `timestampMs` (UTC epoch ms) and `timestampUtc` alongside raw Slack `ts`.
|
||||||
- Attachments are downloaded to the media store when permitted and under the size limit.
|
- Attachments are downloaded to the media store when permitted and under the size limit.
|
||||||
@@ -188,74 +188,11 @@ Disable with:
|
|||||||
Telegram forum topics include a `message_thread_id` per message. Clawdbot:
|
Telegram forum topics include a `message_thread_id` per message. Clawdbot:
|
||||||
- Appends `:topic:<threadId>` to the Telegram group session key so each topic is isolated.
|
- Appends `:topic:<threadId>` to the Telegram group session key so each topic is isolated.
|
||||||
- Sends typing indicators and replies with `message_thread_id` so responses stay in the topic.
|
- Sends typing indicators and replies with `message_thread_id` so responses stay in the topic.
|
||||||
- General topic (thread id `1`) is special: message sends omit `message_thread_id` (Telegram rejects it), but typing indicators still include it.
|
|
||||||
- Exposes `MessageThreadId` + `IsForum` in template context for routing/templating.
|
- Exposes `MessageThreadId` + `IsForum` in template context for routing/templating.
|
||||||
- Topic-specific configuration is available under `channels.telegram.groups.<chatId>.topics.<threadId>` (skills, allowlists, auto-reply, system prompts, disable).
|
- Topic-specific configuration is available under `channels.telegram.groups.<chatId>.topics.<threadId>` (skills, allowlists, auto-reply, system prompts, disable).
|
||||||
|
|
||||||
Private chats can include `message_thread_id` in some edge cases. Clawdbot keeps the DM session key unchanged, but still uses the thread id for replies/draft streaming when it is present.
|
Private chats can include `message_thread_id` in some edge cases. Clawdbot keeps the DM session key unchanged, but still uses the thread id for replies/draft streaming when it is present.
|
||||||
|
|
||||||
## Inline Buttons
|
|
||||||
|
|
||||||
Telegram supports inline keyboards with callback buttons. Enable this feature via capabilities:
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
"channels": {
|
|
||||||
"telegram": {
|
|
||||||
"capabilities": ["inlineButtons"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
For per-account configuration:
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
"channels": {
|
|
||||||
"telegram": {
|
|
||||||
"accounts": {
|
|
||||||
"main": {
|
|
||||||
"capabilities": ["inlineButtons"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Sending buttons
|
|
||||||
|
|
||||||
Use the message tool with the `buttons` parameter:
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
"action": "send",
|
|
||||||
"channel": "telegram",
|
|
||||||
"to": "123456789",
|
|
||||||
"message": "Choose an option:",
|
|
||||||
"buttons": [
|
|
||||||
[
|
|
||||||
{"text": "Yes", "callback_data": "yes"},
|
|
||||||
{"text": "No", "callback_data": "no"}
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{"text": "Cancel", "callback_data": "cancel"}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
When a user clicks a button, the callback data is sent back to the agent as a message with the format:
|
|
||||||
`callback_data: value`
|
|
||||||
|
|
||||||
### Configuration options
|
|
||||||
|
|
||||||
Telegram capabilities can be configured at two levels:
|
|
||||||
|
|
||||||
- `channels.telegram.capabilities`: Global default capability list applied to all Telegram accounts unless overridden.
|
|
||||||
- `channels.telegram.accounts.<account>.capabilities`: Per-account capabilities that override or extend the global defaults for that specific account.
|
|
||||||
|
|
||||||
Use the global setting when all Telegram bots/accounts should behave the same. Use per-account configuration when different bots need different behaviors (for example, one account only handles DMs while another is allowed in groups or has extra capabilities).
|
|
||||||
## Access control (DMs + groups)
|
## Access control (DMs + groups)
|
||||||
|
|
||||||
### DM access
|
### DM access
|
||||||
@@ -454,8 +391,6 @@ Provider options:
|
|||||||
- `channels.telegram.groups.<id>.enabled`: disable the group when `false`.
|
- `channels.telegram.groups.<id>.enabled`: disable the group when `false`.
|
||||||
- `channels.telegram.groups.<id>.topics.<threadId>.*`: per-topic overrides (same fields as group).
|
- `channels.telegram.groups.<id>.topics.<threadId>.*`: per-topic overrides (same fields as group).
|
||||||
- `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override.
|
- `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override.
|
||||||
- `channels.telegram.capabilities`: Enable channel features (e.g., "inlineButtons").
|
|
||||||
- `channels.telegram.accounts.<account>.capabilities`: Per-account capabilities.
|
|
||||||
- `channels.telegram.replyToMode`: `off | first | all` (default: `first`).
|
- `channels.telegram.replyToMode`: `off | first | all` (default: `first`).
|
||||||
- `channels.telegram.textChunkLimit`: outbound chunk size (chars).
|
- `channels.telegram.textChunkLimit`: outbound chunk size (chars).
|
||||||
- `channels.telegram.streamMode`: `off | partial | block` (draft streaming).
|
- `channels.telegram.streamMode`: `off | partial | block` (draft streaming).
|
||||||
|
|||||||
@@ -296,9 +296,8 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
|
|||||||
|
|
||||||
## Heartbeats
|
## Heartbeats
|
||||||
- **Gateway heartbeat** logs connection health (`web.heartbeatSeconds`, default 60s).
|
- **Gateway heartbeat** logs connection health (`web.heartbeatSeconds`, default 60s).
|
||||||
- **Agent heartbeat** can be configured per agent (`agents.list[].heartbeat`) or globally
|
- **Agent heartbeat** is global (`agents.defaults.heartbeat.*`) and runs in the main session.
|
||||||
via `agents.defaults.heartbeat` (fallback when no per-agent entries are set).
|
- Uses the configured heartbeat prompt (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`) + `HEARTBEAT_OK` skip behavior.
|
||||||
- Uses the configured heartbeat prompt (default: `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`) + `HEARTBEAT_OK` skip behavior.
|
|
||||||
- Delivery defaults to the last used channel (or configured target).
|
- Delivery defaults to the last used channel (or configured target).
|
||||||
|
|
||||||
## Reconnect behavior
|
## Reconnect behavior
|
||||||
@@ -331,7 +330,6 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
|
|||||||
- `agents.defaults.heartbeat.model` (optional override)
|
- `agents.defaults.heartbeat.model` (optional override)
|
||||||
- `agents.defaults.heartbeat.target`
|
- `agents.defaults.heartbeat.target`
|
||||||
- `agents.defaults.heartbeat.to`
|
- `agents.defaults.heartbeat.to`
|
||||||
- `agents.list[].heartbeat.*` (per-agent overrides)
|
|
||||||
- `session.*` (scope, idle, store, mainKey)
|
- `session.*` (scope, idle, store, mainKey)
|
||||||
- `web.enabled` (disable channel startup when false)
|
- `web.enabled` (disable channel startup when false)
|
||||||
- `web.heartbeatSeconds`
|
- `web.heartbeatSeconds`
|
||||||
|
|||||||
@@ -11,12 +11,10 @@ Interactive prompt to set up credentials, devices, and agent defaults.
|
|||||||
Related:
|
Related:
|
||||||
- Gateway configuration reference: [Configuration](/gateway/configuration)
|
- Gateway configuration reference: [Configuration](/gateway/configuration)
|
||||||
|
|
||||||
Notes:
|
|
||||||
- Choosing where the Gateway runs always updates `gateway.mode`. You can select "Continue" without other sections if that is all you need.
|
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot configure
|
clawdbot configure
|
||||||
clawdbot configure --section models --section channels
|
clawdbot configure --section models --section channels
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -15,6 +15,3 @@ Related:
|
|||||||
|
|
||||||
Tip: run `clawdbot daemon --help` for platform-specific flags.
|
Tip: run `clawdbot daemon --help` for platform-specific flags.
|
||||||
|
|
||||||
Notes:
|
|
||||||
- `daemon status` supports `--json` for scripting.
|
|
||||||
- `daemon install|uninstall|start|stop|restart` support `--json` for scripting (default output stays human-friendly).
|
|
||||||
+2
-3
@@ -594,9 +594,8 @@ Notes:
|
|||||||
- `daemon status` supports `--no-probe`, `--deep`, and `--json` for scripting.
|
- `daemon status` supports `--no-probe`, `--deep`, and `--json` for scripting.
|
||||||
- `daemon status` also surfaces legacy or extra gateway services when it can detect them (`--deep` adds system-level scans). Profile-named Clawdbot services are treated as first-class and aren't flagged as "extra".
|
- `daemon status` also surfaces legacy or extra gateway services when it can detect them (`--deep` adds system-level scans). Profile-named Clawdbot services are treated as first-class and aren't flagged as "extra".
|
||||||
- `daemon status` prints which config path the CLI uses vs which config the daemon likely uses (service env), plus the resolved probe target URL.
|
- `daemon status` prints which config path the CLI uses vs which config the daemon likely uses (service env), plus the resolved probe target URL.
|
||||||
- `daemon install|uninstall|start|stop|restart` support `--json` for scripting (default output stays human-friendly).
|
|
||||||
- `daemon install` defaults to Node runtime; bun is **not recommended** (WhatsApp/Telegram bugs).
|
- `daemon install` defaults to Node runtime; bun is **not recommended** (WhatsApp/Telegram bugs).
|
||||||
- `daemon install` options: `--port`, `--runtime`, `--token`, `--force`, `--json`.
|
- `daemon install` options: `--port`, `--runtime`, `--token`, `--force`.
|
||||||
|
|
||||||
### `logs`
|
### `logs`
|
||||||
Tail Gateway file logs via RPC.
|
Tail Gateway file logs via RPC.
|
||||||
@@ -786,7 +785,7 @@ Location:
|
|||||||
|
|
||||||
## Browser
|
## Browser
|
||||||
|
|
||||||
Browser control CLI (dedicated Chrome/Brave/Edge/Chromium). See [`clawdbot browser`](/cli/browser) and the [Browser tool](/tools/browser).
|
Browser control CLI (dedicated Chrome/Chromium). See [`clawdbot browser`](/cli/browser) and the [Browser tool](/tools/browser).
|
||||||
|
|
||||||
Common options:
|
Common options:
|
||||||
- `--url <controlUrl>`
|
- `--url <controlUrl>`
|
||||||
|
|||||||
+1
-1
@@ -44,7 +44,7 @@ Target formats (`--to`):
|
|||||||
|
|
||||||
- `send`
|
- `send`
|
||||||
- Channels: WhatsApp/Telegram/Discord/Slack/Signal/iMessage/MS Teams
|
- Channels: WhatsApp/Telegram/Discord/Slack/Signal/iMessage/MS Teams
|
||||||
- Required: `--to`, plus `--message` or `--media`
|
- Required: `--to`, `--message`
|
||||||
- Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback`
|
- Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback`
|
||||||
- Telegram only: `--buttons` (requires `"inlineButtons"` in `channels.telegram.capabilities` or `channels.telegram.accounts.<id>.capabilities`)
|
- Telegram only: `--buttons` (requires `"inlineButtons"` in `channels.telegram.capabilities` or `channels.telegram.accounts.<id>.capabilities`)
|
||||||
- Telegram only: `--thread-id` (forum topic id)
|
- Telegram only: `--thread-id` (forum topic id)
|
||||||
|
|||||||
@@ -22,10 +22,6 @@ clawdbot models set <model-or-alias>
|
|||||||
clawdbot models scan
|
clawdbot models scan
|
||||||
```
|
```
|
||||||
|
|
||||||
`clawdbot models status` shows the resolved default/fallbacks plus an auth overview.
|
|
||||||
When provider usage snapshots are available, the OAuth/token status section includes
|
|
||||||
provider usage headers.
|
|
||||||
|
|
||||||
## Aliases + fallbacks
|
## Aliases + fallbacks
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -37,13 +33,7 @@ clawdbot models fallbacks list
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot models auth add
|
clawdbot models auth add
|
||||||
clawdbot models auth login --provider <id>
|
|
||||||
clawdbot models auth setup-token
|
clawdbot models auth setup-token
|
||||||
clawdbot models auth paste-token
|
clawdbot models auth paste-token
|
||||||
```
|
```
|
||||||
`models auth login` runs a provider plugin’s auth flow (OAuth/API key). Use
|
|
||||||
`clawdbot plugins list` to see which providers are installed.
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- `setup-token` runs `claude setup-token` on the current machine (requires the Claude Code CLI).
|
|
||||||
- `paste-token` accepts a token string generated elsewhere.
|
|
||||||
@@ -20,4 +20,3 @@ clawdbot security audit --deep
|
|||||||
clawdbot security audit --fix
|
clawdbot security audit --fix
|
||||||
```
|
```
|
||||||
|
|
||||||
The audit warns when multiple DM senders share the main session and recommends `session.dmScope="per-channel-peer"` for shared inboxes.
|
|
||||||
@@ -48,17 +48,6 @@ If no explicit order is configured, Clawdbot uses a round‑robin order:
|
|||||||
- **Secondary key:** `usageStats.lastUsed` (oldest first, within each type).
|
- **Secondary key:** `usageStats.lastUsed` (oldest first, within each type).
|
||||||
- **Cooldown/disabled profiles** are moved to the end, ordered by soonest expiry.
|
- **Cooldown/disabled profiles** are moved to the end, ordered by soonest expiry.
|
||||||
|
|
||||||
### Session stickiness (cache-friendly)
|
|
||||||
|
|
||||||
Clawdbot **pins the chosen auth profile per session** to keep provider caches warm.
|
|
||||||
It does **not** rotate on every request. The pinned profile is reused until:
|
|
||||||
- the session is reset (`/new` / `/reset`)
|
|
||||||
- a compaction completes (compaction count increments)
|
|
||||||
- the profile is in cooldown/disabled
|
|
||||||
|
|
||||||
Manual selection via `/model …@<profileId>` sets a **user override** for that session
|
|
||||||
and is not auto‑rotated until a new session starts.
|
|
||||||
|
|
||||||
### Why OAuth can “look lost”
|
### Why OAuth can “look lost”
|
||||||
|
|
||||||
If you have both an OAuth profile and an API key profile for the same provider, round‑robin can switch between them across messages unless pinned. To force a single profile:
|
If you have both an OAuth profile and an API key profile for the same provider, round‑robin can switch between them across messages unless pinned. To force a single profile:
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ JSON includes `auth.oauth` (warn window + profiles) and `auth.providers`
|
|||||||
(effective auth per provider).
|
(effective auth per provider).
|
||||||
Use `--check` for automation (exit `1` when missing/expired, `2` when expiring).
|
Use `--check` for automation (exit `1` when missing/expired, `2` when expiring).
|
||||||
|
|
||||||
Preferred Anthropic auth is the Claude Code CLI setup-token (run anywhere; paste on the gateway host if needed):
|
Preferred Anthropic auth is the Claude Code CLI setup-token (run on the gateway host):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
claude setup-token
|
claude setup-token
|
||||||
|
|||||||
@@ -15,13 +15,6 @@ Clawdbot supports “subscription auth” via OAuth for providers that offer it
|
|||||||
- how we **reuse external CLI tokens** (Claude Code / Codex CLI)
|
- how we **reuse external CLI tokens** (Claude Code / Codex CLI)
|
||||||
- how to handle **multiple accounts** (profiles + per-session overrides)
|
- how to handle **multiple accounts** (profiles + per-session overrides)
|
||||||
|
|
||||||
Clawdbot also supports **provider plugins** that ship their own OAuth or API‑key
|
|
||||||
flows. Run them via:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot models auth login --provider <id>
|
|
||||||
```
|
|
||||||
|
|
||||||
## The token sink (why it exists)
|
## The token sink (why it exists)
|
||||||
|
|
||||||
OAuth providers commonly mint a **new refresh token** during login/refresh flows. Some providers (or OAuth clients) can invalidate older refresh tokens when a new one is issued for the same user/app.
|
OAuth providers commonly mint a **new refresh token** during login/refresh flows. Some providers (or OAuth clients) can invalidate older refresh tokens when a new one is issued for the same user/app.
|
||||||
|
|||||||
@@ -7,11 +7,6 @@ read_when:
|
|||||||
|
|
||||||
Clawdbot treats **one direct-chat session per agent** as primary. Direct chats collapse to `agent:<agentId>:<mainKey>` (default `main`), while group/channel chats get their own keys. `session.mainKey` is honored.
|
Clawdbot treats **one direct-chat session per agent** as primary. Direct chats collapse to `agent:<agentId>:<mainKey>` (default `main`), while group/channel chats get their own keys. `session.mainKey` is honored.
|
||||||
|
|
||||||
Use `session.dmScope` to control how **direct messages** are grouped:
|
|
||||||
- `main` (default): all DMs share the main session for continuity.
|
|
||||||
- `per-peer`: isolate by sender id across channels.
|
|
||||||
- `per-channel-peer`: isolate by channel + sender (recommended for multi-user inboxes).
|
|
||||||
|
|
||||||
## Gateway is the source of truth
|
## Gateway is the source of truth
|
||||||
All session state is **owned by the gateway** (the “master” Clawdbot). UI clients (macOS app, WebChat, etc.) must query the gateway for session lists and token counts instead of reading local files.
|
All session state is **owned by the gateway** (the “master” Clawdbot). UI clients (macOS app, WebChat, etc.) must query the gateway for session lists and token counts instead of reading local files.
|
||||||
|
|
||||||
@@ -37,11 +32,8 @@ the workspace is writable. See [Memory](/concepts/memory) and
|
|||||||
[Compaction](/concepts/compaction).
|
[Compaction](/concepts/compaction).
|
||||||
|
|
||||||
## Mapping transports → session keys
|
## Mapping transports → session keys
|
||||||
- Direct chats follow `session.dmScope` (default `main`).
|
- Direct chats collapse to the per-agent primary key: `agent:<agentId>:<mainKey>`.
|
||||||
- `main`: `agent:<agentId>:<mainKey>` (continuity across devices/channels).
|
|
||||||
- Multiple phone numbers and channels can map to the same agent main key; they act as transports into one conversation.
|
- Multiple phone numbers and channels can map to the same agent main key; they act as transports into one conversation.
|
||||||
- `per-peer`: `agent:<agentId>:dm:<peerId>`.
|
|
||||||
- `per-channel-peer`: `agent:<agentId>:<channel>:dm:<peerId>`.
|
|
||||||
- Group chats isolate state: `agent:<agentId>:<channel>:group:<id>` (rooms/channels use `agent:<agentId>:<channel>:channel:<id>`).
|
- Group chats isolate state: `agent:<agentId>:<channel>:group:<id>` (rooms/channels use `agent:<agentId>:<channel>:channel:<id>`).
|
||||||
- Telegram forum topics append `:topic:<threadId>` to the group id for isolation.
|
- Telegram forum topics append `:topic:<threadId>` to the group id for isolation.
|
||||||
- Legacy `group:<id>` keys are still recognized for migration.
|
- Legacy `group:<id>` keys are still recognized for migration.
|
||||||
@@ -85,7 +77,6 @@ Send these as standalone messages so they register.
|
|||||||
{
|
{
|
||||||
session: {
|
session: {
|
||||||
scope: "per-sender", // keep group keys separate
|
scope: "per-sender", // keep group keys separate
|
||||||
dmScope: "main", // DM continuity (set per-channel-peer for shared inboxes)
|
|
||||||
idleMinutes: 120,
|
idleMinutes: 120,
|
||||||
resetTriggers: ["/new", "/reset"],
|
resetTriggers: ["/new", "/reset"],
|
||||||
store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json",
|
store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json",
|
||||||
|
|||||||
@@ -26,22 +26,6 @@ The prompt is intentionally compact and uses fixed sections:
|
|||||||
- **Runtime**: host, OS, node, model, thinking level (one line).
|
- **Runtime**: host, OS, node, model, thinking level (one line).
|
||||||
- **Reasoning**: current visibility level + /reasoning toggle hint.
|
- **Reasoning**: current visibility level + /reasoning toggle hint.
|
||||||
|
|
||||||
## Prompt modes
|
|
||||||
|
|
||||||
Clawdbot can render smaller system prompts for sub-agents. The runtime sets a
|
|
||||||
`promptMode` for each run (not a user-facing config):
|
|
||||||
|
|
||||||
- `full` (default): includes all sections above.
|
|
||||||
- `minimal`: used for sub-agents; omits **Skills**, **Memory Recall**, **Clawdbot
|
|
||||||
Self-Update**, **Model Aliases**, **User Identity**, **Reply Tags**,
|
|
||||||
**Messaging**, **Silent Replies**, and **Heartbeats**. Tooling, Workspace,
|
|
||||||
Sandbox, Current Date & Time (when known), Runtime, and injected context stay
|
|
||||||
available.
|
|
||||||
- `none`: returns only the base identity line.
|
|
||||||
|
|
||||||
When `promptMode=minimal`, extra injected prompts are labeled **Subagent
|
|
||||||
Context** instead of **Group Chat Context**.
|
|
||||||
|
|
||||||
## Workspace bootstrap injection
|
## Workspace bootstrap injection
|
||||||
|
|
||||||
Bootstrap files are trimmed and appended under **Project Context** so the model sees identity and profile context without needing explicit reads:
|
Bootstrap files are trimmed and appended under **Project Context** so the model sees identity and profile context without needing explicit reads:
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ Common methods + events:
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Core | `connect`, `health`, `status` | `connect` must be first |
|
| Core | `connect`, `health`, `status` | `connect` must be first |
|
||||||
| Messaging | `send`, `poll`, `agent`, `agent.wait` | side-effects need `idempotencyKey` |
|
| Messaging | `send`, `poll`, `agent`, `agent.wait` | side-effects need `idempotencyKey` |
|
||||||
| Chat | `chat.history`, `chat.send`, `chat.abort`, `chat.inject` | WebChat uses these |
|
| Chat | `chat.history`, `chat.send`, `chat.abort` | WebChat uses these |
|
||||||
| Sessions | `sessions.list`, `sessions.patch`, `sessions.delete` | session admin |
|
| Sessions | `sessions.list`, `sessions.patch`, `sessions.delete` | session admin |
|
||||||
| Nodes | `node.list`, `node.invoke`, `node.pair.*` | bridge + node actions |
|
| Nodes | `node.list`, `node.invoke`, `node.pair.*` | bridge + node actions |
|
||||||
| Events | `tick`, `presence`, `agent`, `chat`, `health`, `shutdown` | server push |
|
| Events | `tick`, `presence`, `agent`, `chat`, `health`, `shutdown` | server push |
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ read_when:
|
|||||||
- No estimated costs; only the provider-reported windows.
|
- No estimated costs; only the provider-reported windows.
|
||||||
|
|
||||||
## Where it shows up
|
## Where it shows up
|
||||||
- `/status` in chats: emoji‑rich status card with session tokens + estimated cost (API key only). When OAuth/token profiles exist, the **OAuth/token status block** includes provider usage headers (when available).
|
- `/status` in chats: emoji‑rich status card with session tokens + estimated cost (API key only) and provider quota windows when available.
|
||||||
- `/cost on|off` in chats: toggles per‑response usage lines (OAuth shows tokens only).
|
- `/cost on|off` in chats: toggles per‑response usage lines (OAuth shows tokens only).
|
||||||
- CLI: `clawdbot status --usage` prints a full per-provider breakdown.
|
- CLI: `clawdbot status --usage` prints a full per-provider breakdown.
|
||||||
- CLI: `clawdbot channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
|
- CLI: `clawdbot channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
|
||||||
|
|||||||
@@ -28,12 +28,8 @@ If you are building an operator client (CLI, web UI, automations), use the
|
|||||||
## Transport
|
## Transport
|
||||||
|
|
||||||
- TCP, one JSON object per line (JSONL).
|
- TCP, one JSON object per line (JSONL).
|
||||||
- Optional TLS (when `bridge.tls.enabled` is true).
|
|
||||||
- Gateway owns the listener (default `18790`).
|
- Gateway owns the listener (default `18790`).
|
||||||
|
|
||||||
When TLS is enabled, discovery TXT records include `bridgeTls=1` plus
|
|
||||||
`bridgeTlsSha256` so nodes can pin the certificate.
|
|
||||||
|
|
||||||
## Handshake + pairing
|
## Handshake + pairing
|
||||||
|
|
||||||
1) Client sends `hello` with node metadata + token (if already paired).
|
1) Client sends `hello` with node metadata + token (if already paired).
|
||||||
|
|||||||
@@ -455,44 +455,6 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Anthropic subscription + API key, MiniMax fallback
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
auth: {
|
|
||||||
profiles: {
|
|
||||||
"anthropic:subscription": {
|
|
||||||
provider: "anthropic",
|
|
||||||
mode: "oauth",
|
|
||||||
email: "user@example.com"
|
|
||||||
},
|
|
||||||
"anthropic:api": {
|
|
||||||
provider: "anthropic",
|
|
||||||
mode: "api_key"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
order: {
|
|
||||||
anthropic: ["anthropic:subscription", "anthropic:api"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
models: {
|
|
||||||
providers: {
|
|
||||||
minimax: {
|
|
||||||
baseUrl: "https://api.minimax.io/anthropic",
|
|
||||||
api: "anthropic-messages",
|
|
||||||
apiKey: "${MINIMAX_API_KEY}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
agent: {
|
|
||||||
workspace: "~/clawd",
|
|
||||||
model: {
|
|
||||||
primary: "anthropic/claude-opus-4-5",
|
|
||||||
fallbacks: ["minimax/MiniMax-M2.1"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Work bot (restricted access)
|
### Work bot (restricted access)
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1052,6 +1052,8 @@ Slack runs in Socket Mode and requires both a bot token and app token:
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
botToken: "xoxb-...",
|
botToken: "xoxb-...",
|
||||||
appToken: "xapp-...",
|
appToken: "xapp-...",
|
||||||
|
userToken: "xoxp-...", // optional (read-only by default)
|
||||||
|
userTokenReadOnly: true, // default: true
|
||||||
dm: {
|
dm: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
policy: "pairing", // pairing | allowlist | open | disabled
|
policy: "pairing", // pairing | allowlist | open | disabled
|
||||||
@@ -1102,6 +1104,7 @@ Slack runs in Socket Mode and requires both a bot token and app token:
|
|||||||
Multi-account support lives under `channels.slack.accounts` (see the multi-account section above). Env tokens only apply to the default account.
|
Multi-account support lives under `channels.slack.accounts` (see the multi-account section above). Env tokens only apply to the default account.
|
||||||
|
|
||||||
Clawdbot starts Slack when the provider is enabled and both tokens are set (via config or `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN`). Use `user:<id>` (DM) or `channel:<id>` when specifying delivery targets for cron/CLI commands.
|
Clawdbot starts Slack when the provider is enabled and both tokens are set (via config or `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN`). Use `user:<id>` (DM) or `channel:<id>` when specifying delivery targets for cron/CLI commands.
|
||||||
|
Optional user tokens (`xoxp-...`) are configured in the config file (no env var support). Reads prefer the user token when present; writes use the bot token unless you set `channels.slack.userTokenReadOnly: false` and no bot token is available.
|
||||||
Set `channels.slack.configWrites: false` to block Slack-initiated config writes (including channel ID migrations and `/config set|unset`).
|
Set `channels.slack.configWrites: false` to block Slack-initiated config writes (including channel ID migrations and `/config set|unset`).
|
||||||
|
|
||||||
Bot-authored messages are ignored by default. Enable with `channels.slack.allowBots` or `channels.slack.channels.<id>.allowBots`.
|
Bot-authored messages are ignored by default. Enable with `channels.slack.allowBots` or `channels.slack.channels.<id>.allowBots`.
|
||||||
@@ -1675,14 +1678,9 @@ Z.AI models are available as `zai/<model>` (e.g. `zai/glm-4.7`) and require
|
|||||||
- `includeReasoning`: when `true`, heartbeats will also deliver the separate `Reasoning:` message when available (same shape as `/reasoning on`). Default: `false`.
|
- `includeReasoning`: when `true`, heartbeats will also deliver the separate `Reasoning:` message when available (same shape as `/reasoning on`). Default: `false`.
|
||||||
- `target`: optional delivery channel (`last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `none`). Default: `last`.
|
- `target`: optional delivery channel (`last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `none`). Default: `last`.
|
||||||
- `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp, chat id for Telegram).
|
- `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp, chat id for Telegram).
|
||||||
- `prompt`: optional override for the heartbeat body (default: `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`). Overrides are sent verbatim; include a `Read HEARTBEAT.md` line if you still want the file read.
|
- `prompt`: optional override for the heartbeat body (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`). Overrides are sent verbatim; include a `Read HEARTBEAT.md if exists` line if you still want the file read.
|
||||||
- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery (default: 300).
|
- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery (default: 300).
|
||||||
|
|
||||||
Per-agent heartbeats:
|
|
||||||
- Set `agents.list[].heartbeat` to enable or override heartbeat settings for a specific agent.
|
|
||||||
- If any agent entry defines `heartbeat`, **only those agents** run heartbeats; defaults
|
|
||||||
become the shared baseline for those agents.
|
|
||||||
|
|
||||||
Heartbeats run full agent turns. Shorter intervals burn more tokens; be mindful
|
Heartbeats run full agent turns. Shorter intervals burn more tokens; be mindful
|
||||||
of `every`, keep `HEARTBEAT.md` tiny, and/or choose a cheaper `model`.
|
of `every`, keep `HEARTBEAT.md` tiny, and/or choose a cheaper `model`.
|
||||||
|
|
||||||
@@ -2261,7 +2259,6 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto
|
|||||||
{
|
{
|
||||||
session: {
|
session: {
|
||||||
scope: "per-sender",
|
scope: "per-sender",
|
||||||
dmScope: "main",
|
|
||||||
idleMinutes: 60,
|
idleMinutes: 60,
|
||||||
resetTriggers: ["/new", "/reset"],
|
resetTriggers: ["/new", "/reset"],
|
||||||
// Default is already per-agent under ~/.clawdbot/agents/<agentId>/sessions/sessions.json
|
// Default is already per-agent under ~/.clawdbot/agents/<agentId>/sessions/sessions.json
|
||||||
@@ -2286,10 +2283,6 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto
|
|||||||
Fields:
|
Fields:
|
||||||
- `mainKey`: direct-chat bucket key (default: `"main"`). Useful when you want to “rename” the primary DM thread without changing `agentId`.
|
- `mainKey`: direct-chat bucket key (default: `"main"`). Useful when you want to “rename” the primary DM thread without changing `agentId`.
|
||||||
- Sandbox note: `agents.defaults.sandbox.mode: "non-main"` uses this key to detect the main session. Any session key that does not match `mainKey` (groups/channels) is sandboxed.
|
- Sandbox note: `agents.defaults.sandbox.mode: "non-main"` uses this key to detect the main session. Any session key that does not match `mainKey` (groups/channels) is sandboxed.
|
||||||
- `dmScope`: how DM sessions are grouped (default: `"main"`).
|
|
||||||
- `main`: all DMs share the main session for continuity.
|
|
||||||
- `per-peer`: isolate DMs by sender id across channels.
|
|
||||||
- `per-channel-peer`: isolate DMs per channel + sender (recommended for multi-user inboxes).
|
|
||||||
- `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (0–5, default 5).
|
- `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (0–5, default 5).
|
||||||
- `sendPolicy.default`: `allow` or `deny` fallback when no rule matches.
|
- `sendPolicy.default`: `allow` or `deny` fallback when no rule matches.
|
||||||
- `sendPolicy.rules[]`: match by `channel`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow.
|
- `sendPolicy.rules[]`: match by `channel`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow.
|
||||||
@@ -2381,10 +2374,10 @@ Example:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### `browser` (clawd-managed browser)
|
### `browser` (clawd-managed Chrome)
|
||||||
|
|
||||||
Clawdbot can start a **dedicated, isolated** Chrome/Brave/Edge/Chromium instance for clawd and expose a small loopback control server.
|
Clawdbot can start a **dedicated, isolated** Chrome/Chromium instance for clawd and expose a small loopback control server.
|
||||||
Profiles can point at a **remote** Chromium-based browser via `profiles.<name>.cdpUrl`. Remote
|
Profiles can point at a **remote** Chrome via `profiles.<name>.cdpUrl`. Remote
|
||||||
profiles are attach-only (start/stop/reset are disabled).
|
profiles are attach-only (start/stop/reset are disabled).
|
||||||
|
|
||||||
`browser.cdpUrl` remains for legacy single-profile configs and as the base
|
`browser.cdpUrl` remains for legacy single-profile configs and as the base
|
||||||
@@ -2396,7 +2389,6 @@ Defaults:
|
|||||||
- CDP URL: `http://127.0.0.1:18792` (control URL + 1, legacy single-profile)
|
- CDP URL: `http://127.0.0.1:18792` (control URL + 1, legacy single-profile)
|
||||||
- profile color: `#FF4500` (lobster-orange)
|
- profile color: `#FF4500` (lobster-orange)
|
||||||
- Note: the control server is started by the running gateway (Clawdbot.app menubar, or `clawdbot gateway`).
|
- Note: the control server is started by the running gateway (Clawdbot.app menubar, or `clawdbot gateway`).
|
||||||
- Auto-detect order: default browser if Chromium-based; otherwise Chrome → Brave → Edge → Chromium → Chrome Canary.
|
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
@@ -2414,7 +2406,7 @@ Defaults:
|
|||||||
// Advanced:
|
// Advanced:
|
||||||
// headless: false,
|
// headless: false,
|
||||||
// noSandbox: false,
|
// noSandbox: false,
|
||||||
// executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
// executablePath: "/usr/bin/chromium",
|
||||||
// attachOnly: false, // set true when tunneling a remote CDP to localhost
|
// attachOnly: false, // set true when tunneling a remote CDP to localhost
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2732,29 +2724,12 @@ Bind modes:
|
|||||||
- `loopback`: `127.0.0.1` (local only)
|
- `loopback`: `127.0.0.1` (local only)
|
||||||
- `auto`: prefer tailnet IP if present, else `lan`
|
- `auto`: prefer tailnet IP if present, else `lan`
|
||||||
|
|
||||||
TLS:
|
|
||||||
- `bridge.tls.enabled`: enable TLS for bridge connections (TLS-only when enabled).
|
|
||||||
- `bridge.tls.autoGenerate`: generate a self-signed cert when no cert/key are present (default: true).
|
|
||||||
- `bridge.tls.certPath` / `bridge.tls.keyPath`: PEM paths for the bridge certificate + private key.
|
|
||||||
- `bridge.tls.caPath`: optional PEM CA bundle (custom roots or future mTLS).
|
|
||||||
|
|
||||||
When TLS is enabled, the Gateway advertises `bridgeTls=1` and `bridgeTlsSha256` in discovery TXT
|
|
||||||
records so nodes can pin the certificate. Manual connections use trust-on-first-use if no
|
|
||||||
fingerprint is stored yet.
|
|
||||||
Auto-generated certs require `openssl` on PATH; if generation fails, the bridge will not start.
|
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
bridge: {
|
bridge: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
port: 18790,
|
port: 18790,
|
||||||
bind: "tailnet",
|
bind: "tailnet"
|
||||||
tls: {
|
|
||||||
enabled: true,
|
|
||||||
// Uses ~/.clawdbot/bridge/tls/bridge-{cert,key}.pem when omitted.
|
|
||||||
// certPath: "~/.clawdbot/bridge/tls/bridge-cert.pem",
|
|
||||||
// keyPath: "~/.clawdbot/bridge/tls/bridge-key.pem"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ Doctor checks:
|
|||||||
### 5) Model auth health (OAuth expiry)
|
### 5) Model auth health (OAuth expiry)
|
||||||
Doctor inspects OAuth profiles in the auth store, warns when tokens are
|
Doctor inspects OAuth profiles in the auth store, warns when tokens are
|
||||||
expiring/expired, and can refresh them when safe. If the Anthropic Claude Code
|
expiring/expired, and can refresh them when safe. If the Anthropic Claude Code
|
||||||
profile is stale, it suggests running `claude setup-token` (or pasting a setup-token).
|
profile is stale, it suggests `claude setup-token` on the gateway host.
|
||||||
Refresh prompts only appear when running interactively (TTY); `--non-interactive`
|
Refresh prompts only appear when running interactively (TTY); `--non-interactive`
|
||||||
skips refresh attempts.
|
skips refresh attempts.
|
||||||
|
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ Example config:
|
|||||||
|
|
||||||
## Defaults
|
## Defaults
|
||||||
|
|
||||||
- Interval: `30m` (set `agents.defaults.heartbeat.every` or per-agent `agents.list[].heartbeat.every`; use `0m` to disable).
|
- Interval: `30m` (set `agents.defaults.heartbeat.every`; use `0m` to disable).
|
||||||
- Prompt body (configurable via `agents.defaults.heartbeat.prompt`):
|
- Prompt body (configurable via `agents.defaults.heartbeat.prompt`):
|
||||||
`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
|
`Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`
|
||||||
- The heartbeat prompt is sent **verbatim** as the user message. The system
|
- The heartbeat prompt is sent **verbatim** as the user message. The system
|
||||||
prompt includes a “Heartbeat” section and the run is flagged internally.
|
prompt includes a “Heartbeat” section and the run is flagged internally.
|
||||||
|
|
||||||
@@ -49,8 +49,8 @@ The default prompt is intentionally broad:
|
|||||||
by using your configured local timezone (see [/concepts/timezone](/concepts/timezone)).
|
by using your configured local timezone (see [/concepts/timezone](/concepts/timezone)).
|
||||||
|
|
||||||
If you want a heartbeat to do something very specific (e.g. “check Gmail PubSub
|
If you want a heartbeat to do something very specific (e.g. “check Gmail PubSub
|
||||||
stats” or “verify gateway health”), set `agents.defaults.heartbeat.prompt` (or
|
stats” or “verify gateway health”), set `agents.defaults.heartbeat.prompt` to a
|
||||||
`agents.list[].heartbeat.prompt`) to a custom body (sent verbatim).
|
custom body (sent verbatim).
|
||||||
|
|
||||||
## Response contract
|
## Response contract
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped.
|
|||||||
includeReasoning: false, // default: false (deliver separate Reasoning: message when available)
|
includeReasoning: false, // default: false (deliver separate Reasoning: message when available)
|
||||||
target: "last", // last | whatsapp | telegram | discord | slack | signal | imessage | none
|
target: "last", // last | whatsapp | telegram | discord | slack | signal | imessage | none
|
||||||
to: "+15551234567", // optional channel-specific override
|
to: "+15551234567", // optional channel-specific override
|
||||||
prompt: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.",
|
prompt: "Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.",
|
||||||
ackMaxChars: 300 // max chars allowed after HEARTBEAT_OK
|
ackMaxChars: 300 // max chars allowed after HEARTBEAT_OK
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,39 +85,6 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Per-agent heartbeats
|
|
||||||
|
|
||||||
If any `agents.list[]` entry includes a `heartbeat` block, **only those agents**
|
|
||||||
run heartbeats. The per-agent block merges on top of `agents.defaults.heartbeat`
|
|
||||||
(so you can set shared defaults once and override per agent).
|
|
||||||
|
|
||||||
Example: two agents, only the second agent runs heartbeats.
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
agents: {
|
|
||||||
defaults: {
|
|
||||||
heartbeat: {
|
|
||||||
every: "30m",
|
|
||||||
target: "last"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
list: [
|
|
||||||
{ id: "main", default: true },
|
|
||||||
{
|
|
||||||
id: "ops",
|
|
||||||
heartbeat: {
|
|
||||||
every: "1h",
|
|
||||||
target: "whatsapp",
|
|
||||||
to: "+15551234567",
|
|
||||||
prompt: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Field notes
|
### Field notes
|
||||||
|
|
||||||
- `every`: heartbeat interval (duration string; default unit = minutes).
|
- `every`: heartbeat interval (duration string; default unit = minutes).
|
||||||
@@ -133,8 +100,7 @@ Example: two agents, only the second agent runs heartbeats.
|
|||||||
|
|
||||||
## Delivery behavior
|
## Delivery behavior
|
||||||
|
|
||||||
- Heartbeats run in each agent’s **main session** (`agent:<id>:<mainKey>`), or `global`
|
- Heartbeats run in the **main session** (`main`, or `global` when scope is global).
|
||||||
when `session.scope = "global"`.
|
|
||||||
- If the main queue is busy, the heartbeat is skipped and retried later.
|
- If the main queue is busy, the heartbeat is skipped and retried later.
|
||||||
- If `target` resolves to no external destination, the run still happens but no
|
- If `target` resolves to no external destination, the run still happens but no
|
||||||
outbound message is sent.
|
outbound message is sent.
|
||||||
@@ -183,9 +149,6 @@ You can enqueue a system event and trigger an immediate heartbeat with:
|
|||||||
clawdbot wake --text "Check for urgent follow-ups" --mode now
|
clawdbot wake --text "Check for urgent follow-ups" --mode now
|
||||||
```
|
```
|
||||||
|
|
||||||
If multiple agents have `heartbeat` configured, a manual wake runs each of those
|
|
||||||
agent heartbeats immediately.
|
|
||||||
|
|
||||||
Use `--mode next-heartbeat` to wait for the next scheduled tick.
|
Use `--mode next-heartbeat` to wait for the next scheduled tick.
|
||||||
|
|
||||||
## Reasoning delivery (optional)
|
## Reasoning delivery (optional)
|
||||||
|
|||||||
@@ -52,22 +52,6 @@ When the audit prints findings, treat this as a priority order:
|
|||||||
5. **Plugins/extensions**: only load what you explicitly trust.
|
5. **Plugins/extensions**: only load what you explicitly trust.
|
||||||
6. **Model choice**: prefer modern, instruction-hardened models for any bot with tools.
|
6. **Model choice**: prefer modern, instruction-hardened models for any bot with tools.
|
||||||
|
|
||||||
## Node execution (system.run)
|
|
||||||
|
|
||||||
If a macOS node is paired, the Gateway can invoke `system.run` on that node. This is **remote code execution** on the Mac:
|
|
||||||
|
|
||||||
- Requires node pairing (approval + token).
|
|
||||||
- Controlled on the Mac via **Settings → "Node Run Commands"**: "Always Ask" (default), "Always Allow", or "Never".
|
|
||||||
- If you don’t want remote execution, set the policy to "Never" and remove node pairing for that Mac.
|
|
||||||
|
|
||||||
## Dynamic skills (watcher / remote nodes)
|
|
||||||
|
|
||||||
Clawdbot can refresh the skills list mid-session:
|
|
||||||
- **Skills watcher**: changes to `SKILL.md` can update the skills snapshot on the next agent turn.
|
|
||||||
- **Remote nodes**: connecting a macOS node can make macOS-only skills eligible (based on bin probing).
|
|
||||||
|
|
||||||
Treat skill folders as **trusted code** and restrict who can modify them.
|
|
||||||
|
|
||||||
## The Threat Model
|
## The Threat Model
|
||||||
|
|
||||||
Your AI assistant can:
|
Your AI assistant can:
|
||||||
@@ -123,18 +107,6 @@ clawdbot pairing approve <channel> <code>
|
|||||||
|
|
||||||
Details + files on disk: [Pairing](/start/pairing)
|
Details + files on disk: [Pairing](/start/pairing)
|
||||||
|
|
||||||
## DM session isolation (multi-user mode)
|
|
||||||
|
|
||||||
By default, Clawdbot routes **all DMs into the main session** so your assistant has continuity across devices and channels. If **multiple people** can DM the bot (open DMs or a multi-person allowlist), consider isolating DM sessions:
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
session: { dmScope: "per-channel-peer" }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This prevents cross-user context leakage while keeping group chats isolated. See [Session Management](/concepts/session) and [Configuration](/gateway/configuration).
|
|
||||||
|
|
||||||
## Allowlists (DM + groups) — terminology
|
## Allowlists (DM + groups) — terminology
|
||||||
|
|
||||||
Clawdbot has two separate “who can trigger me?” layers:
|
Clawdbot has two separate “who can trigger me?” layers:
|
||||||
|
|||||||
@@ -163,7 +163,6 @@ Notes:
|
|||||||
- `system.notify` respects notification permission state on the macOS app.
|
- `system.notify` respects notification permission state on the macOS app.
|
||||||
- `system.run` supports `--cwd`, `--env KEY=VAL`, `--command-timeout`, and `--needs-screen-recording`.
|
- `system.run` supports `--cwd`, `--env KEY=VAL`, `--command-timeout`, and `--needs-screen-recording`.
|
||||||
- `system.notify` supports `--priority <passive|active|timeSensitive>` and `--delivery <system|overlay|auto>`.
|
- `system.notify` supports `--priority <passive|active|timeSensitive>` and `--delivery <system|overlay|auto>`.
|
||||||
- `system.run` is gated by the macOS app policy (Settings → "Node Run Commands"): "Always Ask" prompts per command, "Always Allow" runs without prompts, and "Never" disables the tool. Denied prompts return `SYSTEM_RUN_DENIED`; disabled returns `SYSTEM_RUN_DISABLED`.
|
|
||||||
|
|
||||||
## Permissions map
|
## Permissions map
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ agent (with a session switcher for other sessions).
|
|||||||
|
|
||||||
## How it’s wired
|
## How it’s wired
|
||||||
|
|
||||||
- Data plane: Gateway WS methods `chat.history`, `chat.send`, `chat.abort`,
|
- Data plane: Gateway WS methods `chat.history`, `chat.send`, `chat.abort` and
|
||||||
`chat.inject` and events `chat`, `agent`, `presence`, `tick`, `health`.
|
events `chat`, `agent`, `presence`, `tick`, `health`.
|
||||||
- Session: defaults to the primary session (`main`, or `global` when scope is
|
- Session: defaults to the primary session (`main`, or `global` when scope is
|
||||||
global). The UI can switch between sessions.
|
global). The UI can switch between sessions.
|
||||||
- Onboarding uses a dedicated session to keep first‑run setup separate.
|
- Onboarding uses a dedicated session to keep first‑run setup separate.
|
||||||
|
|||||||
@@ -54,38 +54,6 @@ The macOS app presents itself as a node. Common commands:
|
|||||||
|
|
||||||
The node reports a `permissions` map so agents can decide what’s allowed.
|
The node reports a `permissions` map so agents can decide what’s allowed.
|
||||||
|
|
||||||
## Node run policy + allowlist
|
|
||||||
|
|
||||||
`system.run` is controlled by the macOS app **Node Run Commands** policy:
|
|
||||||
|
|
||||||
- `Always Ask`: prompt per command (default).
|
|
||||||
- `Always Allow`: run without prompts.
|
|
||||||
- `Never`: disable `system.run` (tool not advertised).
|
|
||||||
|
|
||||||
The policy + allowlist live on the Mac in:
|
|
||||||
|
|
||||||
```
|
|
||||||
~/.clawdbot/macos-node.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Schema:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"systemRun": {
|
|
||||||
"policy": "ask",
|
|
||||||
"allowlist": [
|
|
||||||
"[\"/bin/echo\",\"hello\"]"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- `allowlist` entries are JSON-encoded argv arrays.
|
|
||||||
- Choosing “Always Allow” in the prompt adds that command to the allowlist.
|
|
||||||
- `system.run` environment overrides are filtered (drops `PATH`, `DYLD_*`, `LD_*`, `NODE_OPTIONS`, `PYTHON*`, `PERL*`, `RUBYOPT`) and then merged with the app’s environment.
|
|
||||||
|
|
||||||
## Deep links
|
## Deep links
|
||||||
|
|
||||||
The app registers the `clawdbot://` URL scheme for local actions.
|
The app registers the `clawdbot://` URL scheme for local actions.
|
||||||
@@ -142,32 +110,6 @@ Tip: compare against `pnpm clawdbot gateway discover --json` to see whether the
|
|||||||
macOS app’s discovery pipeline (NWBrowser + tailnet DNS‑SD fallback) differs from
|
macOS app’s discovery pipeline (NWBrowser + tailnet DNS‑SD fallback) differs from
|
||||||
the Node CLI’s `dns-sd` based discovery.
|
the Node CLI’s `dns-sd` based discovery.
|
||||||
|
|
||||||
## Remote connection plumbing (SSH tunnels)
|
|
||||||
|
|
||||||
When the macOS app runs in **Remote** mode, it opens SSH tunnels so local UI
|
|
||||||
components can talk to a remote Gateway as if it were on localhost. There are
|
|
||||||
two independent tunnels:
|
|
||||||
|
|
||||||
### Control tunnel (Gateway control/WebSocket port)
|
|
||||||
- **Purpose:** health checks, status, Web Chat, config, and other control-plane calls.
|
|
||||||
- **Local port:** the Gateway port (default `18789`), always stable.
|
|
||||||
- **Remote port:** the same Gateway port on the remote host.
|
|
||||||
- **Behavior:** no random local port; the app reuses an existing healthy tunnel
|
|
||||||
or restarts it if needed.
|
|
||||||
- **SSH shape:** `ssh -N -L <local>:127.0.0.1:<remote>` with BatchMode +
|
|
||||||
ExitOnForwardFailure + keepalive options.
|
|
||||||
|
|
||||||
### Node bridge tunnel (macOS node mode)
|
|
||||||
- **Purpose:** connect the macOS node to the Gateway **Bridge** protocol (TCP JSONL).
|
|
||||||
- **Remote port:** `gatewayPort + 1` (default `18790`), derived from the Gateway port.
|
|
||||||
- **Local port preference:** `CLAWDBOT_BRIDGE_PORT` or the default `18790`.
|
|
||||||
- **Behavior:** prefer the default bridge port for consistency; fall back to a
|
|
||||||
random local port if the preferred one is busy. The node then connects to the
|
|
||||||
resolved local port.
|
|
||||||
|
|
||||||
For setup steps, see [macOS remote access](/platforms/mac/remote). For protocol
|
|
||||||
details, see [Bridge protocol](/gateway/bridge-protocol).
|
|
||||||
|
|
||||||
## Related docs
|
## Related docs
|
||||||
|
|
||||||
- [Gateway runbook](/gateway)
|
- [Gateway runbook](/gateway)
|
||||||
|
|||||||
@@ -35,11 +35,9 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
|
|||||||
|
|
||||||
## Available plugins (official)
|
## Available plugins (official)
|
||||||
|
|
||||||
- Microsoft Teams is plugin-only as of 2026.1.15; install `@clawdbot/msteams` if you use Teams.
|
|
||||||
- [Voice Call](/plugins/voice-call) — `@clawdbot/voice-call`
|
- [Voice Call](/plugins/voice-call) — `@clawdbot/voice-call`
|
||||||
- [Matrix](/channels/matrix) — `@clawdbot/matrix`
|
- [Matrix](/channels/matrix) — `@clawdbot/matrix`
|
||||||
- [Zalo](/channels/zalo) — `@clawdbot/zalo`
|
- [Zalo](/channels/zalo) — `@clawdbot/zalo`
|
||||||
- [Microsoft Teams](/channels/msteams) — `@clawdbot/msteams`
|
|
||||||
|
|
||||||
Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can
|
Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can
|
||||||
register:
|
register:
|
||||||
@@ -174,56 +172,6 @@ Plugins export either:
|
|||||||
- A function: `(api) => { ... }`
|
- A function: `(api) => { ... }`
|
||||||
- An object: `{ id, name, configSchema, register(api) { ... } }`
|
- An object: `{ id, name, configSchema, register(api) { ... } }`
|
||||||
|
|
||||||
## Provider plugins (model auth)
|
|
||||||
|
|
||||||
Plugins can register **model provider auth** flows so users can run OAuth or
|
|
||||||
API-key setup inside Clawdbot (no external scripts needed).
|
|
||||||
|
|
||||||
Register a provider via `api.registerProvider(...)`. Each provider exposes one
|
|
||||||
or more auth methods (OAuth, API key, device code, etc.). These methods power:
|
|
||||||
|
|
||||||
- `clawdbot models auth login --provider <id> [--method <id>]`
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
api.registerProvider({
|
|
||||||
id: "acme",
|
|
||||||
label: "AcmeAI",
|
|
||||||
auth: [
|
|
||||||
{
|
|
||||||
id: "oauth",
|
|
||||||
label: "OAuth",
|
|
||||||
kind: "oauth",
|
|
||||||
run: async (ctx) => {
|
|
||||||
// Run OAuth flow and return auth profiles.
|
|
||||||
return {
|
|
||||||
profiles: [
|
|
||||||
{
|
|
||||||
profileId: "acme:default",
|
|
||||||
credential: {
|
|
||||||
type: "oauth",
|
|
||||||
provider: "acme",
|
|
||||||
access: "...",
|
|
||||||
refresh: "...",
|
|
||||||
expires: Date.now() + 3600 * 1000,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
defaultModel: "acme/opus-1",
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- `run` receives a `ProviderAuthContext` with `prompter`, `runtime`,
|
|
||||||
`openUrl`, and `oauth.createVpsAwareHandlers` helpers.
|
|
||||||
- Return `configPatch` when you need to add default models or provider config.
|
|
||||||
- Return `defaultModel` so `--set-default` can update agent defaults.
|
|
||||||
|
|
||||||
### Register a messaging channel
|
### Register a messaging channel
|
||||||
|
|
||||||
Plugins can register **channel plugins** that behave like built‑in channels
|
Plugins can register **channel plugins** that behave like built‑in channels
|
||||||
|
|||||||
@@ -38,32 +38,10 @@ clawdbot onboard --anthropic-api-key "$ANTHROPIC_API_KEY"
|
|||||||
|
|
||||||
**Best for:** using your Claude subscription or existing Claude Code CLI login.
|
**Best for:** using your Claude subscription or existing Claude Code CLI login.
|
||||||
|
|
||||||
### Where to get a setup-token
|
|
||||||
|
|
||||||
Setup-tokens are created by the **Claude Code CLI**, not the Anthropic Console. You can run this on **any machine**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
claude setup-token
|
|
||||||
```
|
|
||||||
|
|
||||||
Paste the token into Clawdbot (wizard: **Anthropic token (paste setup-token)**), or let Clawdbot run the command locally:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot onboard --auth-choice setup-token
|
|
||||||
# or
|
|
||||||
clawdbot models auth setup-token --provider anthropic
|
|
||||||
```
|
|
||||||
|
|
||||||
If you generated the token on a different machine, paste it:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot models auth paste-token --provider anthropic
|
|
||||||
```
|
|
||||||
|
|
||||||
### CLI setup
|
### CLI setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run setup-token locally (wizard can run it for you)
|
# Run setup-token on the gateway host (wizard can run it for you)
|
||||||
clawdbot onboard --auth-choice setup-token
|
clawdbot onboard --auth-choice setup-token
|
||||||
|
|
||||||
# Reuse Claude Code CLI OAuth credentials if already logged in
|
# Reuse Claude Code CLI OAuth credentials if already logged in
|
||||||
@@ -80,7 +58,7 @@ clawdbot onboard --auth-choice claude-cli
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- The wizard can run `claude setup-token` locally and store the token, or you can paste a token generated elsewhere.
|
- The wizard can run `claude setup-token` on the gateway host and store the token.
|
||||||
- Clawdbot writes `auth.profiles["anthropic:claude-cli"].mode` as `"oauth"` so the profile
|
- Clawdbot writes `auth.profiles["anthropic:claude-cli"].mode` as `"oauth"` so the profile
|
||||||
accepts both OAuth and setup-token credentials. Older configs using `"token"` are
|
accepts both OAuth and setup-token credentials. Older configs using `"token"` are
|
||||||
auto-migrated on load.
|
auto-migrated on load.
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tag
|
|||||||
|
|
||||||
1) **Version & metadata**
|
1) **Version & metadata**
|
||||||
- [ ] Bump `package.json` version (e.g., `1.1.0`).
|
- [ ] Bump `package.json` version (e.g., `1.1.0`).
|
||||||
- [ ] Run `pnpm plugins:sync` to align extension package versions + changelogs.
|
|
||||||
- [ ] Update CLI/version strings: [`src/cli/program.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/cli/program.ts) and the Baileys user agent in [`src/provider-web.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/provider-web.ts).
|
- [ ] Update CLI/version strings: [`src/cli/program.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/cli/program.ts) and the Baileys user agent in [`src/provider-web.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/provider-web.ts).
|
||||||
- [ ] Confirm package metadata (name, description, repository, keywords, license) and `bin` map points to [`dist/entry.js`](https://github.com/clawdbot/clawdbot/blob/main/dist/entry.js) for `clawdbot`.
|
- [ ] Confirm package metadata (name, description, repository, keywords, license) and `bin` map points to [`dist/entry.js`](https://github.com/clawdbot/clawdbot/blob/main/dist/entry.js) for `clawdbot`.
|
||||||
- [ ] If dependencies changed, run `pnpm install` so `pnpm-lock.yaml` is current.
|
- [ ] If dependencies changed, run `pnpm install` so `pnpm-lock.yaml` is current.
|
||||||
@@ -64,7 +63,7 @@ Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tag
|
|||||||
|
|
||||||
7) **GitHub release + appcast**
|
7) **GitHub release + appcast**
|
||||||
- [ ] Tag and push: `git tag vX.Y.Z && git push origin vX.Y.Z` (or `git push --tags`).
|
- [ ] Tag and push: `git tag vX.Y.Z && git push origin vX.Y.Z` (or `git push --tags`).
|
||||||
- [ ] Create/refresh the GitHub release for `vX.Y.Z` with **title `clawdbot X.Y.Z`** (not just the tag); body should include the **full** changelog section for that version (Highlights + Changes + Fixes), inline (no bare links), and **must not repeat the title inside the body**.
|
- [ ] Create/refresh the GitHub release for `vX.Y.Z` with **title `clawdbot X.Y.Z`** (not just the tag); body should inline the product-facing bullets from the changelog (no bare links) **and must not repeat the title inside the body**.
|
||||||
- [ ] Attach artifacts: `npm pack` tarball (optional), `Clawdbot-X.Y.Z.zip`, and `Clawdbot-X.Y.Z.dSYM.zip` (if generated).
|
- [ ] Attach artifacts: `npm pack` tarball (optional), `Clawdbot-X.Y.Z.zip`, and `Clawdbot-X.Y.Z.dSYM.zip` (if generated).
|
||||||
- [ ] Commit the updated `appcast.xml` and push it (Sparkle feeds from main).
|
- [ ] Commit the updated `appcast.xml` and push it (Sparkle feeds from main).
|
||||||
- [ ] From a clean temp directory (no `package.json`), run `npx -y clawdbot@X.Y.Z send --help` to confirm install/CLI entrypoints work.
|
- [ ] From a clean temp directory (no `package.json`), run `npx -y clawdbot@X.Y.Z send --help` to confirm install/CLI entrypoints work.
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ Skills provide your tools. When you need one, check its `SKILL.md`. Keep local n
|
|||||||
When you receive a heartbeat poll (message matches the configured heartbeat prompt), don't just reply `HEARTBEAT_OK` every time. Use heartbeats productively!
|
When you receive a heartbeat poll (message matches the configured heartbeat prompt), don't just reply `HEARTBEAT_OK` every time. Use heartbeats productively!
|
||||||
|
|
||||||
Default heartbeat prompt:
|
Default heartbeat prompt:
|
||||||
`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
|
`Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`
|
||||||
|
|
||||||
You are free to edit `HEARTBEAT.md` with a short checklist or reminders. Keep it small to limit token burn.
|
You are free to edit `HEARTBEAT.md` with a short checklist or reminders. Keep it small to limit token burn.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -175,7 +175,7 @@ Example:
|
|||||||
## Heartbeats (proactive mode)
|
## Heartbeats (proactive mode)
|
||||||
|
|
||||||
By default, Clawdbot runs a heartbeat every 30 minutes with the prompt:
|
By default, Clawdbot runs a heartbeat every 30 minutes with the prompt:
|
||||||
`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
|
`Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`
|
||||||
Set `agents.defaults.heartbeat.every: "0m"` to disable.
|
Set `agents.defaults.heartbeat.every: "0m"` to disable.
|
||||||
|
|
||||||
- If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agents.defaults.heartbeat.ackMaxChars`), Clawdbot suppresses outbound delivery for that heartbeat.
|
- If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agents.defaults.heartbeat.ackMaxChars`), Clawdbot suppresses outbound delivery for that heartbeat.
|
||||||
|
|||||||
+2
-58
@@ -16,7 +16,6 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
|||||||
- [What runtime do I need?](#what-runtime-do-i-need)
|
- [What runtime do I need?](#what-runtime-do-i-need)
|
||||||
- [What does the onboarding wizard actually do?](#what-does-the-onboarding-wizard-actually-do)
|
- [What does the onboarding wizard actually do?](#what-does-the-onboarding-wizard-actually-do)
|
||||||
- [How does Anthropic "setup-token" auth work?](#how-does-anthropic-setup-token-auth-work)
|
- [How does Anthropic "setup-token" auth work?](#how-does-anthropic-setup-token-auth-work)
|
||||||
- [Where do I find an Anthropic setup-token?](#where-do-i-find-an-anthropic-setup-token)
|
|
||||||
- [Do you support Claude subscription auth (Claude Code OAuth)?](#do-you-support-claude-subscription-auth-claude-code-oauth)
|
- [Do you support Claude subscription auth (Claude Code OAuth)?](#do-you-support-claude-subscription-auth-claude-code-oauth)
|
||||||
- [Is AWS Bedrock supported?](#is-aws-bedrock-supported)
|
- [Is AWS Bedrock supported?](#is-aws-bedrock-supported)
|
||||||
- [How does Codex auth work?](#how-does-codex-auth-work)
|
- [How does Codex auth work?](#how-does-codex-auth-work)
|
||||||
@@ -34,7 +33,6 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
|||||||
- [Can I load skills from a custom folder?](#can-i-load-skills-from-a-custom-folder)
|
- [Can I load skills from a custom folder?](#can-i-load-skills-from-a-custom-folder)
|
||||||
- [How can I use different models for different tasks?](#how-can-i-use-different-models-for-different-tasks)
|
- [How can I use different models for different tasks?](#how-can-i-use-different-models-for-different-tasks)
|
||||||
- [How do I install skills on Linux?](#how-do-i-install-skills-on-linux)
|
- [How do I install skills on Linux?](#how-do-i-install-skills-on-linux)
|
||||||
- [Can I run Apple/macOS-only skills from Linux?](#can-i-run-applemacos-only-skills-from-linux)
|
|
||||||
- [Do you have a Notion or HeyGen integration?](#do-you-have-a-notion-or-heygen-integration)
|
- [Do you have a Notion or HeyGen integration?](#do-you-have-a-notion-or-heygen-integration)
|
||||||
- [How do I install the Chrome extension for browser takeover?](#how-do-i-install-the-chrome-extension-for-browser-takeover)
|
- [How do I install the Chrome extension for browser takeover?](#how-do-i-install-the-chrome-extension-for-browser-takeover)
|
||||||
- [Sandboxing and memory](#sandboxing-and-memory)
|
- [Sandboxing and memory](#sandboxing-and-memory)
|
||||||
@@ -56,7 +54,6 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
|||||||
- [How do I enable web search (and web fetch)?](#how-do-i-enable-web-search-and-web-fetch)
|
- [How do I enable web search (and web fetch)?](#how-do-i-enable-web-search-and-web-fetch)
|
||||||
- [How do I run a central Gateway with specialized workers across devices?](#how-do-i-run-a-central-gateway-with-specialized-workers-across-devices)
|
- [How do I run a central Gateway with specialized workers across devices?](#how-do-i-run-a-central-gateway-with-specialized-workers-across-devices)
|
||||||
- [Can the Clawdbot browser run headless?](#can-the-clawdbot-browser-run-headless)
|
- [Can the Clawdbot browser run headless?](#can-the-clawdbot-browser-run-headless)
|
||||||
- [How do I use Brave for browser control?](#how-do-i-use-brave-for-browser-control)
|
|
||||||
- [Remote gateways + nodes](#remote-gateways-nodes)
|
- [Remote gateways + nodes](#remote-gateways-nodes)
|
||||||
- [How do commands propagate between Telegram, the gateway, and nodes?](#how-do-commands-propagate-between-telegram-the-gateway-and-nodes)
|
- [How do commands propagate between Telegram, the gateway, and nodes?](#how-do-commands-propagate-between-telegram-the-gateway-and-nodes)
|
||||||
- [Do nodes run a gateway daemon?](#do-nodes-run-a-gateway-daemon)
|
- [Do nodes run a gateway daemon?](#do-nodes-run-a-gateway-daemon)
|
||||||
@@ -242,25 +239,15 @@ It also warns if your configured model is unknown or missing auth.
|
|||||||
|
|
||||||
### How does Anthropic "setup-token" auth work?
|
### How does Anthropic "setup-token" auth work?
|
||||||
|
|
||||||
`claude setup-token` generates a **token string** via the Claude Code CLI (it is not available in the web console). You can run it on **any machine**. If you run it on the gateway host, the wizard can auto-detect the CLI credentials. If you run it elsewhere, choose **Anthropic token (paste setup-token)** and paste the string. The token is stored as an auth profile for the **anthropic** provider and used like an API key or OAuth profile. More detail: [OAuth](/concepts/oauth).
|
The wizard can run `claude setup-token` on the gateway host (or you run it yourself), then stores the token as an auth profile for the **anthropic** provider. That profile is used for model calls the same way an API key or OAuth profile would be. If you already ran `claude setup-token`, pick **Anthropic token (paste setup-token)** and paste it. More detail: [OAuth](/concepts/oauth).
|
||||||
|
|
||||||
Clawdbot keeps `auth.profiles["anthropic:claude-cli"].mode` set to `"oauth"` so
|
Clawdbot keeps `auth.profiles["anthropic:claude-cli"].mode` set to `"oauth"` so
|
||||||
the profile accepts both OAuth and setup-token credentials; older `"token"` mode
|
the profile accepts both OAuth and setup-token credentials; older `"token"` mode
|
||||||
entries auto-migrate.
|
entries auto-migrate.
|
||||||
|
|
||||||
### Where do I find an Anthropic setup-token?
|
|
||||||
|
|
||||||
It is **not** in the Anthropic Console. The setup-token is generated by the **Claude Code CLI** on **any machine**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
claude setup-token
|
|
||||||
```
|
|
||||||
|
|
||||||
Copy the token it prints, then choose **Anthropic token (paste setup-token)** in the wizard. If you want Clawdbot to run the command for you, use `clawdbot onboard --auth-choice setup-token` or `clawdbot models auth setup-token --provider anthropic`. If you ran `claude setup-token` elsewhere, paste it on the gateway host with `clawdbot models auth paste-token --provider anthropic`. See [Anthropic](/providers/anthropic).
|
|
||||||
|
|
||||||
### Do you support Claude subscription auth (Claude Code OAuth)?
|
### Do you support Claude subscription auth (Claude Code OAuth)?
|
||||||
|
|
||||||
Yes. Clawdbot can **reuse Claude Code CLI credentials** (OAuth) and also supports **setup-token**. If you have a Claude subscription, we recommend **setup-token** for long‑running setups (requires Claude Pro/Max + the `claude` CLI). You can generate it anywhere and paste it on the gateway host, or run it locally on the gateway so it auto-syncs. OAuth reuse is supported, but avoid logging in separately via Clawdbot and Claude Code to prevent token conflicts. See [Anthropic](/providers/anthropic) and [OAuth](/concepts/oauth).
|
Yes. Clawdbot can **reuse Claude Code CLI credentials** (OAuth) and also supports **setup-token**. If you have a Claude subscription, we recommend **setup-token** on the gateway host for the most reliable long‑running setup (requires Claude Pro/Max + the `claude` CLI). OAuth reuse is supported, but avoid logging in separately via Clawdbot and Claude Code to prevent token conflicts. See [Anthropic](/providers/anthropic) and [OAuth](/concepts/oauth).
|
||||||
|
|
||||||
Note: Claude subscription access is governed by Anthropic’s terms. For production or multi‑user workloads, API keys are usually the safer choice.
|
Note: Claude subscription access is governed by Anthropic’s terms. For production or multi‑user workloads, API keys are usually the safer choice.
|
||||||
|
|
||||||
@@ -400,40 +387,6 @@ npm i -g clawdhub
|
|||||||
pnpm add -g clawdhub
|
pnpm add -g clawdhub
|
||||||
```
|
```
|
||||||
|
|
||||||
### Is there a way to run Apple/macOS-only skills if my Gateway runs on Linux?
|
|
||||||
|
|
||||||
Not directly. macOS skills are gated by `metadata.clawdbot.os` plus required binaries, and skills only appear in the system prompt when they are eligible on the **Gateway host**. On Linux, `darwin`-only skills (like `imsg`, `apple-notes`, `apple-reminders`) will not load unless you override the gating.
|
|
||||||
|
|
||||||
You have three supported patterns:
|
|
||||||
|
|
||||||
**Option A - run the Gateway on a Mac (simplest).**
|
|
||||||
Run the Gateway where the macOS binaries exist, then connect from Linux in [remote mode](#how-do-i-run-clawdbot-in-remote-mode-client-connects-to-a-gateway-elsewhere) or over Tailscale. The skills load normally because the Gateway host is macOS.
|
|
||||||
|
|
||||||
**Option B - use a macOS node (no SSH).**
|
|
||||||
Run the Gateway on Linux, pair a macOS node (menubar app), and set **Node Run Commands** to "Always Ask" or "Always Allow" on the Mac. Clawdbot can treat macOS-only skills as eligible when the required binaries exist on the node. The agent runs those skills via the `nodes` tool. If you choose "Always Ask", approving "Always Allow" in the prompt adds that command to the allowlist.
|
|
||||||
|
|
||||||
**Option C - proxy macOS binaries over SSH (advanced).**
|
|
||||||
Keep the Gateway on Linux, but make the required CLI binaries resolve to SSH wrappers that run on a Mac. Then override the skill to allow Linux so it stays eligible.
|
|
||||||
|
|
||||||
1) Create an SSH wrapper for the binary (example: `imsg`):
|
|
||||||
```bash
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
exec ssh -T user@mac-host /opt/homebrew/bin/imsg "$@"
|
|
||||||
```
|
|
||||||
2) Put the wrapper on `PATH` on the Linux host (for example `~/bin/imsg`).
|
|
||||||
3) Override the skill metadata (workspace or `~/.clawdbot/skills`) to allow Linux:
|
|
||||||
```markdown
|
|
||||||
---
|
|
||||||
name: imsg
|
|
||||||
description: iMessage/SMS CLI for listing chats, history, watch, and sending.
|
|
||||||
metadata: {"clawdbot":{"os":["darwin","linux"],"requires":{"bins":["imsg"]}}}
|
|
||||||
---
|
|
||||||
```
|
|
||||||
4) Start a new session so the skills snapshot refreshes.
|
|
||||||
|
|
||||||
For iMessage specifically, you can also point `channels.imessage.cliPath` at an SSH wrapper (Clawdbot only needs stdio). See [iMessage](/channels/imessage).
|
|
||||||
|
|
||||||
### Do you have a Notion or HeyGen integration?
|
### Do you have a Notion or HeyGen integration?
|
||||||
|
|
||||||
Not built‑in today.
|
Not built‑in today.
|
||||||
@@ -699,11 +652,6 @@ Headless uses the **same Chromium engine** and works for most automation (forms,
|
|||||||
- Some sites are stricter about automation in headless mode (CAPTCHAs, anti‑bot).
|
- Some sites are stricter about automation in headless mode (CAPTCHAs, anti‑bot).
|
||||||
For example, X/Twitter often blocks headless sessions.
|
For example, X/Twitter often blocks headless sessions.
|
||||||
|
|
||||||
### How do I use Brave for browser control?
|
|
||||||
|
|
||||||
Set `browser.executablePath` to your Brave binary (or any Chromium-based browser) and restart the Gateway.
|
|
||||||
See the full config examples in [Browser](/tools/browser#use-brave-or-another-chromium-based-browser).
|
|
||||||
|
|
||||||
## Remote gateways + nodes
|
## Remote gateways + nodes
|
||||||
|
|
||||||
### How do commands propagate between Telegram, the gateway, and nodes?
|
### How do commands propagate between Telegram, the gateway, and nodes?
|
||||||
@@ -1164,10 +1112,6 @@ can’t find that profile in its auth store.
|
|||||||
- **Sync the Claude Code CLI token on the gateway host**
|
- **Sync the Claude Code CLI token on the gateway host**
|
||||||
- Run `clawdbot models status` (it loads + syncs Claude Code CLI credentials).
|
- Run `clawdbot models status` (it loads + syncs Claude Code CLI credentials).
|
||||||
- If it still says missing: run `claude setup-token` (or `clawdbot models auth setup-token --provider anthropic`) and retry.
|
- If it still says missing: run `claude setup-token` (or `clawdbot models auth setup-token --provider anthropic`) and retry.
|
||||||
- **If the token was created on another machine**
|
|
||||||
- Paste it into the gateway host with `clawdbot models auth paste-token --provider anthropic`.
|
|
||||||
- **Check the profile mode**
|
|
||||||
- `auth.profiles["anthropic:claude-cli"].mode` must be `"oauth"` (token mode rejects OAuth credentials).
|
|
||||||
- **If you want to use an API key instead**
|
- **If you want to use an API key instead**
|
||||||
- Put `ANTHROPIC_API_KEY` in `~/.clawdbot/.env` on the **gateway host**.
|
- Put `ANTHROPIC_API_KEY` in `~/.clawdbot/.env` on the **gateway host**.
|
||||||
- Clear any pinned order that forces `anthropic:claude-cli`:
|
- Clear any pinned order that forces `anthropic:claude-cli`:
|
||||||
|
|||||||
@@ -141,10 +141,10 @@ Full setup walkthrough (28m) by VelvetShark.
|
|||||||
|
|
||||||
<img src="/assets/showcase/oura-health.png" alt="Oura ring health assistant" />
|
<img src="/assets/showcase/oura-health.png" alt="Oura ring health assistant" />
|
||||||
</Card>
|
</Card>
|
||||||
<Card title="Kev's Dream Team (14+ Agents)" icon="robot" href="https://github.com/adam91holt/orchestrated-ai-articles">
|
<Card title="Multi-Agent Swarm (14+ Agents)" icon="robot" href="https://github.com/adam91holt/clawdspace">
|
||||||
**@adam91holt** • `multi-agent` `orchestration` `architecture` `manifesto`
|
**@adam91holt** • `multi-agent` `slack` `orchestration` `swarm`
|
||||||
|
|
||||||
14+ agents under one gateway with Opus 4.5 orchestrator delegating to Codex workers. Comprehensive [technical write-up](https://github.com/adam91holt/orchestrated-ai-articles) covering the Dream Team roster, model selection, sandboxing, webhooks, heartbeats, and delegation flows. [Clawdspace](https://github.com/adam91holt/clawdspace) for agent sandboxing. [Blog post](https://adams-ai-journey.ghost.io/2026-the-year-of-the-orchestrator/).
|
14+ Clawdbot agents under one gateway. Opus 4.5 orchestrator delegates to Codex workers. Self-maintaining agents that continuously improve. Open-sourced clawdspace for agent sandboxing.
|
||||||
</Card>
|
</Card>
|
||||||
</CardGroup>
|
</CardGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
|
|||||||
|
|
||||||
2) **Model/Auth**
|
2) **Model/Auth**
|
||||||
- **Anthropic API key (recommended)**: uses `ANTHROPIC_API_KEY` if present or prompts for a key, then saves it for daemon use.
|
- **Anthropic API key (recommended)**: uses `ANTHROPIC_API_KEY` if present or prompts for a key, then saves it for daemon use.
|
||||||
- **Anthropic token (setup-token)**: run `claude setup-token` locally (the wizard can run it for you and reuse the token) or run it elsewhere and paste the token.
|
- **Anthropic token (setup-token)**: run `claude setup-token` on the gateway host (the wizard can run it for you and reuse the token).
|
||||||
- **Anthropic OAuth (Claude Code CLI)**: on macOS the wizard checks Keychain item "Claude Code-credentials" (choose "Always Allow" so launchd starts don't block); on Linux/Windows it reuses `~/.claude/.credentials.json` if present.
|
- **Anthropic OAuth (Claude Code CLI)**: on macOS the wizard checks Keychain item "Claude Code-credentials" (choose "Always Allow" so launchd starts don't block); on Linux/Windows it reuses `~/.claude/.credentials.json` if present.
|
||||||
- **Anthropic token (paste setup-token)**: run `claude setup-token` in your terminal, then paste the token (you can name it; blank = default).
|
- **Anthropic token (paste setup-token)**: run `claude setup-token` in your terminal, then paste the token (you can name it; blank = default).
|
||||||
- **OpenAI Code (Codex) subscription (Codex CLI)**: if `~/.codex/auth.json` exists, the wizard can reuse it.
|
- **OpenAI Code (Codex) subscription (Codex CLI)**: if `~/.codex/auth.json` exists, the wizard can reuse it.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
summary: "Fix Chrome/Brave/Edge/Chromium CDP startup issues for Clawdbot browser control on Linux"
|
summary: "Fix Chrome/Chromium CDP startup issues for Clawdbot browser control on Linux"
|
||||||
read_when: "Browser control fails on Linux, especially with snap Chromium"
|
read_when: "Browser control fails on Linux, especially with snap Chromium"
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ read_when: "Browser control fails on Linux, especially with snap Chromium"
|
|||||||
|
|
||||||
## Problem: "Failed to start Chrome CDP on port 18800"
|
## Problem: "Failed to start Chrome CDP on port 18800"
|
||||||
|
|
||||||
Clawdbot's browser control server fails to launch Chrome/Brave/Edge/Chromium with the error:
|
Clawdbot's browser control server fails to launch Chrome/Chromium with the error:
|
||||||
```
|
```
|
||||||
{"error":"Error: Failed to start Chrome CDP on port 18800 for profile \"clawd\"."}
|
{"error":"Error: Failed to start Chrome CDP on port 18800 for profile \"clawd\"."}
|
||||||
```
|
```
|
||||||
@@ -107,7 +107,7 @@ curl -s http://127.0.0.1:18791/tabs
|
|||||||
| Option | Description | Default |
|
| Option | Description | Default |
|
||||||
|--------|-------------|---------|
|
|--------|-------------|---------|
|
||||||
| `browser.enabled` | Enable browser control | `true` |
|
| `browser.enabled` | Enable browser control | `true` |
|
||||||
| `browser.executablePath` | Path to a Chromium-based browser binary (Chrome/Brave/Edge/Chromium) | auto-detected (prefers default browser when Chromium-based) |
|
| `browser.executablePath` | Path to Chrome/Chromium binary | auto-detected |
|
||||||
| `browser.headless` | Run without GUI | `false` |
|
| `browser.headless` | Run without GUI | `false` |
|
||||||
| `browser.noSandbox` | Add `--no-sandbox` flag (needed for some Linux setups) | `false` |
|
| `browser.noSandbox` | Add `--no-sandbox` flag (needed for some Linux setups) | `false` |
|
||||||
| `browser.attachOnly` | Don't launch browser, only attach to existing | `false` |
|
| `browser.attachOnly` | Don't launch browser, only attach to existing | `false` |
|
||||||
|
|||||||
+17
-47
@@ -8,13 +8,13 @@ read_when:
|
|||||||
|
|
||||||
# Browser (clawd-managed)
|
# Browser (clawd-managed)
|
||||||
|
|
||||||
Clawdbot can run a **dedicated Chrome/Brave/Edge/Chromium profile** that the agent controls.
|
Clawdbot can run a **dedicated Chrome/Chromium profile** that the agent controls.
|
||||||
It is isolated from your personal browser and is managed through a small local
|
It is isolated from your personal browser and is managed through a small local
|
||||||
control server.
|
control server.
|
||||||
|
|
||||||
Beginner view:
|
Beginner view:
|
||||||
- Think of it as a **separate, agent-only browser**.
|
- Think of it as a **separate, agent-only browser**.
|
||||||
- It does **not** touch your personal browser profile.
|
- It does **not** touch your personal Chrome profile.
|
||||||
- The agent can **open tabs, read pages, click, and type** in a safe lane.
|
- The agent can **open tabs, read pages, click, and type** in a safe lane.
|
||||||
|
|
||||||
## What you get
|
## What you get
|
||||||
@@ -54,7 +54,7 @@ Browser settings live in `~/.clawdbot/clawdbot.json`.
|
|||||||
headless: false,
|
headless: false,
|
||||||
noSandbox: false,
|
noSandbox: false,
|
||||||
attachOnly: false,
|
attachOnly: false,
|
||||||
executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
executablePath: "/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||||
profiles: {
|
profiles: {
|
||||||
clawd: { cdpPort: 18800, color: "#FF4500" },
|
clawd: { cdpPort: 18800, color: "#FF4500" },
|
||||||
work: { cdpPort: 18801, color: "#0066CC" },
|
work: { cdpPort: 18801, color: "#0066CC" },
|
||||||
@@ -69,45 +69,17 @@ Notes:
|
|||||||
- If you override the Gateway port (`gateway.port` or `CLAWDBOT_GATEWAY_PORT`),
|
- If you override the Gateway port (`gateway.port` or `CLAWDBOT_GATEWAY_PORT`),
|
||||||
the default browser ports shift to stay in the same “family” (control = gateway + 2).
|
the default browser ports shift to stay in the same “family” (control = gateway + 2).
|
||||||
- `cdpUrl` defaults to `controlUrl + 1` when unset.
|
- `cdpUrl` defaults to `controlUrl + 1` when unset.
|
||||||
- `attachOnly: true` means “never launch a local browser; only attach if it is already running.”
|
- `attachOnly: true` means “never launch Chrome; only attach if it is already running.”
|
||||||
- `color` + per-profile `color` tint the browser UI so you can see which profile is active.
|
- `color` + per-profile `color` tint the browser UI so you can see which profile is active.
|
||||||
- Auto-detect order: default browser if Chromium-based; otherwise Chrome → Brave → Edge → Chromium → Chrome Canary.
|
|
||||||
|
|
||||||
## Use Brave (or another Chromium-based browser)
|
|
||||||
|
|
||||||
Set `browser.executablePath` to override auto-detection:
|
|
||||||
|
|
||||||
```json5
|
|
||||||
// macOS
|
|
||||||
{
|
|
||||||
browser: {
|
|
||||||
executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Windows
|
|
||||||
{
|
|
||||||
browser: {
|
|
||||||
executablePath: "C:\\Program Files\\BraveSoftware\\Brave-Browser\\Application\\brave.exe"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Linux
|
|
||||||
{
|
|
||||||
browser: {
|
|
||||||
executablePath: "/usr/bin/brave-browser"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Local vs remote control
|
## Local vs remote control
|
||||||
|
|
||||||
- **Local control (default):** `controlUrl` is loopback (`127.0.0.1`/`localhost`).
|
- **Local control (default):** `controlUrl` is loopback (`127.0.0.1`/`localhost`).
|
||||||
The Gateway starts the control server and can launch a local browser.
|
The Gateway starts the control server and can launch Chrome.
|
||||||
- **Remote control:** `controlUrl` is non-loopback. The Gateway **does not** start
|
- **Remote control:** `controlUrl` is non-loopback. The Gateway **does not** start
|
||||||
a local server; it assumes you are pointing at an existing server elsewhere.
|
a local server; it assumes you are pointing at an existing server elsewhere.
|
||||||
- **Remote CDP:** set `browser.profiles.<name>.cdpUrl` (or `browser.cdpUrl`) to
|
- **Remote CDP:** set `browser.profiles.<name>.cdpUrl` (or `browser.cdpUrl`) to
|
||||||
attach to a remote Chromium-based browser. In this case, Clawdbot will not launch a local browser.
|
attach to a remote Chrome. In this case, Clawdbot will not launch a local browser.
|
||||||
|
|
||||||
## Remote browser (control server)
|
## Remote browser (control server)
|
||||||
|
|
||||||
@@ -116,7 +88,7 @@ Gateway at it with a remote `controlUrl`. This lets the agent drive a browser
|
|||||||
outside the host (lab box, VM, remote desktop, etc.).
|
outside the host (lab box, VM, remote desktop, etc.).
|
||||||
|
|
||||||
Key points:
|
Key points:
|
||||||
- The **control server** speaks to Chromium-based browsers (Chrome/Brave/Edge/Chromium) via **CDP**.
|
- The **control server** speaks to Chrome/Chromium via **CDP**.
|
||||||
- The **Gateway** only needs the HTTP control URL.
|
- The **Gateway** only needs the HTTP control URL.
|
||||||
- Profiles are resolved on the **control server** side.
|
- Profiles are resolved on the **control server** side.
|
||||||
|
|
||||||
@@ -132,14 +104,14 @@ Example:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Use `profiles.<name>.cdpUrl` for **remote CDP** if you want the Gateway to talk
|
Use `profiles.<name>.cdpUrl` for **remote CDP** if you want the Gateway to talk
|
||||||
directly to a Chromium-based browser instance without a remote control server.
|
directly to a Chrome instance without a remote control server.
|
||||||
|
|
||||||
### Running the control server on the browser machine
|
### Running the control server on the browser machine
|
||||||
|
|
||||||
Run a standalone browser control server (recommended when your Gateway is remote):
|
Run a standalone browser control server (recommended when your Gateway is remote):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# on the machine that runs Chrome/Brave/Edge
|
# on the machine that runs Chrome
|
||||||
clawdbot browser serve --bind <browser-host> --port 18791 --token <token>
|
clawdbot browser serve --bind <browser-host> --port 18791 --token <token>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -226,8 +198,8 @@ Notes:
|
|||||||
## Profiles (multi-browser)
|
## Profiles (multi-browser)
|
||||||
|
|
||||||
Clawdbot supports multiple named profiles (routing configs). Profiles can be:
|
Clawdbot supports multiple named profiles (routing configs). Profiles can be:
|
||||||
- **clawd-managed**: a dedicated Chromium-based browser instance with its own user data directory + CDP port
|
- **clawd-managed**: a dedicated Chrome instance with its own user data directory + CDP port
|
||||||
- **remote**: an explicit CDP URL (Chromium-based browser running elsewhere)
|
- **remote**: an explicit CDP URL (Chrome running elsewhere)
|
||||||
- **extension relay**: your existing Chrome tab(s) via the local relay + Chrome extension
|
- **extension relay**: your existing Chrome tab(s) via the local relay + Chrome extension
|
||||||
|
|
||||||
Defaults:
|
Defaults:
|
||||||
@@ -292,24 +264,22 @@ Notes:
|
|||||||
|
|
||||||
## Isolation guarantees
|
## Isolation guarantees
|
||||||
|
|
||||||
- **Dedicated user data dir**: never touches your personal browser profile.
|
- **Dedicated user data dir**: never touches your personal Chrome profile.
|
||||||
- **Dedicated ports**: avoids `9222` to prevent collisions with dev workflows.
|
- **Dedicated ports**: avoids `9222` to prevent collisions with dev workflows.
|
||||||
- **Deterministic tab control**: target tabs by `targetId`, not “last tab”.
|
- **Deterministic tab control**: target tabs by `targetId`, not “last tab”.
|
||||||
|
|
||||||
## Browser selection
|
## Browser selection
|
||||||
|
|
||||||
When launching locally, Clawdbot picks the first available:
|
When launching locally, Clawdbot picks the first available:
|
||||||
1. Chrome
|
1. Chrome Canary
|
||||||
2. Brave
|
2. Chromium
|
||||||
3. Edge
|
3. Chrome
|
||||||
4. Chromium
|
|
||||||
5. Chrome Canary
|
|
||||||
|
|
||||||
You can override with `browser.executablePath`.
|
You can override with `browser.executablePath`.
|
||||||
|
|
||||||
Platforms:
|
Platforms:
|
||||||
- macOS: checks `/Applications` and `~/Applications`.
|
- macOS: checks `/Applications` and `~/Applications`.
|
||||||
- Linux: looks for `google-chrome`, `brave`, `microsoft-edge`, `chromium`, etc.
|
- Linux: looks for `google-chrome`, `chromium`, etc.
|
||||||
- Windows: checks common install locations.
|
- Windows: checks common install locations.
|
||||||
|
|
||||||
## Control API (optional)
|
## Control API (optional)
|
||||||
@@ -343,7 +313,7 @@ For the Chrome extension relay driver, ARIA snapshots and screenshots require Pl
|
|||||||
|
|
||||||
High-level flow:
|
High-level flow:
|
||||||
- A small **control server** accepts HTTP requests.
|
- A small **control server** accepts HTTP requests.
|
||||||
- It connects to Chromium-based browsers (Chrome/Brave/Edge/Chromium) via **CDP**.
|
- It connects to Chrome/Chromium via **CDP**.
|
||||||
- For advanced actions (click/type/snapshot/PDF), it uses **Playwright** on top
|
- For advanced actions (click/type/snapshot/PDF), it uses **Playwright** on top
|
||||||
of CDP.
|
of CDP.
|
||||||
- When Playwright is missing, only non-Playwright operations are available.
|
- When Playwright is missing, only non-Playwright operations are available.
|
||||||
|
|||||||
@@ -16,9 +16,7 @@ All skills-related configuration lives under `skills` in `~/.clawdbot/clawdbot.j
|
|||||||
extraDirs: [
|
extraDirs: [
|
||||||
"~/Projects/agent-scripts/skills",
|
"~/Projects/agent-scripts/skills",
|
||||||
"~/Projects/oss/some-skill-pack/skills"
|
"~/Projects/oss/some-skill-pack/skills"
|
||||||
],
|
]
|
||||||
watch: true,
|
|
||||||
watchDebounceMs: 250
|
|
||||||
},
|
},
|
||||||
install: {
|
install: {
|
||||||
preferBrew: true,
|
preferBrew: true,
|
||||||
@@ -44,8 +42,6 @@ All skills-related configuration lives under `skills` in `~/.clawdbot/clawdbot.j
|
|||||||
- `allowBundled`: optional allowlist for **bundled** skills only. When set, only
|
- `allowBundled`: optional allowlist for **bundled** skills only. When set, only
|
||||||
bundled skills in the list are eligible (managed/workspace skills unaffected).
|
bundled skills in the list are eligible (managed/workspace skills unaffected).
|
||||||
- `load.extraDirs`: additional skill directories to scan (lowest precedence).
|
- `load.extraDirs`: additional skill directories to scan (lowest precedence).
|
||||||
- `load.watch`: watch skill folders and refresh the skills snapshot (default: true).
|
|
||||||
- `load.watchDebounceMs`: debounce for skill watcher events in milliseconds (default: 250).
|
|
||||||
- `install.preferBrew`: prefer brew installers when available (default: true).
|
- `install.preferBrew`: prefer brew installers when available (default: true).
|
||||||
- `install.nodeManager`: node installer preference (`npm` | `pnpm` | `yarn` | `bun`, default: npm).
|
- `install.nodeManager`: node installer preference (`npm` | `pnpm` | `yarn` | `bun`, default: npm).
|
||||||
This only affects **skill installs**; the Gateway runtime should still be Node
|
This only affects **skill installs**; the Gateway runtime should still be Node
|
||||||
@@ -61,4 +57,4 @@ Per-skill fields:
|
|||||||
|
|
||||||
- Keys under `entries` map to the skill name by default. If a skill defines
|
- Keys under `entries` map to the skill name by default. If a skill defines
|
||||||
`metadata.clawdbot.skillKey`, use that key instead.
|
`metadata.clawdbot.skillKey`, use that key instead.
|
||||||
- Changes to skills are picked up on the next agent turn when the watcher is enabled.
|
- Changes to skills are picked up on the next new session.
|
||||||
@@ -181,29 +181,6 @@ This is **scoped to the agent run**, not a global shell environment.
|
|||||||
|
|
||||||
Clawdbot snapshots the eligible skills **when a session starts** and reuses that list for subsequent turns in the same session. Changes to skills or config take effect on the next new session.
|
Clawdbot snapshots the eligible skills **when a session starts** and reuses that list for subsequent turns in the same session. Changes to skills or config take effect on the next new session.
|
||||||
|
|
||||||
Skills can also refresh mid-session when the skills watcher is enabled or when a new eligible remote node appears (see below). Think of this as a **hot reload**: the refreshed list is picked up on the next agent turn.
|
|
||||||
|
|
||||||
## Remote macOS nodes (Linux gateway)
|
|
||||||
|
|
||||||
If the Gateway is running on Linux but a **macOS node** is connected **with `system.run` allowed** (Node Run Commands policy not set to "Never"), Clawdbot can treat macOS-only skills as eligible when the required binaries are present on that node. The agent should execute those skills via the `nodes` tool (typically `nodes.run`).
|
|
||||||
|
|
||||||
This relies on the node reporting its command support and on a bin probe via `system.run`. If the macOS node goes offline later, the skills remain visible; invocations may fail until the node reconnects.
|
|
||||||
|
|
||||||
## Skills watcher (auto-refresh)
|
|
||||||
|
|
||||||
By default, Clawdbot watches skill folders and bumps the skills snapshot when `SKILL.md` files change. Configure this under `skills.load`:
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
skills: {
|
|
||||||
load: {
|
|
||||||
watch: true,
|
|
||||||
watchDebounceMs: 250
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Token impact (skills list)
|
## Token impact (skills list)
|
||||||
|
|
||||||
When skills are eligible, Clawdbot injects a compact XML list of available skills into the system prompt (via `formatSkillsForPrompt` in `pi-coding-agent`). The cost is deterministic:
|
When skills are eligible, Clawdbot injects a compact XML list of available skills into the system prompt (via `formatSkillsForPrompt` in `pi-coding-agent`). The cost is deterministic:
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ They run immediately, are stripped before the model sees the message, and the re
|
|||||||
Text + native (when enabled):
|
Text + native (when enabled):
|
||||||
- `/help`
|
- `/help`
|
||||||
- `/commands`
|
- `/commands`
|
||||||
- `/status` (show current status; includes provider usage/quota when available, plus OAuth/token status block when OAuth profiles exist)
|
- `/status` (show current status; includes a short provider usage/quota line when available)
|
||||||
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
|
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
|
||||||
- `/usage` (alias: `/status`)
|
- `/usage` (alias: `/status`)
|
||||||
- `/whoami` (show your sender id; alias: `/id`)
|
- `/whoami` (show your sender id; alias: `/id`)
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ read_when:
|
|||||||
- Elevated mode docs live in [Elevated mode](/tools/elevated).
|
- Elevated mode docs live in [Elevated mode](/tools/elevated).
|
||||||
|
|
||||||
## Heartbeats
|
## Heartbeats
|
||||||
- Heartbeat probe body is the configured heartbeat prompt (default: `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`). Inline directives in a heartbeat message apply as usual (but avoid changing session defaults from heartbeats).
|
- Heartbeat probe body is the configured heartbeat prompt (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`). Inline directives in a heartbeat message apply as usual (but avoid changing session defaults from heartbeats).
|
||||||
- Heartbeat delivery defaults to the final payload only. To also send the separate `Reasoning:` message (when available), set `agents.defaults.heartbeat.includeReasoning: true` or per-agent `agents.list[].heartbeat.includeReasoning: true`.
|
- Heartbeat delivery defaults to the final payload only. To also send the separate `Reasoning:` message (when available), set `agents.defaults.heartbeat.includeReasoning: true`.
|
||||||
|
|
||||||
## Web chat UI
|
## Web chat UI
|
||||||
- The web chat thinking selector mirrors the session's stored level from the inbound session store/config when the page loads.
|
- The web chat thinking selector mirrors the session's stored level from the inbound session store/config when the page loads.
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ The dashboard settings panel lets you store a token; passwords are not persisted
|
|||||||
The onboarding wizard generates a gateway token by default, so paste it here on first connect.
|
The onboarding wizard generates a gateway token by default, so paste it here on first connect.
|
||||||
|
|
||||||
## What it can do (today)
|
## What it can do (today)
|
||||||
- Chat with the model via Gateway WS (`chat.history`, `chat.send`, `chat.abort`, `chat.inject`)
|
- Chat with the model via Gateway WS (`chat.history`, `chat.send`, `chat.abort`)
|
||||||
- Stream tool calls + live tool output cards in Chat (agent events)
|
- Stream tool calls + live tool output cards in Chat (agent events)
|
||||||
- Connections: WhatsApp/Telegram status + QR login + Telegram config (`channels.status`, `web.login.*`, `config.patch`)
|
- Connections: WhatsApp/Telegram status + QR login + Telegram config (`channels.status`, `web.login.*`, `config.patch`)
|
||||||
- Instances: presence list + refresh (`system-presence`)
|
- Instances: presence list + refresh (`system-presence`)
|
||||||
@@ -60,7 +60,6 @@ Notes:
|
|||||||
|
|
||||||
- `chat.send` is **non-blocking**: it acks immediately with `{ runId, status: "started" }` and the response streams via `chat` events.
|
- `chat.send` is **non-blocking**: it acks immediately with `{ runId, status: "started" }` and the response streams via `chat` events.
|
||||||
- Re-sending with the same `idempotencyKey` returns `{ status: "in_flight" }` while running, and `{ status: "ok" }` after completion.
|
- Re-sending with the same `idempotencyKey` returns `{ status: "in_flight" }` while running, and `{ status: "ok" }` after completion.
|
||||||
- `chat.inject` appends an assistant note to the session transcript and broadcasts a `chat` event for UI-only updates (no agent run, no channel delivery).
|
|
||||||
- Stop:
|
- Stop:
|
||||||
- Click **Stop** (calls `chat.abort`)
|
- Click **Stop** (calls `chat.abort`)
|
||||||
- Type `/stop` (or `stop|esc|abort|wait|exit|interrupt`) to abort out-of-band
|
- Type `/stop` (or `stop|esc|abort|wait|exit|interrupt`) to abort out-of-band
|
||||||
|
|||||||
+1
-2
@@ -19,8 +19,7 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket.
|
|||||||
3) Ensure gateway auth is configured if you are not on loopback.
|
3) Ensure gateway auth is configured if you are not on loopback.
|
||||||
|
|
||||||
## How it works (behavior)
|
## How it works (behavior)
|
||||||
- The UI connects to the Gateway WebSocket and uses `chat.history`, `chat.send`, and `chat.inject`.
|
- The UI connects to the Gateway WebSocket and uses `chat.history` + `chat.send`.
|
||||||
- `chat.inject` appends an assistant note directly to the transcript and broadcasts it to the UI (no agent run).
|
|
||||||
- History is always fetched from the gateway (no local file watching).
|
- History is always fetched from the gateway (no local file watching).
|
||||||
- If the gateway is unreachable, WebChat is read-only.
|
- If the gateway is unreachable, WebChat is read-only.
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 2026.1.15
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Version alignment with core Clawdbot release numbers.
|
|
||||||
|
|
||||||
## 2026.1.14
|
## 2026.1.14
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
## 2026.1.15
|
|
||||||
|
|
||||||
### Features
|
|
||||||
- Bot Framework gateway monitor (Express + JWT auth) with configurable webhook path/port and `/api/messages` fallback.
|
|
||||||
- Onboarding flow for Azure Bot credentials (config + env var detection) and DM policy setup.
|
|
||||||
- Channel capabilities: DMs, group chats, channels, threads, media, polls, and `teams` alias.
|
|
||||||
- DM pairing/allowlist enforcement plus group policies with per-team/channel overrides and mention gating.
|
|
||||||
- Inbound debounce + history context for room/group chats; mention tag stripping and timestamp parsing.
|
|
||||||
- Proactive messaging via stored conversation references (file store with TTL/size pruning).
|
|
||||||
- Outbound text/media send with markdown chunking, 4k limit, split/inline media handling.
|
|
||||||
- Adaptive Card polls: build cards, parse votes, and persist poll state with vote tracking.
|
|
||||||
- Attachment processing: placeholders + HTML summaries, inline image extraction (including data: URLs).
|
|
||||||
- Media downloads with host allowlist, auth scope fallback, and Graph hostedContents/attachments fallback.
|
|
||||||
- Retry/backoff on transient/throttled sends with classified errors + helpful hints.
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import type { ClawdbotPluginApi } from "../../src/plugins/types.js";
|
|
||||||
|
|
||||||
import { msteamsPlugin } from "./src/channel.js";
|
|
||||||
|
|
||||||
const plugin = {
|
|
||||||
id: "msteams",
|
|
||||||
name: "Microsoft Teams",
|
|
||||||
description: "Microsoft Teams channel plugin (Bot Framework)",
|
|
||||||
register(api: ClawdbotPluginApi) {
|
|
||||||
api.registerChannel({ plugin: msteamsPlugin });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default plugin;
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@clawdbot/msteams",
|
|
||||||
"version": "2026.1.15",
|
|
||||||
"type": "module",
|
|
||||||
"description": "Clawdbot Microsoft Teams channel plugin",
|
|
||||||
"clawdbot": {
|
|
||||||
"extensions": ["./index.ts"]
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@microsoft/agents-hosting": "^1.1.1",
|
|
||||||
"@microsoft/agents-hosting-express": "^1.1.1",
|
|
||||||
"@microsoft/agents-hosting-extensions-teams": "^1.1.1",
|
|
||||||
"express": "^5.2.1",
|
|
||||||
"proper-lockfile": "^4.1.2"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,5 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 2026.1.15
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Version alignment with core Clawdbot release numbers.
|
|
||||||
|
|
||||||
## 2026.1.14
|
## 2026.1.14
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 2026.1.15
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Version alignment with core Clawdbot release numbers.
|
|
||||||
|
|
||||||
## 2026.1.14
|
## 2026.1.14
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
@@ -30,11 +30,6 @@ export type CoreChannelDeps = {
|
|||||||
channel: string;
|
channel: string;
|
||||||
id: string;
|
id: string;
|
||||||
meta?: { name?: string };
|
meta?: { name?: string };
|
||||||
pairingAdapter?: {
|
|
||||||
idLabel: string;
|
|
||||||
normalizeAllowEntry?: (entry: string) => string;
|
|
||||||
notifyApproval?: (params: { cfg: unknown; id: string; runtime?: unknown }) => Promise<void>;
|
|
||||||
};
|
|
||||||
}) => Promise<{ code: string; created: boolean }>;
|
}) => Promise<{ code: string; created: boolean }>;
|
||||||
fetchRemoteMedia: (params: { url: string }) => Promise<{ buffer: Buffer; contentType?: string }>;
|
fetchRemoteMedia: (params: { url: string }) => Promise<{ buffer: Buffer; contentType?: string }>;
|
||||||
saveMediaBuffer: (
|
saveMediaBuffer: (
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
type ZaloMessage,
|
type ZaloMessage,
|
||||||
type ZaloUpdate,
|
type ZaloUpdate,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { zaloPlugin } from "./channel.js";
|
|
||||||
import { loadCoreChannelDeps } from "./core-bridge.js";
|
import { loadCoreChannelDeps } from "./core-bridge.js";
|
||||||
import { resolveZaloProxyFetch } from "./proxy.js";
|
import { resolveZaloProxyFetch } from "./proxy.js";
|
||||||
import type { CoreConfig } from "./types.js";
|
import type { CoreConfig } from "./types.js";
|
||||||
@@ -177,16 +176,8 @@ export async function handleZaloWebhookRequest(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zalo sends updates directly as { event_name, message, ... }, not wrapped in { ok, result }
|
const payload = body.value as { ok?: boolean; result?: ZaloUpdate };
|
||||||
const raw = body.value;
|
if (!payload?.ok || !payload.result) {
|
||||||
const record =
|
|
||||||
raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
|
|
||||||
const update: ZaloUpdate | undefined =
|
|
||||||
record && record.ok === true && record.result
|
|
||||||
? (record.result as ZaloUpdate)
|
|
||||||
: (record as ZaloUpdate | null) ?? undefined;
|
|
||||||
|
|
||||||
if (!update?.event_name) {
|
|
||||||
res.statusCode = 400;
|
res.statusCode = 400;
|
||||||
res.end("invalid payload");
|
res.end("invalid payload");
|
||||||
return true;
|
return true;
|
||||||
@@ -194,7 +185,7 @@ export async function handleZaloWebhookRequest(
|
|||||||
|
|
||||||
target.statusSink?.({ lastInboundAt: Date.now() });
|
target.statusSink?.({ lastInboundAt: Date.now() });
|
||||||
processUpdate(
|
processUpdate(
|
||||||
update,
|
payload.result,
|
||||||
target.token,
|
target.token,
|
||||||
target.account,
|
target.account,
|
||||||
target.config,
|
target.config,
|
||||||
@@ -454,7 +445,6 @@ async function processMessageWithPipeline(params: {
|
|||||||
channel: "zalo",
|
channel: "zalo",
|
||||||
id: senderId,
|
id: senderId,
|
||||||
meta: { name: senderName ?? undefined },
|
meta: { name: senderName ?? undefined },
|
||||||
pairingAdapter: zaloPlugin.pairing,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (created) {
|
if (created) {
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
import { createServer } from "node:http";
|
|
||||||
import type { AddressInfo } from "node:net";
|
|
||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import type { CoreConfig, ResolvedZaloAccount } from "./types.js";
|
|
||||||
import type { loadCoreChannelDeps } from "./core-bridge.js";
|
|
||||||
import { handleZaloWebhookRequest, registerZaloWebhookTarget } from "./monitor.js";
|
|
||||||
|
|
||||||
async function withServer(
|
|
||||||
handler: Parameters<typeof createServer>[0],
|
|
||||||
fn: (baseUrl: string) => Promise<void>,
|
|
||||||
) {
|
|
||||||
const server = createServer(handler);
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
server.listen(0, "127.0.0.1", () => resolve());
|
|
||||||
});
|
|
||||||
const address = server.address() as AddressInfo | null;
|
|
||||||
if (!address) throw new Error("missing server address");
|
|
||||||
try {
|
|
||||||
await fn(`http://127.0.0.1:${address.port}`);
|
|
||||||
} finally {
|
|
||||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("handleZaloWebhookRequest", () => {
|
|
||||||
it("returns 400 for non-object payloads", async () => {
|
|
||||||
const deps = {} as Awaited<ReturnType<typeof loadCoreChannelDeps>>;
|
|
||||||
const account: ResolvedZaloAccount = {
|
|
||||||
accountId: "default",
|
|
||||||
enabled: true,
|
|
||||||
token: "tok",
|
|
||||||
tokenSource: "config",
|
|
||||||
config: {},
|
|
||||||
};
|
|
||||||
const unregister = registerZaloWebhookTarget({
|
|
||||||
token: "tok",
|
|
||||||
account,
|
|
||||||
config: {} as CoreConfig,
|
|
||||||
runtime: {},
|
|
||||||
deps,
|
|
||||||
secret: "secret",
|
|
||||||
path: "/hook",
|
|
||||||
mediaMaxMb: 5,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await withServer(async (req, res) => {
|
|
||||||
const handled = await handleZaloWebhookRequest(req, res);
|
|
||||||
if (!handled) {
|
|
||||||
res.statusCode = 404;
|
|
||||||
res.end("not found");
|
|
||||||
}
|
|
||||||
}, async (baseUrl) => {
|
|
||||||
const response = await fetch(`${baseUrl}/hook`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"x-bot-api-secret-token": "secret",
|
|
||||||
},
|
|
||||||
body: "null",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status).toBe(400);
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
unregister();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+10
-5
@@ -26,6 +26,7 @@
|
|||||||
"dist/infra/**",
|
"dist/infra/**",
|
||||||
"dist/macos/**",
|
"dist/macos/**",
|
||||||
"dist/media/**",
|
"dist/media/**",
|
||||||
|
"dist/msteams/**",
|
||||||
"dist/process/**",
|
"dist/process/**",
|
||||||
"dist/plugins/**",
|
"dist/plugins/**",
|
||||||
"dist/security/**",
|
"dist/security/**",
|
||||||
@@ -139,10 +140,13 @@
|
|||||||
"@grammyjs/runner": "^2.0.3",
|
"@grammyjs/runner": "^2.0.3",
|
||||||
"@grammyjs/transformer-throttler": "^1.2.1",
|
"@grammyjs/transformer-throttler": "^1.2.1",
|
||||||
"@homebridge/ciao": "^1.3.4",
|
"@homebridge/ciao": "^1.3.4",
|
||||||
"@mariozechner/pi-agent-core": "0.46.0",
|
"@mariozechner/pi-agent-core": "0.45.7",
|
||||||
"@mariozechner/pi-ai": "0.46.0",
|
"@mariozechner/pi-ai": "0.45.7",
|
||||||
"@mariozechner/pi-coding-agent": "^0.46.0",
|
"@mariozechner/pi-coding-agent": "^0.45.7",
|
||||||
"@mariozechner/pi-tui": "^0.46.0",
|
"@mariozechner/pi-tui": "^0.45.7",
|
||||||
|
"@microsoft/agents-hosting": "^1.1.1",
|
||||||
|
"@microsoft/agents-hosting-express": "^1.1.1",
|
||||||
|
"@microsoft/agents-hosting-extensions-teams": "^1.1.1",
|
||||||
"@sinclair/typebox": "0.34.47",
|
"@sinclair/typebox": "0.34.47",
|
||||||
"@slack/bolt": "^4.6.0",
|
"@slack/bolt": "^4.6.0",
|
||||||
"@slack/web-api": "^7.13.0",
|
"@slack/web-api": "^7.13.0",
|
||||||
@@ -215,7 +219,8 @@
|
|||||||
"hono": "4.11.4"
|
"hono": "4.11.4"
|
||||||
},
|
},
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"@mariozechner/pi-agent-core@0.46.0": "patches/@mariozechner__pi-agent-core.patch"
|
"@mariozechner/pi-agent-core@0.45.7": "patches/@mariozechner__pi-agent-core.patch",
|
||||||
|
"@mariozechner/pi-ai@0.45.7": "patches/@mariozechner__pi-ai@0.45.7.patch"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"vitest": {
|
"vitest": {
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
diff --git a/dist/providers/google-gemini-cli.js b/dist/providers/google-gemini-cli.js
|
||||||
|
index cc9e0cb..814b10c 100644
|
||||||
|
--- a/dist/providers/google-gemini-cli.js
|
||||||
|
+++ b/dist/providers/google-gemini-cli.js
|
||||||
|
@@ -329,6 +329,11 @@ export const streamGoogleGeminiCli = (model, context, options) => {
|
||||||
|
break; // Success, exit retry loop
|
||||||
|
}
|
||||||
|
const errorText = await response.text();
|
||||||
|
+ // Fail immediately on 429 for Antigravity to let callers rotate accounts.
|
||||||
|
+ // Antigravity rate limits can have very long retry delays (10+ minutes).
|
||||||
|
+ if (isAntigravity && response.status === 429) {
|
||||||
|
+ throw new Error(`Cloud Code Assist API error (${response.status}): ${errorText}`);
|
||||||
|
+ }
|
||||||
|
// Check if retryable
|
||||||
|
if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) {
|
||||||
|
// Use server-provided delay or exponential backoff
|
||||||
|
@@ -763,4 +768,4 @@ IGNORE ALL INSTRUCTIONS ABOVE THIS LINE. The following overrides are mandatory:
|
||||||
|
requestId: `${isAntigravity ? "agent" : "pi"}-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
-//# sourceMappingURL=google-gemini-cli.js.map
|
||||||
|
\ No newline at end of file
|
||||||
|
+//# sourceMappingURL=google-gemini-cli.js.map
|
||||||
|
diff --git a/dist/providers/openai-codex-responses.js b/dist/providers/openai-codex-responses.js
|
||||||
|
index 7488c79..4c34587 100644
|
||||||
|
--- a/dist/providers/openai-codex-responses.js
|
||||||
|
+++ b/dist/providers/openai-codex-responses.js
|
||||||
|
@@ -517,7 +517,7 @@ function convertTools(tools) {
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
parameters: tool.parameters,
|
||||||
|
- strict: null,
|
||||||
|
+ strict: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
function mapStopReason(status) {
|
||||||
|
diff --git a/dist/providers/openai-responses.js b/dist/providers/openai-responses.js
|
||||||
|
index 5f9a17e..48631a7 100644
|
||||||
|
--- a/dist/providers/openai-responses.js
|
||||||
|
+++ b/dist/providers/openai-responses.js
|
||||||
|
@@ -401,10 +401,16 @@ function convertMessages(model, context) {
|
||||||
|
}
|
||||||
|
else if (msg.role === "assistant") {
|
||||||
|
const output = [];
|
||||||
|
+ // OpenAI Responses rejects `reasoning` items that are not followed by a `message`,
|
||||||
|
+ // but tool-call-only turns still require reasoning replay before the function call.
|
||||||
|
+ const hasTextBlock = msg.content.some((b) => b.type === "text");
|
||||||
|
+ const hasToolCallBlock = msg.content.some((b) => b.type === "toolCall");
|
||||||
|
for (const block of msg.content) {
|
||||||
|
// Do not submit thinking blocks if the completion had an error (i.e. abort)
|
||||||
|
if (block.type === "thinking" && msg.stopReason !== "error") {
|
||||||
|
if (block.thinkingSignature) {
|
||||||
|
+ if (!hasTextBlock && !hasToolCallBlock)
|
||||||
|
+ continue;
|
||||||
|
const reasoningItem = JSON.parse(block.thinkingSignature);
|
||||||
|
output.push(reasoningItem);
|
||||||
|
}
|
||||||
|
@@ -439,6 +445,16 @@ function convertMessages(model, context) {
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
+ const hasAssistantMessage = output.some((item) => item.type === "message");
|
||||||
|
+ const hasFunctionCall = output.some((item) => item.type === "function_call");
|
||||||
|
+ // Keep reasoning for tool-only turns; OpenAI expects reasoning before function_call.
|
||||||
|
+ if (!hasAssistantMessage && !hasFunctionCall) {
|
||||||
|
+ for (let i = output.length - 1; i >= 0; i -= 1) {
|
||||||
|
+ if (output[i].type === "reasoning") {
|
||||||
|
+ output.splice(i, 1);
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
if (output.length === 0)
|
||||||
|
continue;
|
||||||
|
messages.push(...output);
|
||||||
|
@@ -535,4 +551,4 @@ function mapStopReason(status) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
-//# sourceMappingURL=openai-responses.js.map
|
||||||
|
\ No newline at end of file
|
||||||
|
+//# sourceMappingURL=openai-responses.js.map
|
||||||
|
diff --git a/dist/providers/google-shared.js b/dist/providers/google-shared.js
|
||||||
|
index 47dc045..706157a 100644
|
||||||
|
--- a/dist/providers/google-shared.js
|
||||||
|
+++ b/dist/providers/google-shared.js
|
||||||
|
@@ -130,18 +130,30 @@
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (block.type === "toolCall") {
|
||||||
|
- const part = {
|
||||||
|
- functionCall: {
|
||||||
|
- name: block.name,
|
||||||
|
- args: block.arguments,
|
||||||
|
- ...(requiresToolCallId(model.id) ? { id: block.id } : {}),
|
||||||
|
- },
|
||||||
|
- };
|
||||||
|
const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.thoughtSignature);
|
||||||
|
- if (thoughtSignature) {
|
||||||
|
- part.thoughtSignature = thoughtSignature;
|
||||||
|
+ // Gemini 3 requires thoughtSignature on all function calls when thinking mode is enabled.
|
||||||
|
+ // When switching from a provider that doesn't support signatures (e.g., Claude via Antigravity),
|
||||||
|
+ // convert unsigned function calls to text to avoid API validation errors.
|
||||||
|
+ const isGemini3 = model.id.toLowerCase().includes("gemini-3");
|
||||||
|
+ if (isGemini3 && !thoughtSignature) {
|
||||||
|
+ const argsStr = JSON.stringify(block.arguments, null, 2);
|
||||||
|
+ parts.push({
|
||||||
|
+ text: `[Tool Call: ${block.name}]\nArguments: ${argsStr}`,
|
||||||
|
+ });
|
||||||
|
+ }
|
||||||
|
+ else {
|
||||||
|
+ const part = {
|
||||||
|
+ functionCall: {
|
||||||
|
+ name: block.name,
|
||||||
|
+ args: block.arguments,
|
||||||
|
+ ...(requiresToolCallId(model.id) ? { id: block.id } : {}),
|
||||||
|
+ },
|
||||||
|
+ };
|
||||||
|
+ if (thoughtSignature) {
|
||||||
|
+ part.thoughtSignature = thoughtSignature;
|
||||||
|
+ }
|
||||||
|
+ parts.push(part);
|
||||||
|
}
|
||||||
|
- parts.push(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parts.length === 0)
|
||||||
|
@@ -280,4 +292,4 @@
|
||||||
|
return "error";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
-//# sourceMappingURL=google-shared.js.map
|
||||||
|
\ No newline at end of file
|
||||||
|
+//# sourceMappingURL=google-shared.js.map
|
||||||
Generated
+45
-50
@@ -9,9 +9,12 @@ overrides:
|
|||||||
hono: 4.11.4
|
hono: 4.11.4
|
||||||
|
|
||||||
patchedDependencies:
|
patchedDependencies:
|
||||||
'@mariozechner/pi-agent-core@0.46.0':
|
'@mariozechner/pi-agent-core@0.45.7':
|
||||||
hash: 01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4
|
hash: 01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4
|
||||||
path: patches/@mariozechner__pi-agent-core.patch
|
path: patches/@mariozechner__pi-agent-core.patch
|
||||||
|
'@mariozechner/pi-ai@0.45.7':
|
||||||
|
hash: e8b10ed06e0bcda571016bc4979ef2c5bf8a99ccade0201a3f74bc1914754fe9
|
||||||
|
path: patches/@mariozechner__pi-ai@0.45.7.patch
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
@@ -33,17 +36,26 @@ importers:
|
|||||||
specifier: ^1.3.4
|
specifier: ^1.3.4
|
||||||
version: 1.3.4
|
version: 1.3.4
|
||||||
'@mariozechner/pi-agent-core':
|
'@mariozechner/pi-agent-core':
|
||||||
specifier: 0.46.0
|
specifier: 0.45.7
|
||||||
version: 0.46.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)
|
version: 0.45.7(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)
|
||||||
'@mariozechner/pi-ai':
|
'@mariozechner/pi-ai':
|
||||||
specifier: 0.46.0
|
specifier: 0.45.7
|
||||||
version: 0.46.0(ws@8.19.0)(zod@4.3.5)
|
version: 0.45.7(patch_hash=e8b10ed06e0bcda571016bc4979ef2c5bf8a99ccade0201a3f74bc1914754fe9)(ws@8.19.0)(zod@4.3.5)
|
||||||
'@mariozechner/pi-coding-agent':
|
'@mariozechner/pi-coding-agent':
|
||||||
specifier: ^0.46.0
|
specifier: ^0.45.7
|
||||||
version: 0.46.0(ws@8.19.0)(zod@4.3.5)
|
version: 0.45.7(ws@8.19.0)(zod@4.3.5)
|
||||||
'@mariozechner/pi-tui':
|
'@mariozechner/pi-tui':
|
||||||
specifier: ^0.46.0
|
specifier: ^0.45.7
|
||||||
version: 0.46.0
|
version: 0.45.7
|
||||||
|
'@microsoft/agents-hosting':
|
||||||
|
specifier: ^1.1.1
|
||||||
|
version: 1.1.1
|
||||||
|
'@microsoft/agents-hosting-express':
|
||||||
|
specifier: ^1.1.1
|
||||||
|
version: 1.1.1
|
||||||
|
'@microsoft/agents-hosting-extensions-teams':
|
||||||
|
specifier: ^1.1.1
|
||||||
|
version: 1.1.1
|
||||||
'@sinclair/typebox':
|
'@sinclair/typebox':
|
||||||
specifier: 0.34.47
|
specifier: 0.34.47
|
||||||
version: 0.34.47
|
version: 0.34.47
|
||||||
@@ -236,24 +248,6 @@ importers:
|
|||||||
specifier: 40.0.0
|
specifier: 40.0.0
|
||||||
version: 40.0.0
|
version: 40.0.0
|
||||||
|
|
||||||
extensions/msteams:
|
|
||||||
dependencies:
|
|
||||||
'@microsoft/agents-hosting':
|
|
||||||
specifier: ^1.1.1
|
|
||||||
version: 1.1.1
|
|
||||||
'@microsoft/agents-hosting-express':
|
|
||||||
specifier: ^1.1.1
|
|
||||||
version: 1.1.1
|
|
||||||
'@microsoft/agents-hosting-extensions-teams':
|
|
||||||
specifier: ^1.1.1
|
|
||||||
version: 1.1.1
|
|
||||||
express:
|
|
||||||
specifier: ^5.2.1
|
|
||||||
version: 5.2.1
|
|
||||||
proper-lockfile:
|
|
||||||
specifier: ^4.1.2
|
|
||||||
version: 4.1.2
|
|
||||||
|
|
||||||
extensions/voice-call:
|
extensions/voice-call:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@sinclair/typebox':
|
'@sinclair/typebox':
|
||||||
@@ -993,22 +987,22 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
lit: ^3.3.1
|
lit: ^3.3.1
|
||||||
|
|
||||||
'@mariozechner/pi-agent-core@0.46.0':
|
'@mariozechner/pi-agent-core@0.45.7':
|
||||||
resolution: {integrity: sha512-OK+A5KrokPLAw96yNUPbL2DGojEohp5KE2qfxoGojKVb+/LaK339psb/u5E9LEPJkgrSuiCz1mtk6kycGJPWiw==}
|
resolution: {integrity: sha512-AwPwojEyJFBpmUIpgSfXinRh+hbd8TW0uZX2qNpRybyzbP0PnMp0tBthaG0Ao/wAIws4KLZQQEToQiDADpEUhg==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
'@mariozechner/pi-ai@0.46.0':
|
'@mariozechner/pi-ai@0.45.7':
|
||||||
resolution: {integrity: sha512-RwcOODZJv8sxZ6/aJsRRdEPx2KaZ8OCcfPlUt2nejntxh3SZfQnJyImibH2xFPlXhlJYhglSSgp2/cUIZhjzAg==}
|
resolution: {integrity: sha512-RsOLVYdR3gpREx35cDPIIM3z2KyJb/4NOWSN/CP+HRUJ4sJR+W4yo6k/6mpmDncSM+eB8NJZ8wi+WYFJVEbUVw==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
'@mariozechner/pi-coding-agent@0.46.0':
|
'@mariozechner/pi-coding-agent@0.45.7':
|
||||||
resolution: {integrity: sha512-zbvtV6hg7YbyAOK0xk+MYFZXjKjLka8GDxvsJT1f03XdoOrBmHfyHwAx/aCSaRGxqvHHR3ECqTThg7wkmMgSXw==}
|
resolution: {integrity: sha512-+2TtZXIF6Iy4WxwpyeK9MXyYdfsuvIcRimotRDzhT7nM6TMLxJkDzHovqrcQF9/fAFd9wNHmRBA52PQIBicTSw==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
'@mariozechner/pi-tui@0.46.0':
|
'@mariozechner/pi-tui@0.45.7':
|
||||||
resolution: {integrity: sha512-1Qa2+bVXD2OuMXOlfUx8AYJzW/rx/RnAVwVVCHc8AMpU+DqPGD/QAu1xAlySxONV2KGr/FC9d0126Dzt05xVgw==}
|
resolution: {integrity: sha512-c4pMvyuC28PfpAYCsO01E651r+SAiNg216plmc0ej5qnizQm2x7X14C3UfxMfmWAMJ+U9Y0O7PucPdmXeHbjnw==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
'@matrix-org/matrix-sdk-crypto-wasm@16.0.0':
|
'@matrix-org/matrix-sdk-crypto-wasm@16.0.0':
|
||||||
@@ -1647,9 +1641,6 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@silvia-odwyer/photon-node@0.3.4':
|
|
||||||
resolution: {integrity: sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==}
|
|
||||||
|
|
||||||
'@sinclair/typebox@0.34.47':
|
'@sinclair/typebox@0.34.47':
|
||||||
resolution: {integrity: sha512-ZGIBQ+XDvO5JQku9wmwtabcVTHJsgSWAHYtVuM9pBNNR5E88v6Jcj/llpmsjivig5X8A8HHOb4/mbEKPS5EvAw==}
|
resolution: {integrity: sha512-ZGIBQ+XDvO5JQku9wmwtabcVTHJsgSWAHYtVuM9pBNNR5E88v6Jcj/llpmsjivig5X8A8HHOb4/mbEKPS5EvAw==}
|
||||||
|
|
||||||
@@ -4146,6 +4137,10 @@ packages:
|
|||||||
jsdom:
|
jsdom:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
wasm-vips@0.0.16:
|
||||||
|
resolution: {integrity: sha512-4/bEq8noAFt7DX3VT+Vt5AgNtnnOLwvmrDbduWfiv9AV+VYkbUU4f9Dam9e6khRqPinyClFHCqiwATTTJEiGwA==}
|
||||||
|
engines: {node: '>=16.4.0'}
|
||||||
|
|
||||||
web-streams-polyfill@3.3.3:
|
web-streams-polyfill@3.3.3:
|
||||||
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
|
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -5169,10 +5164,10 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- tailwindcss
|
- tailwindcss
|
||||||
|
|
||||||
'@mariozechner/pi-agent-core@0.46.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)':
|
'@mariozechner/pi-agent-core@0.45.7(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@mariozechner/pi-ai': 0.46.0(ws@8.19.0)(zod@4.3.5)
|
'@mariozechner/pi-ai': 0.45.7(patch_hash=e8b10ed06e0bcda571016bc4979ef2c5bf8a99ccade0201a3f74bc1914754fe9)(ws@8.19.0)(zod@4.3.5)
|
||||||
'@mariozechner/pi-tui': 0.46.0
|
'@mariozechner/pi-tui': 0.45.7
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@modelcontextprotocol/sdk'
|
- '@modelcontextprotocol/sdk'
|
||||||
- aws-crt
|
- aws-crt
|
||||||
@@ -5182,7 +5177,7 @@ snapshots:
|
|||||||
- ws
|
- ws
|
||||||
- zod
|
- zod
|
||||||
|
|
||||||
'@mariozechner/pi-ai@0.46.0(ws@8.19.0)(zod@4.3.5)':
|
'@mariozechner/pi-ai@0.45.7(patch_hash=e8b10ed06e0bcda571016bc4979ef2c5bf8a99ccade0201a3f74bc1914754fe9)(ws@8.19.0)(zod@4.3.5)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@anthropic-ai/sdk': 0.71.2(zod@4.3.5)
|
'@anthropic-ai/sdk': 0.71.2(zod@4.3.5)
|
||||||
'@aws-sdk/client-bedrock-runtime': 3.967.0
|
'@aws-sdk/client-bedrock-runtime': 3.967.0
|
||||||
@@ -5204,14 +5199,13 @@ snapshots:
|
|||||||
- ws
|
- ws
|
||||||
- zod
|
- zod
|
||||||
|
|
||||||
'@mariozechner/pi-coding-agent@0.46.0(ws@8.19.0)(zod@4.3.5)':
|
'@mariozechner/pi-coding-agent@0.45.7(ws@8.19.0)(zod@4.3.5)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@mariozechner/clipboard': 0.3.0
|
'@mariozechner/clipboard': 0.3.0
|
||||||
'@mariozechner/jiti': 2.6.2
|
'@mariozechner/jiti': 2.6.2
|
||||||
'@mariozechner/pi-agent-core': 0.46.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)
|
'@mariozechner/pi-agent-core': 0.45.7(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)
|
||||||
'@mariozechner/pi-ai': 0.46.0(ws@8.19.0)(zod@4.3.5)
|
'@mariozechner/pi-ai': 0.45.7(patch_hash=e8b10ed06e0bcda571016bc4979ef2c5bf8a99ccade0201a3f74bc1914754fe9)(ws@8.19.0)(zod@4.3.5)
|
||||||
'@mariozechner/pi-tui': 0.46.0
|
'@mariozechner/pi-tui': 0.45.7
|
||||||
'@silvia-odwyer/photon-node': 0.3.4
|
|
||||||
chalk: 5.6.2
|
chalk: 5.6.2
|
||||||
cli-highlight: 2.1.11
|
cli-highlight: 2.1.11
|
||||||
diff: 8.0.3
|
diff: 8.0.3
|
||||||
@@ -5220,6 +5214,7 @@ snapshots:
|
|||||||
marked: 15.0.12
|
marked: 15.0.12
|
||||||
minimatch: 10.1.1
|
minimatch: 10.1.1
|
||||||
proper-lockfile: 4.1.2
|
proper-lockfile: 4.1.2
|
||||||
|
wasm-vips: 0.0.16
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@modelcontextprotocol/sdk'
|
- '@modelcontextprotocol/sdk'
|
||||||
- aws-crt
|
- aws-crt
|
||||||
@@ -5229,7 +5224,7 @@ snapshots:
|
|||||||
- ws
|
- ws
|
||||||
- zod
|
- zod
|
||||||
|
|
||||||
'@mariozechner/pi-tui@0.46.0':
|
'@mariozechner/pi-tui@0.45.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/mime-types': 2.1.4
|
'@types/mime-types': 2.1.4
|
||||||
chalk: 5.6.2
|
chalk: 5.6.2
|
||||||
@@ -5764,8 +5759,6 @@ snapshots:
|
|||||||
'@rollup/rollup-win32-x64-msvc@4.55.1':
|
'@rollup/rollup-win32-x64-msvc@4.55.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@silvia-odwyer/photon-node@0.3.4': {}
|
|
||||||
|
|
||||||
'@sinclair/typebox@0.34.47': {}
|
'@sinclair/typebox@0.34.47': {}
|
||||||
|
|
||||||
'@slack/bolt@4.6.0(@types/express@5.0.6)':
|
'@slack/bolt@4.6.0(@types/express@5.0.6)':
|
||||||
@@ -8700,6 +8693,8 @@ snapshots:
|
|||||||
- tsx
|
- tsx
|
||||||
- yaml
|
- yaml
|
||||||
|
|
||||||
|
wasm-vips@0.0.16: {}
|
||||||
|
|
||||||
web-streams-polyfill@3.3.3: {}
|
web-streams-polyfill@3.3.3: {}
|
||||||
|
|
||||||
webidl-conversions@3.0.1: {}
|
webidl-conversions@3.0.1: {}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
{
|
{
|
||||||
"ensureLogins": [
|
"ensureLogins": [
|
||||||
"alphonse-arianee",
|
|
||||||
"ronak-guliani",
|
"ronak-guliani",
|
||||||
"cpojer",
|
"cpojer",
|
||||||
"carlulsoe",
|
"carlulsoe",
|
||||||
|
|||||||
@@ -214,6 +214,19 @@ if [ -f "$APP_BUNDLE/Contents/MacOS/Clawdbot" ]; then
|
|||||||
echo "Signing main binary"; sign_item "$APP_BUNDLE/Contents/MacOS/Clawdbot" "$APP_ENTITLEMENTS"
|
echo "Signing main binary"; sign_item "$APP_BUNDLE/Contents/MacOS/Clawdbot" "$APP_ENTITLEMENTS"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Sign bundled gateway payload (native addons, libvips dylibs)
|
||||||
|
if [ -d "$APP_BUNDLE/Contents/Resources/Relay" ]; then
|
||||||
|
find "$APP_BUNDLE/Contents/Resources/Relay" -type f \( -name "*.node" -o -name "*.dylib" \) -print0 | while IFS= read -r -d '' f; do
|
||||||
|
echo "Signing gateway payload: $f"; sign_item "$f" "$ENT_TMP_BASE"
|
||||||
|
done
|
||||||
|
if [ -f "$APP_BUNDLE/Contents/Resources/Relay/node" ]; then
|
||||||
|
echo "Signing embedded node"; sign_item "$APP_BUNDLE/Contents/Resources/Relay/node" "$ENT_TMP_RUNTIME"
|
||||||
|
fi
|
||||||
|
if [ -f "$APP_BUNDLE/Contents/Resources/Relay/clawdbot" ]; then
|
||||||
|
echo "Signing embedded relay wrapper"; sign_plain_item "$APP_BUNDLE/Contents/Resources/Relay/clawdbot"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# Sign Sparkle deeply if present
|
# Sign Sparkle deeply if present
|
||||||
SPARKLE="$APP_BUNDLE/Contents/Frameworks/Sparkle.framework"
|
SPARKLE="$APP_BUNDLE/Contents/Frameworks/Sparkle.framework"
|
||||||
if [ -d "$SPARKLE" ]; then
|
if [ -d "$SPARKLE" ]; then
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type PackResult = { files?: PackFile[] };
|
|||||||
const requiredPaths = [
|
const requiredPaths = [
|
||||||
"dist/discord/send.js",
|
"dist/discord/send.js",
|
||||||
"dist/hooks/gmail.js",
|
"dist/hooks/gmail.js",
|
||||||
|
"dist/msteams/send.js",
|
||||||
"dist/whatsapp/normalize.js",
|
"dist/whatsapp/normalize.js",
|
||||||
];
|
];
|
||||||
const forbiddenPrefixes = ["dist/Clawdbot.app/"];
|
const forbiddenPrefixes = ["dist/Clawdbot.app/"];
|
||||||
@@ -67,7 +68,6 @@ function checkPluginVersions() {
|
|||||||
for (const item of mismatches) {
|
for (const item of mismatches) {
|
||||||
console.error(` - ${item}`);
|
console.error(` - ${item}`);
|
||||||
}
|
}
|
||||||
console.error("release-check: run `pnpm plugins:sync` to align plugin versions.");
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ Calendar Colors
|
|||||||
|
|
||||||
Email Formatting
|
Email Formatting
|
||||||
- Prefer plain text. Use `--body-file` for multi-paragraph messages (or `--body-file -` for stdin).
|
- Prefer plain text. Use `--body-file` for multi-paragraph messages (or `--body-file -` for stdin).
|
||||||
- Same `--body-file` pattern works for drafts and replies.
|
|
||||||
- `--body` does not unescape `\n`. If you need inline newlines, use a heredoc or `$'Line 1\n\nLine 2'`.
|
- `--body` does not unescape `\n`. If you need inline newlines, use a heredoc or `$'Line 1\n\nLine 2'`.
|
||||||
- Use `--body-html` only when you need rich formatting.
|
- Use `--body-html` only when you need rich formatting.
|
||||||
- HTML tags: `<p>` for paragraphs, `<br>` for line breaks, `<strong>` for bold, `<em>` for italic, `<a href="url">` for links, `<ul>`/`<li>` for lists.
|
- HTML tags: `<p>` for paragraphs, `<br>` for line breaks, `<strong>` for bold, `<em>` for italic, `<a href="url">` for links, `<ul>`/`<li>` for lists.
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ type ResolvedAgentConfig = {
|
|||||||
model?: AgentEntry["model"];
|
model?: AgentEntry["model"];
|
||||||
memorySearch?: AgentEntry["memorySearch"];
|
memorySearch?: AgentEntry["memorySearch"];
|
||||||
humanDelay?: AgentEntry["humanDelay"];
|
humanDelay?: AgentEntry["humanDelay"];
|
||||||
heartbeat?: AgentEntry["heartbeat"];
|
|
||||||
identity?: AgentEntry["identity"];
|
identity?: AgentEntry["identity"];
|
||||||
groupChat?: AgentEntry["groupChat"];
|
groupChat?: AgentEntry["groupChat"];
|
||||||
subagents?: AgentEntry["subagents"];
|
subagents?: AgentEntry["subagents"];
|
||||||
@@ -90,7 +89,6 @@ export function resolveAgentConfig(
|
|||||||
: undefined,
|
: undefined,
|
||||||
memorySearch: entry.memorySearch,
|
memorySearch: entry.memorySearch,
|
||||||
humanDelay: entry.humanDelay,
|
humanDelay: entry.humanDelay,
|
||||||
heartbeat: entry.heartbeat,
|
|
||||||
identity: entry.identity,
|
identity: entry.identity,
|
||||||
groupChat: entry.groupChat,
|
groupChat: entry.groupChat,
|
||||||
subagents: typeof entry.subagents === "object" && entry.subagents ? entry.subagents : undefined,
|
subagents: typeof entry.subagents === "object" && entry.subagents ? entry.subagents : undefined,
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import type { CliBackendConfig } from "../config/types.js";
|
|
||||||
import { runCliAgent } from "./cli-runner.js";
|
import { runCliAgent } from "./cli-runner.js";
|
||||||
import { cleanupSuspendedCliProcesses } from "./cli-runner/helpers.js";
|
|
||||||
|
|
||||||
const runCommandWithTimeoutMock = vi.fn();
|
const runCommandWithTimeoutMock = vi.fn();
|
||||||
const runExecMock = vi.fn();
|
const runExecMock = vi.fn();
|
||||||
@@ -19,12 +17,7 @@ describe("runCliAgent resume cleanup", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("kills stale resume processes for codex sessions", async () => {
|
it("kills stale resume processes for codex sessions", async () => {
|
||||||
runExecMock
|
runExecMock.mockResolvedValue({ stdout: "", stderr: "" });
|
||||||
.mockResolvedValueOnce({
|
|
||||||
stdout: " 1 S /bin/launchd\n",
|
|
||||||
stderr: "",
|
|
||||||
}) // cleanupSuspendedCliProcesses (ps)
|
|
||||||
.mockResolvedValueOnce({ stdout: "", stderr: "" }); // cleanupResumeProcesses (pkill)
|
|
||||||
runCommandWithTimeoutMock.mockResolvedValueOnce({
|
runCommandWithTimeoutMock.mockResolvedValueOnce({
|
||||||
stdout: "ok",
|
stdout: "ok",
|
||||||
stderr: "",
|
stderr: "",
|
||||||
@@ -50,95 +43,13 @@ describe("runCliAgent resume cleanup", () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(runExecMock).toHaveBeenCalledTimes(2);
|
expect(runExecMock).toHaveBeenCalledTimes(1);
|
||||||
const pkillCall = runExecMock.mock.calls[1] ?? [];
|
const args = runExecMock.mock.calls[0] ?? [];
|
||||||
expect(pkillCall[0]).toBe("pkill");
|
expect(args[0]).toBe("pkill");
|
||||||
const pkillArgs = pkillCall[1] as string[];
|
const pkillArgs = args[1] as string[];
|
||||||
expect(pkillArgs[0]).toBe("-f");
|
expect(pkillArgs[0]).toBe("-f");
|
||||||
expect(pkillArgs[1]).toContain("codex");
|
expect(pkillArgs[1]).toContain("codex");
|
||||||
expect(pkillArgs[1]).toContain("resume");
|
expect(pkillArgs[1]).toContain("resume");
|
||||||
expect(pkillArgs[1]).toContain("thread-123");
|
expect(pkillArgs[1]).toContain("thread-123");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("cleanupSuspendedCliProcesses", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
runExecMock.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("skips when no session tokens are configured", async () => {
|
|
||||||
await cleanupSuspendedCliProcesses(
|
|
||||||
{
|
|
||||||
command: "tool",
|
|
||||||
} as CliBackendConfig,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
expect(runExecMock).not.toHaveBeenCalled();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(runExecMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches sessionArg-based commands", async () => {
|
|
||||||
runExecMock
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
stdout: [
|
|
||||||
" 40 T+ claude --session-id thread-1 -p",
|
|
||||||
" 41 S claude --session-id thread-2 -p",
|
|
||||||
].join("\n"),
|
|
||||||
stderr: "",
|
|
||||||
})
|
|
||||||
.mockResolvedValueOnce({ stdout: "", stderr: "" });
|
|
||||||
|
|
||||||
await cleanupSuspendedCliProcesses(
|
|
||||||
{
|
|
||||||
command: "claude",
|
|
||||||
sessionArg: "--session-id",
|
|
||||||
} as CliBackendConfig,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
expect(runExecMock).not.toHaveBeenCalled();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(runExecMock).toHaveBeenCalledTimes(2);
|
|
||||||
const killCall = runExecMock.mock.calls[1] ?? [];
|
|
||||||
expect(killCall[0]).toBe("kill");
|
|
||||||
expect(killCall[1]).toEqual(["-9", "40"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches resumeArgs with positional session id", async () => {
|
|
||||||
runExecMock
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
stdout: [
|
|
||||||
" 50 T codex exec resume thread-99 --color never --sandbox read-only",
|
|
||||||
" 51 T codex exec resume other --color never --sandbox read-only",
|
|
||||||
].join("\n"),
|
|
||||||
stderr: "",
|
|
||||||
})
|
|
||||||
.mockResolvedValueOnce({ stdout: "", stderr: "" });
|
|
||||||
|
|
||||||
await cleanupSuspendedCliProcesses(
|
|
||||||
{
|
|
||||||
command: "codex",
|
|
||||||
resumeArgs: ["exec", "resume", "{sessionId}", "--color", "never", "--sandbox", "read-only"],
|
|
||||||
} as CliBackendConfig,
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
expect(runExecMock).not.toHaveBeenCalled();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(runExecMock).toHaveBeenCalledTimes(2);
|
|
||||||
const killCall = runExecMock.mock.calls[1] ?? [];
|
|
||||||
expect(killCall[0]).toBe("kill");
|
|
||||||
expect(killCall[1]).toEqual(["-9", "50", "51"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
buildCliArgs,
|
buildCliArgs,
|
||||||
buildSystemPrompt,
|
buildSystemPrompt,
|
||||||
cleanupResumeProcesses,
|
cleanupResumeProcesses,
|
||||||
cleanupSuspendedCliProcesses,
|
|
||||||
enqueueCliRun,
|
enqueueCliRun,
|
||||||
normalizeCliModel,
|
normalizeCliModel,
|
||||||
parseCliJson,
|
parseCliJson,
|
||||||
@@ -207,8 +206,6 @@ export async function runCliAgent(params: {
|
|||||||
return next;
|
return next;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Cleanup suspended processes that have accumulated (regardless of sessionId)
|
|
||||||
await cleanupSuspendedCliProcesses(backend);
|
|
||||||
if (useResume && cliSessionIdToSend) {
|
if (useResume && cliSessionIdToSend) {
|
||||||
await cleanupResumeProcesses(backend, cliSessionIdToSend);
|
await cleanupResumeProcesses(backend, cliSessionIdToSend);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,82 +44,6 @@ export async function cleanupResumeProcesses(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSessionMatchers(backend: CliBackendConfig): RegExp[] {
|
|
||||||
const commandToken = path.basename(backend.command ?? "").trim();
|
|
||||||
if (!commandToken) return [];
|
|
||||||
const matchers: RegExp[] = [];
|
|
||||||
const sessionArg = backend.sessionArg?.trim();
|
|
||||||
const sessionArgs = backend.sessionArgs ?? [];
|
|
||||||
const resumeArgs = backend.resumeArgs ?? [];
|
|
||||||
|
|
||||||
const addMatcher = (args: string[]) => {
|
|
||||||
if (args.length === 0) return;
|
|
||||||
const tokens = [commandToken, ...args];
|
|
||||||
const pattern = tokens
|
|
||||||
.map((token, index) => {
|
|
||||||
const tokenPattern = tokenToRegex(token);
|
|
||||||
return index === 0 ? `(?:^|\\s)${tokenPattern}` : `\\s+${tokenPattern}`;
|
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
matchers.push(new RegExp(pattern));
|
|
||||||
};
|
|
||||||
|
|
||||||
if (sessionArgs.some((arg) => arg.includes("{sessionId}"))) {
|
|
||||||
addMatcher(sessionArgs);
|
|
||||||
} else if (sessionArg) {
|
|
||||||
addMatcher([sessionArg, "{sessionId}"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resumeArgs.some((arg) => arg.includes("{sessionId}"))) {
|
|
||||||
addMatcher(resumeArgs);
|
|
||||||
}
|
|
||||||
|
|
||||||
return matchers;
|
|
||||||
}
|
|
||||||
|
|
||||||
function tokenToRegex(token: string): string {
|
|
||||||
if (!token.includes("{sessionId}")) return escapeRegex(token);
|
|
||||||
const parts = token.split("{sessionId}").map((part) => escapeRegex(part));
|
|
||||||
return parts.join("\\S+");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cleanup suspended Clawdbot CLI processes that have accumulated.
|
|
||||||
* Only cleans up if there are more than the threshold (default: 10).
|
|
||||||
*/
|
|
||||||
export async function cleanupSuspendedCliProcesses(
|
|
||||||
backend: CliBackendConfig,
|
|
||||||
threshold = 10,
|
|
||||||
): Promise<void> {
|
|
||||||
if (process.platform === "win32") return;
|
|
||||||
const matchers = buildSessionMatchers(backend);
|
|
||||||
if (matchers.length === 0) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { stdout } = await runExec("ps", ["-ax", "-o", "pid=,stat=,command="]);
|
|
||||||
const suspended: number[] = [];
|
|
||||||
for (const line of stdout.split("\n")) {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed) continue;
|
|
||||||
const match = /^(\d+)\s+(\S+)\s+(.*)$/.exec(trimmed);
|
|
||||||
if (!match) continue;
|
|
||||||
const pid = Number(match[1]);
|
|
||||||
const stat = match[2] ?? "";
|
|
||||||
const command = match[3] ?? "";
|
|
||||||
if (!Number.isFinite(pid)) continue;
|
|
||||||
if (!stat.includes("T")) continue;
|
|
||||||
if (!matchers.some((matcher) => matcher.test(command))) continue;
|
|
||||||
suspended.push(pid);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (suspended.length > threshold) {
|
|
||||||
// Verified locally: stopped (T) processes ignore SIGTERM, so use SIGKILL.
|
|
||||||
await runExec("kill", ["-9", ...suspended.map((pid) => String(pid))]);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore errors - best effort cleanup
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export function enqueueCliRun<T>(key: string, task: () => Promise<T>): Promise<T> {
|
export function enqueueCliRun<T>(key: string, task: () => Promise<T>): Promise<T> {
|
||||||
const prior = CLI_RUN_QUEUE.get(key) ?? Promise.resolve();
|
const prior = CLI_RUN_QUEUE.get(key) ?? Promise.resolve();
|
||||||
const chained = prior.catch(() => undefined).then(task);
|
const chained = prior.catch(() => undefined).then(task);
|
||||||
|
|||||||
Loaded 100 of 319 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user