Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f975ebf91b | ||
|
|
f2db894685 | ||
|
|
4f03283126 | ||
|
|
ed7dec0975 | ||
|
|
fbb3da506f | ||
|
|
dfa6c5c2b3 | ||
|
|
028eed5fe8 | ||
|
|
2b16a87f04 | ||
|
|
731049936d | ||
|
|
5a5b058ba0 | ||
|
|
72f28be648 | ||
|
|
ff645524d8 | ||
|
|
c534390bc0 | ||
|
|
c5003e5441 | ||
|
|
0b3ebb0c63 | ||
|
|
23981496f9 | ||
|
|
f2e425dc2b | ||
|
|
e48d68bbc7 | ||
|
|
842fc8d08b | ||
|
|
54ec14262b | ||
|
|
d0c70178e0 | ||
|
|
3bc9c330eb | ||
|
|
b7fcc8584f | ||
|
|
2b8ce3f06b | ||
|
|
41d44021e7 | ||
|
|
1ab1e312b2 | ||
|
|
fa9aafce83 | ||
|
|
0d5dec4c66 | ||
|
|
b2d5889f6e | ||
|
|
2ee71e4154 | ||
|
|
1656f491fd | ||
|
|
a057b3c9e8 | ||
|
|
236b27cb3a | ||
|
|
1634abf293 | ||
|
|
ca9688b5cc | ||
|
|
e6364d031d | ||
|
|
01c8d099ad | ||
|
|
f6e619f078 | ||
|
|
b2b331230b | ||
|
|
c0c9742e44 | ||
|
|
abcca86e4e | ||
|
|
a5d8f89b53 | ||
|
|
a0d2a7232e | ||
|
|
f449115ec5 | ||
|
|
16bc4cdef3 | ||
|
|
23e4ba845c | ||
|
|
d9f9e93dee | ||
|
|
dae34f3a61 | ||
|
|
af61b353a4 | ||
|
|
29476b222d | ||
|
|
e7c16cc0e6 | ||
|
|
3dddbe1053 | ||
|
|
2dea6bfa7e | ||
|
|
04e3bfed35 | ||
|
|
3e32050601 | ||
|
|
7fb45ed9b8 | ||
|
|
b7ba94f0c1 | ||
|
|
5b827528f8 | ||
|
|
409e33d9c2 | ||
|
|
7c38b535f6 |
No files matched your search
@@ -116,6 +116,14 @@
|
|||||||
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
|
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
|
||||||
- For manual `clawdbot message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
|
- For manual `clawdbot message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
|
||||||
|
|
||||||
|
## NPM + 1Password (publish/verify)
|
||||||
|
- Use the 1password skill; all `op` commands must run inside a fresh tmux session.
|
||||||
|
- Sign in: `eval "$(op signin --account my.1password.com)"` (app unlocked + integration on).
|
||||||
|
- OTP: `op read 'op://Private/Npmjs/one-time password?attribute=otp'`.
|
||||||
|
- Publish: `npm publish --access public --otp="<otp>"` (run from the package dir).
|
||||||
|
- Verify without local npmrc side effects: `npm view <pkg> version --userconfig "$(mktemp)"`.
|
||||||
|
- Kill the tmux session after publish.
|
||||||
|
|
||||||
## Exclamation Mark Escaping Workaround
|
## Exclamation Mark Escaping Workaround
|
||||||
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot message send` with messages containing exclamation marks, use heredoc syntax:
|
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot message send` with messages containing exclamation marks, use heredoc syntax:
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
## 2026.1.15 (unreleased)
|
## 2026.1.15 (unreleased)
|
||||||
|
|
||||||
|
- **BREAKING:** Microsoft Teams is now a plugin; install `@clawdbot/msteams` via `clawdbot plugins install @clawdbot/msteams`.
|
||||||
- CLI: set process titles to `clawdbot-<command>` for clearer process listings.
|
- CLI: set process titles to `clawdbot-<command>` for clearer process listings.
|
||||||
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
|
- 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.
|
- 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.
|
- 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.
|
- TUI: show provider/model labels for the active session and default model.
|
||||||
@@ -14,21 +16,30 @@
|
|||||||
- 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.
|
- 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: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.
|
||||||
|
- Fix: macOS gateway stays attached across restarts (no port flapping) and status probes pick the right auth. (#982) — thanks @wes-davis.
|
||||||
|
- 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.
|
- 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.
|
||||||
|
- Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.
|
||||||
|
- 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
|
||||||
|
|
||||||
@@ -60,6 +71,7 @@
|
|||||||
- 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.
|
||||||
|
|||||||
@@ -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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a>
|
||||||
<a href="https://github.com/search?q=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/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/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/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/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/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/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/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>
|
<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>
|
||||||
</p>
|
</p>
|
||||||
@@ -119,7 +119,7 @@ dependencies {
|
|||||||
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.0.7")
|
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.0.7")
|
||||||
testImplementation("io.kotest:kotest-assertions-core-jvm:6.0.7")
|
testImplementation("io.kotest:kotest-assertions-core-jvm:6.0.7")
|
||||||
testImplementation("org.robolectric:robolectric:4.16")
|
testImplementation("org.robolectric:robolectric:4.16")
|
||||||
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.1")
|
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.2")
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.withType<Test>().configureEach {
|
tasks.withType<Test>().configureEach {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ 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
|
||||||
@@ -160,6 +161,9 @@ 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)
|
||||||
@@ -488,12 +492,17 @@ 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())
|
||||||
@@ -510,6 +519,7 @@ class NodeRuntime(context: Context) {
|
|||||||
session.connect(
|
session.connect(
|
||||||
endpoint = endpoint,
|
endpoint = endpoint,
|
||||||
hello = buildSessionHello(token = authToken),
|
hello = buildSessionHello(token = authToken),
|
||||||
|
tls = tls,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -556,6 +566,41 @@ 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,6 +147,16 @@ 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,6 +143,8 @@ 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(
|
||||||
@@ -155,6 +157,8 @@ class BridgeDiscovery(
|
|||||||
gatewayPort = gatewayPort,
|
gatewayPort = gatewayPort,
|
||||||
bridgePort = bridgePort,
|
bridgePort = bridgePort,
|
||||||
canvasPort = canvasPort,
|
canvasPort = canvasPort,
|
||||||
|
tlsEnabled = tlsEnabled,
|
||||||
|
tlsFingerprintSha256 = tlsFingerprint,
|
||||||
)
|
)
|
||||||
publish()
|
publish()
|
||||||
}
|
}
|
||||||
@@ -209,6 +213,11 @@ 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
|
||||||
@@ -252,6 +261,8 @@ 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(
|
||||||
@@ -264,6 +275,8 @@ class BridgeDiscovery(
|
|||||||
gatewayPort = gatewayPort,
|
gatewayPort = gatewayPort,
|
||||||
bridgePort = bridgePort,
|
bridgePort = bridgePort,
|
||||||
canvasPort = canvasPort,
|
canvasPort = canvasPort,
|
||||||
|
tlsEnabled = tlsEnabled,
|
||||||
|
tlsFingerprintSha256 = tlsFingerprint,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,6 +487,11 @@ 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,6 +10,8 @@ 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 =
|
||||||
@@ -18,6 +20,8 @@ data class BridgeEndpoint(
|
|||||||
name = "$host:$port",
|
name = "$host:$port",
|
||||||
host = host,
|
host = host,
|
||||||
port = port,
|
port = port,
|
||||||
|
tlsEnabled = false,
|
||||||
|
tlsFingerprintSha256 = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+109
-85
@@ -14,7 +14,6 @@ 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 }
|
||||||
@@ -33,95 +32,120 @@ 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(endpoint: BridgeEndpoint, hello: Hello): PairResult =
|
suspend fun pairAndHello(
|
||||||
|
endpoint: BridgeEndpoint,
|
||||||
|
hello: Hello,
|
||||||
|
tls: BridgeTlsParams? = null,
|
||||||
|
onTlsFingerprint: ((String) -> Unit)? = null,
|
||||||
|
): PairResult =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val socket = Socket()
|
if (tls != null) {
|
||||||
socket.tcpNoDelay = true
|
|
||||||
try {
|
|
||||||
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
|
||||||
socket.soTimeout = 60_000
|
|
||||||
|
|
||||||
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
|
||||||
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
|
||||||
|
|
||||||
fun send(line: String) {
|
|
||||||
writer.write(line)
|
|
||||||
writer.write("\n")
|
|
||||||
writer.flush()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendJson(obj: JsonObject) = send(obj.toString())
|
|
||||||
|
|
||||||
sendJson(
|
|
||||||
buildJsonObject {
|
|
||||||
put("type", JsonPrimitive("hello"))
|
|
||||||
put("nodeId", JsonPrimitive(hello.nodeId))
|
|
||||||
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
|
||||||
hello.token?.let { put("token", JsonPrimitive(it)) }
|
|
||||||
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
|
||||||
hello.version?.let { put("version", JsonPrimitive(it)) }
|
|
||||||
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
|
||||||
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
|
||||||
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
|
||||||
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
val firstObj = json.parseToJsonElement(reader.readLine()).asObjectOrNull()
|
|
||||||
?: return@withContext PairResult(ok = false, token = null, error = "unexpected bridge response")
|
|
||||||
when (firstObj["type"].asStringOrNull()) {
|
|
||||||
"hello-ok" -> PairResult(ok = true, token = hello.token)
|
|
||||||
"error" -> {
|
|
||||||
val code = firstObj["code"].asStringOrNull() ?: "UNAVAILABLE"
|
|
||||||
val message = firstObj["message"].asStringOrNull() ?: "pairing required"
|
|
||||||
if (code != "NOT_PAIRED" && code != "UNAUTHORIZED") {
|
|
||||||
return@withContext PairResult(ok = false, token = null, error = "$code: $message")
|
|
||||||
}
|
|
||||||
|
|
||||||
sendJson(
|
|
||||||
buildJsonObject {
|
|
||||||
put("type", JsonPrimitive("pair-request"))
|
|
||||||
put("nodeId", JsonPrimitive(hello.nodeId))
|
|
||||||
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
|
||||||
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
|
||||||
hello.version?.let { put("version", JsonPrimitive(it)) }
|
|
||||||
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
|
||||||
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
|
||||||
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
|
||||||
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
val nextLine = reader.readLine() ?: break
|
|
||||||
val next = json.parseToJsonElement(nextLine).asObjectOrNull() ?: continue
|
|
||||||
when (next["type"].asStringOrNull()) {
|
|
||||||
"pair-ok" -> {
|
|
||||||
val token = next["token"].asStringOrNull()
|
|
||||||
return@withContext PairResult(ok = !token.isNullOrBlank(), token = token)
|
|
||||||
}
|
|
||||||
"error" -> {
|
|
||||||
val c = next["code"].asStringOrNull() ?: "UNAVAILABLE"
|
|
||||||
val m = next["message"].asStringOrNull() ?: "pairing failed"
|
|
||||||
return@withContext PairResult(ok = false, token = null, error = "$c: $m")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PairResult(ok = false, token = null, error = "pairing failed")
|
|
||||||
}
|
|
||||||
else -> PairResult(ok = false, token = null, error = "unexpected bridge response")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
val message = e.message?.trim().orEmpty().ifEmpty { "gateway unreachable" }
|
|
||||||
PairResult(ok = false, token = null, error = message)
|
|
||||||
} finally {
|
|
||||||
try {
|
try {
|
||||||
socket.close()
|
return@withContext pairAndHelloWithTls(endpoint, hello, tls, onTlsFingerprint)
|
||||||
} catch (_: Throwable) {
|
} catch (e: Exception) {
|
||||||
// ignore
|
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
|
||||||
|
try {
|
||||||
|
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
||||||
|
socket.soTimeout = 60_000
|
||||||
|
startTlsHandshakeIfNeeded(socket)
|
||||||
|
|
||||||
|
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
||||||
|
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
||||||
|
|
||||||
|
fun send(line: String) {
|
||||||
|
writer.write(line)
|
||||||
|
writer.write("\n")
|
||||||
|
writer.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendJson(obj: JsonObject) = send(obj.toString())
|
||||||
|
|
||||||
|
sendJson(
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("hello"))
|
||||||
|
put("nodeId", JsonPrimitive(hello.nodeId))
|
||||||
|
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
||||||
|
hello.token?.let { put("token", JsonPrimitive(it)) }
|
||||||
|
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
||||||
|
hello.version?.let { put("version", JsonPrimitive(it)) }
|
||||||
|
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
||||||
|
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||||
|
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
||||||
|
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
val firstObj = json.parseToJsonElement(reader.readLine()).asObjectOrNull()
|
||||||
|
?: return PairResult(ok = false, token = null, error = "unexpected bridge response")
|
||||||
|
return when (firstObj["type"].asStringOrNull()) {
|
||||||
|
"hello-ok" -> PairResult(ok = true, token = hello.token)
|
||||||
|
"error" -> {
|
||||||
|
val code = firstObj["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||||
|
val message = firstObj["message"].asStringOrNull() ?: "pairing required"
|
||||||
|
if (code != "NOT_PAIRED" && code != "UNAUTHORIZED") {
|
||||||
|
return PairResult(ok = false, token = null, error = "$code: $message")
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson(
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("pair-request"))
|
||||||
|
put("nodeId", JsonPrimitive(hello.nodeId))
|
||||||
|
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
||||||
|
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
||||||
|
hello.version?.let { put("version", JsonPrimitive(it)) }
|
||||||
|
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
||||||
|
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||||
|
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
||||||
|
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
val nextLine = reader.readLine() ?: break
|
||||||
|
val next = json.parseToJsonElement(nextLine).asObjectOrNull() ?: continue
|
||||||
|
when (next["type"].asStringOrNull()) {
|
||||||
|
"pair-ok" -> {
|
||||||
|
val token = next["token"].asStringOrNull()
|
||||||
|
return PairResult(ok = !token.isNullOrBlank(), token = token)
|
||||||
|
}
|
||||||
|
"error" -> {
|
||||||
|
val c = next["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||||
|
val m = next["message"].asStringOrNull() ?: "pairing failed"
|
||||||
|
return PairResult(ok = false, token = null, error = "$c: $m")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PairResult(ok = false, token = null, error = "pairing failed")
|
||||||
|
}
|
||||||
|
else -> PairResult(ok = false, token = null, error = "unexpected bridge response")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
val message = e.message?.trim().orEmpty().ifEmpty { "gateway unreachable" }
|
||||||
|
return PairResult(ok = false, token = null, error = message)
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
socket.close()
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ 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,
|
||||||
@@ -66,11 +67,17 @@ 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 var desired: Pair<BridgeEndpoint, Hello>? = null
|
private data class DesiredConnection(
|
||||||
|
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) {
|
fun connect(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams? = null) {
|
||||||
desired = endpoint to hello
|
desired = DesiredConnection(endpoint, hello, tls)
|
||||||
if (job == null) {
|
if (job == null) {
|
||||||
job = scope.launch(Dispatchers.IO) { runLoop() }
|
job = scope.launch(Dispatchers.IO) { runLoop() }
|
||||||
}
|
}
|
||||||
@@ -78,7 +85,7 @@ class BridgeSession(
|
|||||||
|
|
||||||
suspend fun updateHello(hello: Hello) {
|
suspend fun updateHello(hello: Hello) {
|
||||||
val target = desired ?: return
|
val target = desired ?: return
|
||||||
desired = target.first to hello
|
desired = target.copy(hello = hello)
|
||||||
val conn = currentConnection ?: return
|
val conn = currentConnection ?: return
|
||||||
conn.sendJson(buildHelloJson(hello))
|
conn.sendJson(buildHelloJson(hello))
|
||||||
}
|
}
|
||||||
@@ -165,10 +172,10 @@ class BridgeSession(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
val (endpoint, hello) = target
|
val (endpoint, hello, tls) = target
|
||||||
try {
|
try {
|
||||||
onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…")
|
onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…")
|
||||||
connectOnce(endpoint, hello)
|
connectOnce(endpoint, hello, tls)
|
||||||
attempt = 0
|
attempt = 0
|
||||||
} catch (err: Throwable) {
|
} catch (err: Throwable) {
|
||||||
attempt += 1
|
attempt += 1
|
||||||
@@ -192,60 +199,76 @@ class BridgeSession(
|
|||||||
return InvokeResult.error(code = "UNAVAILABLE", message = msg)
|
return InvokeResult.error(code = "UNAVAILABLE", message = msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun connectOnce(endpoint: BridgeEndpoint, hello: Hello) =
|
private suspend fun connectOnce(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams?) =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val socket = Socket()
|
if (tls != null) {
|
||||||
socket.tcpNoDelay = true
|
try {
|
||||||
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
connectWithSocket(endpoint, hello, tls)
|
||||||
socket.soTimeout = 0
|
return@withContext
|
||||||
|
} catch (err: Throwable) {
|
||||||
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
if (tls.required) throw err
|
||||||
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
|
||||||
|
|
||||||
val conn = Connection(socket, reader, writer, writeLock)
|
|
||||||
currentConnection = conn
|
|
||||||
|
|
||||||
try {
|
|
||||||
conn.sendJson(buildHelloJson(hello))
|
|
||||||
|
|
||||||
val firstLine = reader.readLine() ?: throw IllegalStateException("bridge closed connection")
|
|
||||||
val first = json.parseToJsonElement(firstLine).asObjectOrNull()
|
|
||||||
?: throw IllegalStateException("unexpected bridge response")
|
|
||||||
when (first["type"].asStringOrNull()) {
|
|
||||||
"hello-ok" -> {
|
|
||||||
val name = first["serverName"].asStringOrNull() ?: "Bridge"
|
|
||||||
val rawCanvasUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null }
|
|
||||||
val rawMainSessionKey = first["mainSessionKey"].asStringOrNull()?.trim()?.ifEmpty { null }
|
|
||||||
canvasHostUrl = normalizeCanvasHostUrl(rawCanvasUrl, endpoint)
|
|
||||||
mainSessionKey = rawMainSessionKey
|
|
||||||
if (BuildConfig.DEBUG) {
|
|
||||||
// Local JVM unit tests use android.jar stubs; Log.d can throw "not mocked".
|
|
||||||
runCatching {
|
|
||||||
android.util.Log.d(
|
|
||||||
"ClawdbotBridge",
|
|
||||||
"canvasHostUrl resolved=${canvasHostUrl ?: "none"} (raw=${rawCanvasUrl ?: "none"})",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onConnected(name, conn.remoteAddress, rawMainSessionKey)
|
|
||||||
}
|
|
||||||
"error" -> {
|
|
||||||
val code = first["code"].asStringOrNull() ?: "UNAVAILABLE"
|
|
||||||
val msg = first["message"].asStringOrNull() ?: "connect failed"
|
|
||||||
throw IllegalStateException("$code: $msg")
|
|
||||||
}
|
|
||||||
else -> throw IllegalStateException("unexpected bridge response")
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
connectWithSocket(endpoint, hello, null)
|
||||||
|
}
|
||||||
|
|
||||||
while (scope.isActive) {
|
private suspend fun connectWithSocket(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams?) {
|
||||||
val line = reader.readLine() ?: break
|
val socket =
|
||||||
val frame = json.parseToJsonElement(line).asObjectOrNull() ?: continue
|
createBridgeSocket(tls) { fingerprint ->
|
||||||
when (frame["type"].asStringOrNull()) {
|
onTlsFingerprint?.invoke(tls?.stableId ?: endpoint.stableId, fingerprint)
|
||||||
"event" -> {
|
}
|
||||||
val event = frame["event"].asStringOrNull() ?: return@withContext
|
socket.tcpNoDelay = true
|
||||||
val payload = frame["payloadJSON"].asStringOrNull()
|
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
||||||
onEvent(event, payload)
|
socket.soTimeout = 0
|
||||||
|
startTlsHandshakeIfNeeded(socket)
|
||||||
|
|
||||||
|
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
||||||
|
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
||||||
|
|
||||||
|
val conn = Connection(socket, reader, writer, writeLock)
|
||||||
|
currentConnection = conn
|
||||||
|
|
||||||
|
try {
|
||||||
|
conn.sendJson(buildHelloJson(hello))
|
||||||
|
|
||||||
|
val firstLine = reader.readLine() ?: throw IllegalStateException("bridge closed connection")
|
||||||
|
val first = json.parseToJsonElement(firstLine).asObjectOrNull()
|
||||||
|
?: throw IllegalStateException("unexpected bridge response")
|
||||||
|
when (first["type"].asStringOrNull()) {
|
||||||
|
"hello-ok" -> {
|
||||||
|
val name = first["serverName"].asStringOrNull() ?: "Bridge"
|
||||||
|
val rawCanvasUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null }
|
||||||
|
val rawMainSessionKey = first["mainSessionKey"].asStringOrNull()?.trim()?.ifEmpty { null }
|
||||||
|
canvasHostUrl = normalizeCanvasHostUrl(rawCanvasUrl, endpoint)
|
||||||
|
mainSessionKey = rawMainSessionKey
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
// Local JVM unit tests use android.jar stubs; Log.d can throw "not mocked".
|
||||||
|
runCatching {
|
||||||
|
android.util.Log.d(
|
||||||
|
"ClawdbotBridge",
|
||||||
|
"canvasHostUrl resolved=${canvasHostUrl ?: "none"} (raw=${rawCanvasUrl ?: "none"})",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
onConnected(name, conn.remoteAddress, rawMainSessionKey)
|
||||||
|
}
|
||||||
|
"error" -> {
|
||||||
|
val code = first["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||||
|
val msg = first["message"].asStringOrNull() ?: "connect failed"
|
||||||
|
throw IllegalStateException("$code: $msg")
|
||||||
|
}
|
||||||
|
else -> throw IllegalStateException("unexpected bridge response")
|
||||||
|
}
|
||||||
|
|
||||||
|
while (scope.isActive) {
|
||||||
|
val line = reader.readLine() ?: break
|
||||||
|
val frame = json.parseToJsonElement(line).asObjectOrNull() ?: continue
|
||||||
|
when (frame["type"].asStringOrNull()) {
|
||||||
|
"event" -> {
|
||||||
|
val event = frame["event"].asStringOrNull() ?: continue
|
||||||
|
val payload = frame["payloadJSON"].asStringOrNull()
|
||||||
|
onEvent(event, payload)
|
||||||
|
}
|
||||||
"ping" -> {
|
"ping" -> {
|
||||||
val id = frame["id"].asStringOrNull() ?: ""
|
val id = frame["id"].asStringOrNull() ?: ""
|
||||||
conn.sendJson(buildJsonObject { put("type", JsonPrimitive("pong")); put("id", JsonPrimitive(id)) })
|
conn.sendJson(buildJsonObject { put("type", JsonPrimitive("pong")); put("id", JsonPrimitive(id)) })
|
||||||
@@ -291,20 +314,20 @@ class BridgeSession(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
"invoke-res" -> {
|
"invoke-res" -> {
|
||||||
// gateway->node only (ignore)
|
// gateway->node only (ignore)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
currentConnection = null
|
|
||||||
for ((_, waiter) in pending) {
|
|
||||||
waiter.cancel()
|
|
||||||
}
|
|
||||||
pending.clear()
|
|
||||||
conn.closeQuietly()
|
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
currentConnection = null
|
||||||
|
for ((_, waiter) in pending) {
|
||||||
|
waiter.cancel()
|
||||||
|
}
|
||||||
|
pending.clear()
|
||||||
|
conn.closeQuietly()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun buildHelloJson(hello: Hello): JsonObject =
|
private fun buildHelloJson(hello: Hello): JsonObject =
|
||||||
buildJsonObject {
|
buildJsonObject {
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package com.clawdbot.android.bridge
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
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()
|
||||||
|
@SuppressLint("CustomX509TrustManager")
|
||||||
|
val trustManager =
|
||||||
|
object : X509TrustManager {
|
||||||
|
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {
|
||||||
|
defaultTrust.checkClientTrusted(chain, authType)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
|
||||||
|
if (chain.isEmpty()) throw CertificateException("empty certificate chain")
|
||||||
|
val fingerprint = sha256Hex(chain[0].encoded)
|
||||||
|
if (expected != null) {
|
||||||
|
if (fingerprint != expected) {
|
||||||
|
throw CertificateException("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,10 +10,36 @@ 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 connection = NWConnection(to: endpoint, using: .tcp)
|
let params = self.makeParameters(tls: tls)
|
||||||
|
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") {
|
||||||
@@ -142,6 +168,18 @@ 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,6 +10,7 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,9 +116,12 @@ 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: BridgeEndpointID.stableID(endpoint),
|
bridgeStableID: stableID,
|
||||||
|
tls: tlsParams,
|
||||||
token: token,
|
token: token,
|
||||||
instanceId: instanceId)
|
instanceId: instanceId)
|
||||||
return
|
return
|
||||||
@@ -135,10 +139,12 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -182,6 +188,7 @@ 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)
|
||||||
{
|
{
|
||||||
@@ -193,6 +200,7 @@ 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
|
||||||
@@ -208,6 +216,7 @@ 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 {
|
||||||
@@ -217,6 +226,47 @@ 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,6 +23,8 @@ 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?
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +92,8 @@ 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
|
||||||
@@ -214,4 +218,9 @@ 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,15 +69,42 @@ 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let params = NWParameters.tcp
|
private func connectOnce(
|
||||||
params.includePeerToPeer = true
|
endpoint: NWEndpoint,
|
||||||
|
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
|
||||||
@@ -255,6 +282,18 @@ 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: [
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
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
|
||||||
|
let trustRef = sec_trust_copy_ref(trust).takeRetainedValue()
|
||||||
|
if let chain = SecTrustCopyCertificateChain(trustRef) as? [SecCertificate],
|
||||||
|
let cert = chain.first
|
||||||
|
{
|
||||||
|
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(trustRef, 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 }
|
||||||
|
}
|
||||||
@@ -190,14 +190,7 @@ actor CameraController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func listDevices() -> [CameraDeviceInfo] {
|
func listDevices() -> [CameraDeviceInfo] {
|
||||||
let types: [AVCaptureDevice.DeviceType] = [
|
return Self.discoverVideoDevices().map { device in
|
||||||
.builtInWideAngleCamera,
|
|
||||||
]
|
|
||||||
let session = AVCaptureDevice.DiscoverySession(
|
|
||||||
deviceTypes: types,
|
|
||||||
mediaType: .video,
|
|
||||||
position: .unspecified)
|
|
||||||
return session.devices.map { device in
|
|
||||||
CameraDeviceInfo(
|
CameraDeviceInfo(
|
||||||
id: device.uniqueID,
|
id: device.uniqueID,
|
||||||
name: device.localizedName,
|
name: device.localizedName,
|
||||||
@@ -232,7 +225,7 @@ actor CameraController {
|
|||||||
deviceId: String?) -> AVCaptureDevice?
|
deviceId: String?) -> AVCaptureDevice?
|
||||||
{
|
{
|
||||||
if let deviceId, !deviceId.isEmpty {
|
if let deviceId, !deviceId.isEmpty {
|
||||||
if let match = AVCaptureDevice.devices(for: .video).first(where: { $0.uniqueID == deviceId }) {
|
if let match = Self.discoverVideoDevices().first(where: { $0.uniqueID == deviceId }) {
|
||||||
return match
|
return match
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -252,6 +245,24 @@ actor CameraController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private nonisolated static func discoverVideoDevices() -> [AVCaptureDevice] {
|
||||||
|
let types: [AVCaptureDevice.DeviceType] = [
|
||||||
|
.builtInWideAngleCamera,
|
||||||
|
.builtInUltraWideCamera,
|
||||||
|
.builtInTelephotoCamera,
|
||||||
|
.builtInDualCamera,
|
||||||
|
.builtInDualWideCamera,
|
||||||
|
.builtInTripleCamera,
|
||||||
|
.builtInTrueDepthCamera,
|
||||||
|
.builtInLiDARDepthCamera,
|
||||||
|
]
|
||||||
|
let session = AVCaptureDevice.DiscoverySession(
|
||||||
|
deviceTypes: types,
|
||||||
|
mediaType: .video,
|
||||||
|
position: .unspecified)
|
||||||
|
return session.devices
|
||||||
|
}
|
||||||
|
|
||||||
nonisolated static func clampQuality(_ quality: Double?) -> Double {
|
nonisolated static func clampQuality(_ quality: Double?) -> Double {
|
||||||
let q = quality ?? 0.9
|
let q = quality ?? 0.9
|
||||||
return min(1.0, max(0.05, q))
|
return min(1.0, max(0.05, q))
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ 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()
|
||||||
@@ -232,6 +233,7 @@ 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 {
|
||||||
|
|||||||
@@ -137,9 +137,11 @@ final class ScreenRecordService: @unchecked Sendable {
|
|||||||
recordQueue: DispatchQueue) -> @Sendable (CMSampleBuffer, RPSampleBufferType, Error?) -> Void
|
recordQueue: DispatchQueue) -> @Sendable (CMSampleBuffer, RPSampleBufferType, Error?) -> Void
|
||||||
{
|
{
|
||||||
{ sample, type, error in
|
{ sample, type, error in
|
||||||
|
let sampleBox = UncheckedSendableBox(value: sample)
|
||||||
// ReplayKit can call the capture handler on a background queue.
|
// ReplayKit can call the capture handler on a background queue.
|
||||||
// Serialize writes to avoid queue asserts.
|
// Serialize writes to avoid queue asserts.
|
||||||
recordQueue.async {
|
recordQueue.async {
|
||||||
|
let sample = sampleBox.value
|
||||||
if let error {
|
if let error {
|
||||||
state.withLock { state in
|
state.withLock { state in
|
||||||
if state.handlerError == nil { state.handlerError = error }
|
if state.handlerError == nil { state.handlerError = error }
|
||||||
|
|||||||
@@ -407,9 +407,11 @@ 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
|
||||||
@@ -426,6 +428,7 @@ 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,
|
||||||
@@ -462,6 +465,8 @@ 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
|
||||||
@@ -485,6 +490,7 @@ 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
|
||||||
@@ -500,7 +506,8 @@ struct SettingsTab: View {
|
|||||||
|
|
||||||
self.appModel.connectToBridge(
|
self.appModel.connectToBridge(
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
bridgeStableID: BridgeEndpointID.stableID(endpoint),
|
bridgeStableID: stableID,
|
||||||
|
tls: tlsParams,
|
||||||
hello: BridgeHello(
|
hello: BridgeHello(
|
||||||
nodeId: self.instanceId,
|
nodeId: self.instanceId,
|
||||||
displayName: self.displayName,
|
displayName: self.displayName,
|
||||||
@@ -517,6 +524,47 @@ 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,6 +27,7 @@ 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
|
||||||
@@ -244,6 +245,8 @@ 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"
|
||||||
@@ -292,6 +295,8 @@ 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",
|
||||||
@@ -303,6 +308,8 @@ 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,6 +170,10 @@ 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
|
||||||
|
|
||||||
@@ -292,6 +296,7 @@ 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,6 +26,9 @@ 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"
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ enum GatewayEnvironment {
|
|||||||
let port = self.gatewayPort()
|
let port = self.gatewayPort()
|
||||||
if let gatewayBin {
|
if let gatewayBin {
|
||||||
let bind = self.preferredGatewayBind() ?? "loopback"
|
let bind = self.preferredGatewayBind() ?? "loopback"
|
||||||
let cmd = [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind]
|
let cmd = [gatewayBin, "gateway", "--port", "\(port)", "--bind", bind]
|
||||||
return GatewayCommandResolution(status: status, command: cmd)
|
return GatewayCommandResolution(status: status, command: cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,7 +186,7 @@ enum GatewayEnvironment {
|
|||||||
case let .success(resolvedRuntime) = runtime
|
case let .success(resolvedRuntime) = runtime
|
||||||
{
|
{
|
||||||
let bind = self.preferredGatewayBind() ?? "loopback"
|
let bind = self.preferredGatewayBind() ?? "loopback"
|
||||||
let cmd = [resolvedRuntime.path, entry, "gateway-daemon", "--port", "\(port)", "--bind", bind]
|
let cmd = [resolvedRuntime.path, entry, "gateway", "--port", "\(port)", "--bind", bind]
|
||||||
return GatewayCommandResolution(status: status, command: cmd)
|
return GatewayCommandResolution(status: status, command: cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,77 +2,16 @@ 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 FileManager.default.fileExists(atPath: self.plistURL.path) else { return false }
|
guard let loaded = await self.readDaemonLoaded() else { return false }
|
||||||
let result = await Launchctl.run(["print", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
return loaded
|
||||||
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? {
|
||||||
@@ -81,255 +20,44 @@ 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 {
|
||||||
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"])
|
self.logger.info("launchd enable requested via CLI port=\(port)")
|
||||||
try? FileManager.default.removeItem(at: self.legacyPlistURL)
|
return await self.runDaemonCommand([
|
||||||
|
"install",
|
||||||
let desiredBind = self.preferredGatewayBind() ?? "loopback"
|
"--force",
|
||||||
let desiredToken = self.preferredGatewayToken()
|
"--port",
|
||||||
let desiredPassword = self.preferredGatewayPassword()
|
"\(port)",
|
||||||
let desiredConfig = DesiredConfig(
|
"--runtime",
|
||||||
port: port,
|
"node",
|
||||||
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."
|
|
||||||
}
|
|
||||||
|
|
||||||
// If launchd already loaded the job (common on login), avoid `bootout` unless we must
|
|
||||||
// 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")
|
self.logger.info("launchd disable requested via CLI")
|
||||||
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
return await self.runDaemonCommand(["uninstall"])
|
||||||
await self.ensureDisabled()
|
|
||||||
try? FileManager.default.removeItem(at: self.plistURL)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func kickstart() async {
|
static func kickstart() async {
|
||||||
_ = await Launchctl.run(["kickstart", "-k", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
_ = await self.runDaemonCommand(["restart"], timeout: 20)
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func ensureEnabled() async {
|
static func launchdGatewayLogPath() -> String {
|
||||||
let result = await Launchctl.run(["enable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
let snapshot = self.launchdConfigSnapshot()
|
||||||
guard result.status != 0 else { return }
|
if let stdout = snapshot?.stdoutPath?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
!stdout.isEmpty
|
||||||
if msg.isEmpty {
|
{
|
||||||
self.logger.warning("launchd enable failed")
|
return stdout
|
||||||
} else {
|
|
||||||
self.logger.warning("launchd enable failed: \(msg)")
|
|
||||||
}
|
}
|
||||||
}
|
if let stderr = snapshot?.stderrPath?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!stderr.isEmpty
|
||||||
private static func ensureDisabled() async {
|
{
|
||||||
let result = await Launchctl.run(["disable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
return stderr
|
||||||
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)")
|
|
||||||
}
|
}
|
||||||
|
return LogLocator.launchdGatewayLogPath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,20 +67,102 @@ extension GatewayLaunchAgentManager {
|
|||||||
.appendingPathComponent(self.disableLaunchAgentMarker)
|
.appendingPathComponent(self.disableLaunchAgentMarker)
|
||||||
return FileManager.default.fileExists(atPath: marker.path)
|
return FileManager.default.fileExists(atPath: marker.path)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
#if DEBUG
|
private static func readDaemonLoaded() async -> Bool? {
|
||||||
extension GatewayLaunchAgentManager {
|
let result = await self.runDaemonCommandResult(
|
||||||
static func _testPreferredGatewayBind() -> String? {
|
["status", "--json", "--no-probe"],
|
||||||
self.preferredGatewayBind()
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
static func _testPreferredGatewayToken() -> String? {
|
private struct CommandResult {
|
||||||
self.preferredGatewayToken()
|
let success: Bool
|
||||||
|
let payload: Data?
|
||||||
|
let message: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
static func _testEscapePlistValue(_ raw: String) -> String {
|
private struct ParsedDaemonJson {
|
||||||
self.escapePlistValue(raw)
|
let text: String
|
||||||
|
let object: [String: Any]
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func runDaemonCommand(
|
||||||
|
_ args: [String],
|
||||||
|
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
|
|
||||||
@@ -87,6 +87,14 @@ final class GatewayProcessManager {
|
|||||||
self.status = .stopped
|
self.status = .stopped
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Many surfaces can call `setActive(true)` in quick succession (startup, Canvas, health checks).
|
||||||
|
// Avoid spawning multiple concurrent "start" tasks that can thrash launchd and flap the port.
|
||||||
|
switch self.status {
|
||||||
|
case .starting, .running, .attachedExisting:
|
||||||
|
return
|
||||||
|
case .stopped, .failed:
|
||||||
|
break
|
||||||
|
}
|
||||||
self.status = .starting
|
self.status = .starting
|
||||||
self.logger.debug("gateway start requested")
|
self.logger.debug("gateway start requested")
|
||||||
|
|
||||||
@@ -140,7 +148,7 @@ final class GatewayProcessManager {
|
|||||||
|
|
||||||
func refreshLog() {
|
func refreshLog() {
|
||||||
guard self.logRefreshTask == nil else { return }
|
guard self.logRefreshTask == nil else { return }
|
||||||
let path = LogLocator.launchdGatewayLogPath
|
let path = GatewayLaunchAgentManager.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 +362,7 @@ final class GatewayProcessManager {
|
|||||||
|
|
||||||
func clearLog() {
|
func clearLog() {
|
||||||
self.log = ""
|
self.log = ""
|
||||||
try? FileManager.default.removeItem(atPath: LogLocator.launchdGatewayLogPath)
|
try? FileManager.default.removeItem(atPath: GatewayLaunchAgentManager.launchdGatewayLogPath())
|
||||||
self.logger.debug("gateway log cleared")
|
self.logger.debug("gateway log cleared")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,28 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -92,7 +114,8 @@ struct GeneralSettings: View {
|
|||||||
Text("While Using").tag(ClawdbotLocationMode.whileUsing.rawValue)
|
Text("While Using").tag(ClawdbotLocationMode.whileUsing.rawValue)
|
||||||
Text("Always").tag(ClawdbotLocationMode.always.rawValue)
|
Text("Always").tag(ClawdbotLocationMode.always.rawValue)
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.labelsHidden()
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
|
||||||
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
|
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
|
||||||
.disabled(self.locationMode == .off)
|
.disabled(self.locationMode == .off)
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ 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?
|
||||||
@@ -53,6 +55,10 @@ 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
|
||||||
@@ -60,6 +66,8 @@ 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,
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
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,6 +31,12 @@ 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) {
|
||||||
@@ -68,6 +74,13 @@ 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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,39 @@ 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 connection = NWConnection(to: endpoint, using: .tcp)
|
let params = self.makeParameters(tls: tls)
|
||||||
|
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(
|
||||||
@@ -164,6 +193,18 @@ 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,6 +36,7 @@ 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)
|
||||||
@@ -44,15 +45,35 @@ 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let params = NWParameters.tcp
|
private func connectOnce(
|
||||||
params.includePeerToPeer = true
|
endpoint: NWEndpoint,
|
||||||
let tcpOptions = NWProtocolTCP.Options()
|
hello: BridgeHello,
|
||||||
tcpOptions.enableKeepalive = true
|
tls: MacNodeBridgeTLSParams?,
|
||||||
tcpOptions.keepaliveIdle = 30
|
onConnected: (@Sendable (String, String?) async -> Void)? = nil,
|
||||||
tcpOptions.keepaliveInterval = 15
|
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async throws
|
||||||
tcpOptions.keepaliveCount = 3
|
{
|
||||||
params.defaultProtocolStack.transportProtocol = tcpOptions
|
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.macos.bridge-session")
|
let queue = DispatchQueue(label: "com.clawdbot.macos.bridge-session")
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
@@ -262,6 +283,25 @@ 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)
|
||||||
@@ -420,7 +460,7 @@ actor MacNodeBridgeSession {
|
|||||||
do {
|
do {
|
||||||
try await self.send(response)
|
try await self.send(response)
|
||||||
} catch {
|
} catch {
|
||||||
await self.logInvokeSendFailure(error)
|
self.logInvokeSendFailure(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
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 = self.keyPrefix + stableID
|
||||||
|
let raw = self.defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return raw?.isEmpty == false ? raw : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveFingerprint(_ value: String, stableID: String) {
|
||||||
|
let key = self.keyPrefix + stableID
|
||||||
|
self.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
|
||||||
|
let trustRef = sec_trust_copy_ref(trust).takeRetainedValue()
|
||||||
|
if let chain = SecTrustCopyCertificateChain(trustRef) as? [SecCertificate],
|
||||||
|
let cert = chain.first
|
||||||
|
{
|
||||||
|
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(trustRef, 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(\.isHexDigit)
|
||||||
|
}
|
||||||
@@ -4,6 +4,12 @@ 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()
|
||||||
@@ -37,6 +43,7 @@ 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 }) {
|
||||||
@@ -53,7 +60,16 @@ final class MacNodeModeCoordinator {
|
|||||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let endpoint = await self.resolveBridgeEndpoint(timeoutSeconds: 5) else {
|
let systemRunPolicy = SystemRunPolicy.load()
|
||||||
|
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
|
||||||
@@ -63,10 +79,11 @@ 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=\(endpoint, privacy: .public)")
|
"mac node bridge connecting endpoint=\(target.endpoint, privacy: .public)")
|
||||||
try await self.session.connect(
|
try await self.session.connect(
|
||||||
endpoint: endpoint,
|
endpoint: target.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 {
|
||||||
@@ -86,7 +103,7 @@ final class MacNodeModeCoordinator {
|
|||||||
return await self.runtime.handleInvoke(req)
|
return await self.runtime.handleInvoke(req)
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
if await self.tryPair(endpoint: endpoint, error: error) {
|
if await self.tryPair(target: target, error: error) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
@@ -143,10 +160,13 @@ 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)
|
||||||
@@ -160,7 +180,7 @@ final class MacNodeModeCoordinator {
|
|||||||
return commands
|
return commands
|
||||||
}
|
}
|
||||||
|
|
||||||
private func tryPair(endpoint: NWEndpoint, error: Error) async -> Bool {
|
private func tryPair(target: BridgeTarget, 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 }
|
||||||
|
|
||||||
@@ -170,9 +190,10 @@ 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: endpoint,
|
endpoint: target.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)")
|
||||||
})
|
})
|
||||||
@@ -190,7 +211,7 @@ final class MacNodeModeCoordinator {
|
|||||||
"mac-\(InstanceIdentity.instanceId)"
|
"mac-\(InstanceIdentity.instanceId)"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveLoopbackBridgeEndpoint(timeoutSeconds: Double) async -> NWEndpoint? {
|
private func resolveLoopbackBridgeEndpoint(timeoutSeconds: Double) async -> BridgeTarget? {
|
||||||
guard let port = Self.loopbackBridgePort(),
|
guard let port = Self.loopbackBridgePort(),
|
||||||
let endpointPort = NWEndpoint.Port(rawValue: port)
|
let endpointPort = NWEndpoint.Port(rawValue: port)
|
||||||
else {
|
else {
|
||||||
@@ -198,7 +219,10 @@ 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)
|
||||||
return reachable ? endpoint : nil
|
guard reachable else { return 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? {
|
||||||
@@ -291,7 +315,7 @@ final class MacNodeModeCoordinator {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveBridgeEndpoint(timeoutSeconds: Double) async -> NWEndpoint? {
|
private func resolveBridgeEndpoint(timeoutSeconds: Double) async -> BridgeTarget? {
|
||||||
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 {
|
||||||
@@ -303,7 +327,10 @@ 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)")
|
||||||
return .hostPort(host: "127.0.0.1", port: port)
|
let endpoint = NWEndpoint.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")
|
||||||
@@ -336,7 +363,10 @@ 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)")
|
||||||
return .hostPort(host: "127.0.0.1", port: port)
|
let endpoint = NWEndpoint.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)")
|
||||||
@@ -347,8 +377,8 @@ final class MacNodeModeCoordinator {
|
|||||||
tunnel.terminate()
|
tunnel.terminate()
|
||||||
self.tunnel = nil
|
self.tunnel = nil
|
||||||
}
|
}
|
||||||
if mode == .local, let endpoint = await self.resolveLoopbackBridgeEndpoint(timeoutSeconds: 0.4) {
|
if mode == .local, let target = await self.resolveLoopbackBridgeEndpoint(timeoutSeconds: 0.4) {
|
||||||
return endpoint
|
return target
|
||||||
}
|
}
|
||||||
return await Self.discoverBridgeEndpoint(timeoutSeconds: timeoutSeconds)
|
return await Self.discoverBridgeEndpoint(timeoutSeconds: timeoutSeconds)
|
||||||
}
|
}
|
||||||
@@ -368,14 +398,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 -> NWEndpoint? {
|
private static func discoverBridgeEndpoint(timeoutSeconds: Double) async -> BridgeTarget? {
|
||||||
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<NWEndpoint?, Never>?
|
var continuation: CheckedContinuation<BridgeTarget?, Never>?
|
||||||
|
|
||||||
func finish(_ endpoint: NWEndpoint?) {
|
func finish(_ target: BridgeTarget?) {
|
||||||
self.lock.lock()
|
self.lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
if self.resolved { return }
|
if self.resolved { return }
|
||||||
@@ -383,7 +413,7 @@ final class MacNodeModeCoordinator {
|
|||||||
for browser in self.browsers {
|
for browser in self.browsers {
|
||||||
browser.cancel()
|
browser.cancel()
|
||||||
}
|
}
|
||||||
self.continuation?.resume(returning: endpoint)
|
self.continuation?.resume(returning: target)
|
||||||
self.continuation = nil
|
self.continuation = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -409,12 +439,12 @@ final class MacNodeModeCoordinator {
|
|||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
state.finish(match.endpoint)
|
state.finish(Self.targetFromResult(match))
|
||||||
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(result.endpoint)
|
state.finish(Self.targetFromResult(result))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
browser.stateUpdateHandler = { browserState in
|
browser.stateUpdateHandler = { browserState in
|
||||||
@@ -432,6 +462,72 @@ final class MacNodeModeCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private nonisolated 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 nonisolated 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 nonisolated 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 nonisolated static func txtValue(_ dict: [String: String], key: String) -> String? {
|
||||||
|
let raw = dict[key]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
return raw.isEmpty ? nil : raw
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated 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,6 +426,37 @@ 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
|
||||||
@@ -441,7 +472,7 @@ actor MacNodeRuntime {
|
|||||||
let result = await ShellExecutor.runDetailed(
|
let result = await ShellExecutor.runDetailed(
|
||||||
command: command,
|
command: command,
|
||||||
cwd: params.cwd,
|
cwd: params.cwd,
|
||||||
env: params.env,
|
env: env,
|
||||||
timeout: timeoutSec)
|
timeout: timeoutSec)
|
||||||
|
|
||||||
struct RunPayload: Encodable {
|
struct RunPayload: Encodable {
|
||||||
@@ -529,6 +560,39 @@ 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 self.blockedEnvKeys.contains(upper) { continue }
|
||||||
|
if self.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,7 +1,14 @@
|
|||||||
|
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(
|
||||||
@@ -17,6 +24,8 @@ 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
|
||||||
@@ -57,4 +66,31 @@ 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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -351,10 +351,14 @@ actor PortGuardian {
|
|||||||
if port == GatewayEnvironment.gatewayPort() { return cmd.contains("ssh") }
|
if port == GatewayEnvironment.gatewayPort() { return cmd.contains("ssh") }
|
||||||
return false
|
return false
|
||||||
case .local:
|
case .local:
|
||||||
if !cmd.contains("clawdbot") { return false }
|
// The gateway may listen as `clawdbot` or via its runtime (e.g. node).
|
||||||
if full.contains("gateway-daemon") { return true }
|
let hasGatewayArgs =
|
||||||
|
full.contains(" gateway ") &&
|
||||||
|
full.contains(" --port \(port)")
|
||||||
|
if hasGatewayArgs { return true }
|
||||||
// If args are unavailable, treat a clawdbot listener as expected.
|
// If args are unavailable, treat a clawdbot listener as expected.
|
||||||
return full == cmd
|
if cmd.contains("clawdbot"), full == cmd { return true }
|
||||||
|
return false
|
||||||
case .unconfigured:
|
case .unconfigured:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
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:
|
||||||
|
"Never"
|
||||||
|
case .ask:
|
||||||
|
"Always Ask"
|
||||||
|
case .always:
|
||||||
|
"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 self.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 = self.load(from: defaults)
|
||||||
|
if allowlist.insert(key).inserted {
|
||||||
|
MacNodeConfigFile.setSystemRunAllowlist(Array(allowlist).sorted())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1679,6 +1679,27 @@ 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,6 +7,8 @@ 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",
|
||||||
@@ -31,6 +33,8 @@ 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",
|
||||||
@@ -48,6 +52,8 @@ 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,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import Testing
|
|||||||
let url = FileManager.default.temporaryDirectory
|
let url = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
|
.appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
|
||||||
let plist: [String: Any] = [
|
let plist: [String: Any] = [
|
||||||
"ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789", "--bind", "loopback"],
|
"ProgramArguments": ["clawdbot", "gateway", "--port", "18789", "--bind", "loopback"],
|
||||||
"EnvironmentVariables": [
|
"EnvironmentVariables": [
|
||||||
"CLAWDBOT_GATEWAY_TOKEN": " secret ",
|
"CLAWDBOT_GATEWAY_TOKEN": " secret ",
|
||||||
"CLAWDBOT_GATEWAY_PASSWORD": "pw",
|
"CLAWDBOT_GATEWAY_PASSWORD": "pw",
|
||||||
@@ -28,7 +28,7 @@ import Testing
|
|||||||
let url = FileManager.default.temporaryDirectory
|
let url = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
|
.appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
|
||||||
let plist: [String: Any] = [
|
let plist: [String: Any] = [
|
||||||
"ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789"],
|
"ProgramArguments": ["clawdbot", "gateway", "--port", "18789"],
|
||||||
]
|
]
|
||||||
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
||||||
try data.write(to: url, options: [.atomic])
|
try data.write(to: url, options: [.atomic])
|
||||||
|
|||||||
@@ -112,20 +112,6 @@ 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
|
||||||
|
|||||||
@@ -227,6 +227,8 @@ Outbound Discord API calls retry on rate limits (429) using Discord `retry_after
|
|||||||
actions: {
|
actions: {
|
||||||
reactions: true,
|
reactions: true,
|
||||||
stickers: true,
|
stickers: true,
|
||||||
|
emojiUploads: true,
|
||||||
|
stickerUploads: true,
|
||||||
polls: true,
|
polls: true,
|
||||||
permissions: true,
|
permissions: true,
|
||||||
messages: true,
|
messages: true,
|
||||||
@@ -237,6 +239,7 @@ Outbound Discord API calls retry on rate limits (429) using Discord `retry_after
|
|||||||
roleInfo: true,
|
roleInfo: true,
|
||||||
roles: false,
|
roles: false,
|
||||||
channelInfo: true,
|
channelInfo: true,
|
||||||
|
channels: true,
|
||||||
voiceStatus: true,
|
voiceStatus: true,
|
||||||
events: true,
|
events: true,
|
||||||
moderation: false
|
moderation: false
|
||||||
@@ -304,8 +307,9 @@ ack reaction after the bot replies.
|
|||||||
- `retry`: retry policy for outbound Discord API calls (attempts, minDelayMs, maxDelayMs, jitter).
|
- `retry`: retry policy for outbound Discord API calls (attempts, minDelayMs, maxDelayMs, jitter).
|
||||||
- `actions`: per-action tool gates; omit to allow all (set `false` to disable).
|
- `actions`: per-action tool gates; omit to allow all (set `false` to disable).
|
||||||
- `reactions` (covers react + read reactions)
|
- `reactions` (covers react + read reactions)
|
||||||
- `stickers`, `polls`, `permissions`, `messages`, `threads`, `pins`, `search`
|
- `stickers`, `emojiUploads`, `stickerUploads`, `polls`, `permissions`, `messages`, `threads`, `pins`, `search`
|
||||||
- `memberInfo`, `roleInfo`, `channelInfo`, `voiceStatus`, `events`
|
- `memberInfo`, `roleInfo`, `channelInfo`, `voiceStatus`, `events`
|
||||||
|
- `channels` (create/edit/delete channels + categories + permissions)
|
||||||
- `roles` (role add/remove, default `false`)
|
- `roles` (role add/remove, default `false`)
|
||||||
- `moderation` (timeout/kick/ban, default `false`)
|
- `moderation` (timeout/kick/ban, default `false`)
|
||||||
|
|
||||||
@@ -321,6 +325,8 @@ Reaction notifications use `guilds.<id>.reactionNotifications`:
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| reactions | enabled | React + list reactions + emojiList |
|
| reactions | enabled | React + list reactions + emojiList |
|
||||||
| stickers | enabled | Send stickers |
|
| stickers | enabled | Send stickers |
|
||||||
|
| emojiUploads | enabled | Upload emojis |
|
||||||
|
| stickerUploads | enabled | Upload stickers |
|
||||||
| polls | enabled | Create polls |
|
| polls | enabled | Create polls |
|
||||||
| permissions | enabled | Channel permission snapshot |
|
| permissions | enabled | Channel permission snapshot |
|
||||||
| messages | enabled | Read/send/edit/delete |
|
| messages | enabled | Read/send/edit/delete |
|
||||||
@@ -330,6 +336,7 @@ Reaction notifications use `guilds.<id>.reactionNotifications`:
|
|||||||
| memberInfo | enabled | Member info |
|
| memberInfo | enabled | Member info |
|
||||||
| roleInfo | enabled | Role list |
|
| roleInfo | enabled | Role list |
|
||||||
| channelInfo | enabled | Channel info + list |
|
| channelInfo | enabled | Channel info + list |
|
||||||
|
| channels | enabled | Channel/category management |
|
||||||
| voiceStatus | enabled | Voice state lookup |
|
| voiceStatus | enabled | Voice state lookup |
|
||||||
| events | enabled | List/create scheduled events |
|
| events | enabled | List/create scheduled events |
|
||||||
| roles | disabled | Role add/remove |
|
| roles | disabled | Role add/remove |
|
||||||
|
|||||||
@@ -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.
|
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately).
|
||||||
- [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.
|
||||||
|
|||||||
+45
-17
@@ -3,20 +3,43 @@ 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 (Bot Framework)
|
# Microsoft Teams (plugin)
|
||||||
|
|
||||||
> "Abandon all hope, ye who enter here."
|
> "Abandon all hope, ye who enter here."
|
||||||
|
|
||||||
|
|
||||||
Updated: 2026-01-08
|
Updated: 2026-01-16
|
||||||
|
|
||||||
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) Create an **Azure Bot** (App ID + client secret + tenant ID).
|
1) Install the Microsoft Teams plugin.
|
||||||
2) Configure Clawdbot with those credentials.
|
2) Create an **Azure Bot** (App ID + client secret + tenant ID).
|
||||||
3) Expose `/api/messages` (port 3978 by default) via a public URL or tunnel.
|
3) Configure Clawdbot with those credentials.
|
||||||
4) Install the Teams app package and start the gateway.
|
4) Expose `/api/messages` (port 3978 by default) via a public URL or tunnel.
|
||||||
|
5) Install the Teams app package and start the gateway.
|
||||||
|
|
||||||
Minimal config:
|
Minimal config:
|
||||||
```json5
|
```json5
|
||||||
@@ -73,11 +96,12 @@ Example:
|
|||||||
```
|
```
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
1. Create an **Azure Bot** (App ID + secret + tenant ID).
|
1. Install the Microsoft Teams plugin.
|
||||||
2. Build a **Teams app package** that references the bot and includes the RSC permissions below.
|
2. Create an **Azure Bot** (App ID + secret + tenant ID).
|
||||||
3. Upload/install the Teams app into a team (or personal scope for DMs).
|
3. Build a **Teams app package** that references the bot and includes the RSC permissions below.
|
||||||
4. Configure `msteams` in `~/.clawdbot/clawdbot.json` (or env vars) and start the gateway.
|
4. Upload/install the Teams app into a team (or personal scope for DMs).
|
||||||
5. The gateway listens for Bot Framework webhook traffic on `/api/messages` by default.
|
5. Configure `msteams` in `~/.clawdbot/clawdbot.json` (or env vars) and start the gateway.
|
||||||
|
6. The gateway listens for Bot Framework webhook traffic on `/api/messages` by default.
|
||||||
|
|
||||||
## Azure Bot Setup (Prerequisites)
|
## Azure Bot Setup (Prerequisites)
|
||||||
|
|
||||||
@@ -166,13 +190,17 @@ 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. **Bot registration**
|
1. **Install the Microsoft Teams plugin**
|
||||||
|
- 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)
|
||||||
|
|
||||||
2. **Teams app manifest**
|
3. **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).
|
||||||
@@ -180,7 +208,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`.
|
||||||
|
|
||||||
3. **Configure Clawdbot**
|
4. **Configure Clawdbot**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"msteams": {
|
"msteams": {
|
||||||
@@ -198,12 +226,12 @@ This is often easier than hand-editing JSON manifests.
|
|||||||
- `MSTEAMS_APP_PASSWORD`
|
- `MSTEAMS_APP_PASSWORD`
|
||||||
- `MSTEAMS_TENANT_ID`
|
- `MSTEAMS_TENANT_ID`
|
||||||
|
|
||||||
4. **Bot endpoint**
|
5. **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).
|
||||||
|
|
||||||
5. **Run the gateway**
|
6. **Run the gateway**
|
||||||
- The Teams channel starts automatically when `msteams` config exists and credentials are set.
|
- The Teams channel starts automatically when the plugin is installed and `msteams` config exists with credentials.
|
||||||
|
|
||||||
## 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.
|
||||||
|
|||||||
@@ -194,6 +194,68 @@ Telegram forum topics include a `message_thread_id` per message. Clawdbot:
|
|||||||
|
|
||||||
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
|
||||||
@@ -392,6 +454,8 @@ 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).
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
summary: "CLI reference for `clawdbot config` (get/set/unset config values)"
|
||||||
|
read_when:
|
||||||
|
- You want to read or edit config non-interactively
|
||||||
|
---
|
||||||
|
|
||||||
|
# `clawdbot config`
|
||||||
|
|
||||||
|
Config helpers: get/set/unset values by path. Run without a subcommand to open
|
||||||
|
the configure wizard (same as `clawdbot configure`).
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot config get browser.executablePath
|
||||||
|
clawdbot config set browser.executablePath "/usr/bin/google-chrome"
|
||||||
|
clawdbot config set agents.defaults.heartbeat.every "2h"
|
||||||
|
clawdbot config unset tools.web.search.apiKey
|
||||||
|
```
|
||||||
|
|
||||||
|
## Paths
|
||||||
|
|
||||||
|
Paths use dot or bracket notation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot config get agents.defaults.workspace
|
||||||
|
clawdbot config get agents.list[0].id
|
||||||
|
```
|
||||||
|
|
||||||
|
## Values
|
||||||
|
|
||||||
|
Values are parsed as JSON5 when possible; otherwise they are treated as strings.
|
||||||
|
Use `--json` to require JSON5 parsing.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot config set agents.defaults.heartbeat.every "0m"
|
||||||
|
clawdbot config set gateway.port 19001 --json
|
||||||
|
clawdbot config set channels.whatsapp.groups '["*"]' --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart the gateway after edits.
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
---
|
---
|
||||||
summary: "CLI reference for `clawdbot configure` / `clawdbot config` (interactive configuration prompts)"
|
summary: "CLI reference for `clawdbot configure` (interactive configuration prompts)"
|
||||||
read_when:
|
read_when:
|
||||||
- You want to tweak credentials, devices, or agent defaults interactively
|
- You want to tweak credentials, devices, or agent defaults interactively
|
||||||
---
|
---
|
||||||
|
|
||||||
# `clawdbot configure` (alias: `config`)
|
# `clawdbot configure`
|
||||||
|
|
||||||
Interactive prompt to set up credentials, devices, and agent defaults.
|
Interactive prompt to set up credentials, devices, and agent defaults.
|
||||||
|
|
||||||
|
Tip: `clawdbot config` without a subcommand opens the same wizard. Use
|
||||||
|
`clawdbot config get|set|unset` for non-interactive edits.
|
||||||
|
|
||||||
Related:
|
Related:
|
||||||
- Gateway configuration reference: [Configuration](/gateway/configuration)
|
- Gateway configuration reference: [Configuration](/gateway/configuration)
|
||||||
|
- Config CLI: [Config](/cli/config)
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- Choosing where the Gateway runs always updates `gateway.mode`. You can select "Continue" without other sections if that is all you need.
|
- Choosing where the Gateway runs always updates `gateway.mode`. You can select "Continue" without other sections if that is all you need.
|
||||||
|
|||||||
@@ -15,3 +15,6 @@ 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).
|
||||||
+20
-5
@@ -13,7 +13,8 @@ This page describes the current CLI behavior. If commands change, update this do
|
|||||||
|
|
||||||
- [`setup`](/cli/setup)
|
- [`setup`](/cli/setup)
|
||||||
- [`onboard`](/cli/onboard)
|
- [`onboard`](/cli/onboard)
|
||||||
- [`configure`](/cli/configure) (alias: `config`)
|
- [`configure`](/cli/configure)
|
||||||
|
- [`config`](/cli/config)
|
||||||
- [`doctor`](/cli/doctor)
|
- [`doctor`](/cli/doctor)
|
||||||
- [`dashboard`](/cli/dashboard)
|
- [`dashboard`](/cli/dashboard)
|
||||||
- [`reset`](/cli/reset)
|
- [`reset`](/cli/reset)
|
||||||
@@ -83,7 +84,11 @@ Palette source of truth: `src/terminal/palette.ts` (aka “lobster seam”).
|
|||||||
clawdbot [--dev] [--profile <name>] <command>
|
clawdbot [--dev] [--profile <name>] <command>
|
||||||
setup
|
setup
|
||||||
onboard
|
onboard
|
||||||
configure (alias: config)
|
configure
|
||||||
|
config
|
||||||
|
get
|
||||||
|
set
|
||||||
|
unset
|
||||||
doctor
|
doctor
|
||||||
security
|
security
|
||||||
audit
|
audit
|
||||||
@@ -310,9 +315,18 @@ Options:
|
|||||||
- `--node-manager <npm|pnpm|bun>` (pnpm recommended; bun not recommended for Gateway runtime)
|
- `--node-manager <npm|pnpm|bun>` (pnpm recommended; bun not recommended for Gateway runtime)
|
||||||
- `--json`
|
- `--json`
|
||||||
|
|
||||||
### `configure` / `config`
|
### `configure`
|
||||||
Interactive configuration wizard (models, channels, skills, gateway).
|
Interactive configuration wizard (models, channels, skills, gateway).
|
||||||
|
|
||||||
|
### `config`
|
||||||
|
Non-interactive config helpers (get/set/unset). Running `clawdbot config` with no
|
||||||
|
subcommand launches the wizard.
|
||||||
|
|
||||||
|
Subcommands:
|
||||||
|
- `config get <path>`: print a config value (dot/bracket path).
|
||||||
|
- `config set <path> <value>`: set a value (JSON5 or raw string).
|
||||||
|
- `config unset <path>`: remove a value.
|
||||||
|
|
||||||
### `doctor`
|
### `doctor`
|
||||||
Health checks + quick fixes (config + gateway + legacy services).
|
Health checks + quick fixes (config + gateway + legacy services).
|
||||||
|
|
||||||
@@ -594,8 +608,9 @@ 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`.
|
- `daemon install` options: `--port`, `--runtime`, `--token`, `--force`, `--json`.
|
||||||
|
|
||||||
### `logs`
|
### `logs`
|
||||||
Tail Gateway file logs via RPC.
|
Tail Gateway file logs via RPC.
|
||||||
@@ -785,7 +800,7 @@ Location:
|
|||||||
|
|
||||||
## Browser
|
## Browser
|
||||||
|
|
||||||
Browser control CLI (dedicated Chrome/Chromium). See [`clawdbot browser`](/cli/browser) and the [Browser tool](/tools/browser).
|
Browser control CLI (dedicated Chrome/Brave/Edge/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`, `--message`
|
- Required: `--to`, plus `--message` or `--media`
|
||||||
- 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)
|
||||||
|
|||||||
@@ -43,3 +43,7 @@ clawdbot models auth paste-token
|
|||||||
```
|
```
|
||||||
`models auth login` runs a provider plugin’s auth flow (OAuth/API key). Use
|
`models auth login` runs a provider plugin’s auth flow (OAuth/API key). Use
|
||||||
`clawdbot plugins list` to see which providers are installed.
|
`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.
|
||||||
@@ -21,6 +21,8 @@ clawdbot plugins info <id>
|
|||||||
clawdbot plugins enable <id>
|
clawdbot plugins enable <id>
|
||||||
clawdbot plugins disable <id>
|
clawdbot plugins disable <id>
|
||||||
clawdbot plugins doctor
|
clawdbot plugins doctor
|
||||||
|
clawdbot plugins update <id>
|
||||||
|
clawdbot plugins update --all
|
||||||
```
|
```
|
||||||
|
|
||||||
### Install
|
### Install
|
||||||
@@ -31,3 +33,12 @@ clawdbot plugins install <npm-spec>
|
|||||||
|
|
||||||
Security note: treat plugin installs like running code. Prefer pinned versions.
|
Security note: treat plugin installs like running code. Prefer pinned versions.
|
||||||
|
|
||||||
|
### Update
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot plugins update <id>
|
||||||
|
clawdbot plugins update --all
|
||||||
|
clawdbot plugins update <id> --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
Updates only apply to plugins installed from npm (tracked in `plugins.installs`).
|
||||||
@@ -20,3 +20,4 @@ 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.
|
||||||
@@ -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 on the gateway host):
|
Preferred Anthropic auth is the Claude Code CLI setup-token (run anywhere; paste on the gateway host if needed):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
claude setup-token
|
claude setup-token
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ 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.
|
||||||
|
|
||||||
@@ -32,8 +37,11 @@ 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 collapse to the per-agent primary key: `agent:<agentId>:<mainKey>`.
|
- Direct chats follow `session.dmScope` (default `main`).
|
||||||
- Multiple phone numbers and channels can map to the same agent main key; they act as transports into one conversation.
|
- `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.
|
||||||
|
- `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.
|
||||||
@@ -77,6 +85,7 @@ 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",
|
||||||
|
|||||||
@@ -28,8 +28,12 @@ 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).
|
||||||
|
|||||||
@@ -2261,6 +2261,7 @@ 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
|
||||||
@@ -2285,6 +2286,10 @@ 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.
|
||||||
@@ -2376,10 +2381,10 @@ Example:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### `browser` (clawd-managed Chrome)
|
### `browser` (clawd-managed browser)
|
||||||
|
|
||||||
Clawdbot can start a **dedicated, isolated** Chrome/Chromium instance for clawd and expose a small loopback control server.
|
Clawdbot can start a **dedicated, isolated** Chrome/Brave/Edge/Chromium instance for clawd and expose a small loopback control server.
|
||||||
Profiles can point at a **remote** Chrome via `profiles.<name>.cdpUrl`. Remote
|
Profiles can point at a **remote** Chromium-based browser 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
|
||||||
@@ -2391,6 +2396,7 @@ 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
|
||||||
{
|
{
|
||||||
@@ -2408,7 +2414,7 @@ Defaults:
|
|||||||
// Advanced:
|
// Advanced:
|
||||||
// headless: false,
|
// headless: false,
|
||||||
// noSandbox: false,
|
// noSandbox: false,
|
||||||
// executablePath: "/usr/bin/chromium",
|
// executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
||||||
// attachOnly: false, // set true when tunneling a remote CDP to localhost
|
// attachOnly: false, // set true when tunneling a remote CDP to localhost
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2726,12 +2732,29 @@ 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 `claude setup-token` on the gateway host.
|
profile is stale, it suggests running `claude setup-token` (or pasting a setup-token).
|
||||||
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.
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,22 @@ 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:
|
||||||
@@ -107,6 +123,18 @@ 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:
|
||||||
|
|||||||
@@ -169,6 +169,20 @@ The agent was interrupted mid-response.
|
|||||||
|
|
||||||
**Fix:** Just send another message. The session continues.
|
**Fix:** Just send another message. The session continues.
|
||||||
|
|
||||||
|
### "Agent failed before reply: Unknown model: anthropic/claude-haiku-3-5"
|
||||||
|
|
||||||
|
Clawdbot intentionally rejects **older/insecure models** (especially those more
|
||||||
|
vulnerable to prompt injection). If you see this error, the model name is no
|
||||||
|
longer supported.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- Pick a **latest** model for the provider and update your config or model alias.
|
||||||
|
- If you’re unsure which models are available, run `clawdbot models list` or
|
||||||
|
`clawdbot models scan` and choose a supported one.
|
||||||
|
- Check gateway logs for the detailed failure reason.
|
||||||
|
|
||||||
|
See also: [Models CLI](/cli/models) and [Model providers](/concepts/model-providers).
|
||||||
|
|
||||||
### Messages Not Triggering
|
### Messages Not Triggering
|
||||||
|
|
||||||
**Check 1:** Is the sender allowlisted?
|
**Check 1:** Is the sender allowlisted?
|
||||||
@@ -306,6 +320,139 @@ Clawdbot keeps conversation history in memory.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Common troubleshooting
|
||||||
|
|
||||||
|
### “All models failed” — what should I check first?
|
||||||
|
|
||||||
|
- **Credentials** present for the provider(s) being tried (auth profiles + env vars).
|
||||||
|
- **Model routing**: confirm `agents.defaults.model.primary` and fallbacks are models you can access.
|
||||||
|
- **Gateway logs** in `/tmp/clawdbot/…` for the exact provider error.
|
||||||
|
- **Model status**: use `/model status` (chat) or `clawdbot models status` (CLI).
|
||||||
|
|
||||||
|
### I’m running on my personal WhatsApp number — why is self-chat weird?
|
||||||
|
|
||||||
|
Enable self-chat mode and allowlist your own number:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
whatsapp: {
|
||||||
|
selfChatMode: true,
|
||||||
|
dmPolicy: "allowlist",
|
||||||
|
allowFrom: ["+15555550123"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See [WhatsApp setup](/channels/whatsapp).
|
||||||
|
|
||||||
|
### WhatsApp logged me out. How do I re‑auth?
|
||||||
|
|
||||||
|
Run the login command again and scan the QR code:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot channels login
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build errors on `main` — what’s the standard fix path?
|
||||||
|
|
||||||
|
1) `git pull origin main && pnpm install`
|
||||||
|
2) `pnpm clawdbot doctor`
|
||||||
|
3) Check GitHub issues or Discord
|
||||||
|
4) Temporary workaround: check out an older commit
|
||||||
|
|
||||||
|
### npm install fails (allow-build-scripts / missing tar or yargs). What now?
|
||||||
|
|
||||||
|
If you’re running from source, use the repo’s package manager: **pnpm** (preferred).
|
||||||
|
The repo declares `packageManager: "pnpm@…"`, and pnpm patches are tracked in `pnpm.patchedDependencies`.
|
||||||
|
|
||||||
|
Typical recovery:
|
||||||
|
```bash
|
||||||
|
git status # ensure you’re in the repo root
|
||||||
|
pnpm install
|
||||||
|
pnpm build
|
||||||
|
pnpm clawdbot doctor
|
||||||
|
clawdbot daemon restart
|
||||||
|
```
|
||||||
|
|
||||||
|
Why: pnpm is the configured package manager for this repo, and the dependency
|
||||||
|
patching workflow relies on it.
|
||||||
|
|
||||||
|
### How do I switch between git installs and npm installs?
|
||||||
|
|
||||||
|
Use the **website installer** and select the install method with a flag. It
|
||||||
|
upgrades in place and rewrites the gateway service to point at the new install.
|
||||||
|
|
||||||
|
Switch **to git install**:
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://clawd.bot/install.sh | bash -s -- --install-method git --no-onboard
|
||||||
|
```
|
||||||
|
|
||||||
|
Switch **to npm global**:
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://clawd.bot/install.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- The git flow only rebases if the repo is clean. Commit or stash changes first.
|
||||||
|
- After switching, run:
|
||||||
|
```bash
|
||||||
|
clawdbot doctor
|
||||||
|
clawdbot daemon restart
|
||||||
|
```
|
||||||
|
|
||||||
|
### Telegram block streaming isn’t splitting text between tool calls. Why?
|
||||||
|
|
||||||
|
Block streaming only sends **completed text blocks**. Common reasons you see a single message:
|
||||||
|
- `agents.defaults.blockStreamingDefault` is still `"off"`.
|
||||||
|
- `channels.telegram.blockStreaming` is set to `false`.
|
||||||
|
- `channels.telegram.streamMode` is `partial` or `block` **and draft streaming is active**
|
||||||
|
(private chat + topics). Draft streaming disables block streaming in that case.
|
||||||
|
- Your `minChars` / coalesce settings are too high, so chunks get merged.
|
||||||
|
- The model emits one large text block (no mid‑reply flush points).
|
||||||
|
|
||||||
|
Fix checklist:
|
||||||
|
1) Put block streaming settings under `agents.defaults`, not the root.
|
||||||
|
2) Set `channels.telegram.streamMode: "off"` if you want real multi‑message block replies.
|
||||||
|
3) Use smaller chunk/coalesce thresholds while debugging.
|
||||||
|
|
||||||
|
See [Streaming](/concepts/streaming).
|
||||||
|
|
||||||
|
### Discord doesn’t reply in my server even with `requireMention: false`. Why?
|
||||||
|
|
||||||
|
`requireMention` only controls mention‑gating **after** the channel passes allowlists.
|
||||||
|
By default `channels.discord.groupPolicy` is **allowlist**, so guilds must be explicitly enabled.
|
||||||
|
If you set `channels.discord.guilds.<guildId>.channels`, only the listed channels are allowed; omit it to allow all channels in the guild.
|
||||||
|
|
||||||
|
Fix checklist:
|
||||||
|
1) Set `channels.discord.groupPolicy: "open"` **or** add a guild allowlist entry (and optionally a channel allowlist).
|
||||||
|
2) Use **numeric channel IDs** in `channels.discord.guilds.<guildId>.channels`.
|
||||||
|
3) Put `requireMention: false` **under** `channels.discord.guilds` (global or per‑channel).
|
||||||
|
Top‑level `channels.discord.requireMention` is not a supported key.
|
||||||
|
4) Ensure the bot has **Message Content Intent** and channel permissions.
|
||||||
|
5) Run `clawdbot channels status --probe` for audit hints.
|
||||||
|
|
||||||
|
Docs: [Discord](/channels/discord), [Channels troubleshooting](/channels/troubleshooting).
|
||||||
|
|
||||||
|
### Cloud Code Assist API error: invalid tool schema (400). What now?
|
||||||
|
|
||||||
|
This is almost always a **tool schema compatibility** issue. The Cloud Code Assist
|
||||||
|
endpoint accepts a strict subset of JSON Schema. Clawdbot scrubs/normalizes tool
|
||||||
|
schemas in current `main`, but the fix is not in the last release yet (as of
|
||||||
|
January 13, 2026).
|
||||||
|
|
||||||
|
Fix checklist:
|
||||||
|
1) **Update Clawdbot**:
|
||||||
|
- If you can run from source, pull `main` and restart the gateway.
|
||||||
|
- Otherwise, wait for the next release that includes the schema scrubber.
|
||||||
|
2) Avoid unsupported keywords like `anyOf/oneOf/allOf`, `patternProperties`,
|
||||||
|
`additionalProperties`, `minLength`, `maxLength`, `format`, etc.
|
||||||
|
3) If you define custom tools, keep the top‑level schema as `type: "object"` with
|
||||||
|
`properties` and simple enums.
|
||||||
|
|
||||||
|
See [Tools](/tools) and [TypeBox schemas](/concepts/typebox).
|
||||||
|
|
||||||
## macOS Specific Issues
|
## macOS Specific Issues
|
||||||
|
|
||||||
### App Crashes when Granting Permissions (Speech/Mic)
|
### App Crashes when Granting Permissions (Speech/Mic)
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ services:
|
|||||||
[
|
[
|
||||||
"node",
|
"node",
|
||||||
"dist/index.js",
|
"dist/index.js",
|
||||||
"gateway-daemon",
|
"gateway",
|
||||||
"--bind",
|
"--bind",
|
||||||
"${CLAWDBOT_GATEWAY_BIND}",
|
"${CLAWDBOT_GATEWAY_BIND}",
|
||||||
"--port",
|
"--port",
|
||||||
|
|||||||
@@ -54,6 +54,38 @@ 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.
|
||||||
|
|||||||
@@ -35,9 +35,11 @@ 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:
|
||||||
@@ -158,11 +160,15 @@ clawdbot plugins install <path> # add a local file/dir to plugins.l
|
|||||||
clawdbot plugins install ./extensions/voice-call # relative path ok
|
clawdbot plugins install ./extensions/voice-call # relative path ok
|
||||||
clawdbot plugins install ./plugin.tgz # install from a local tarball
|
clawdbot plugins install ./plugin.tgz # install from a local tarball
|
||||||
clawdbot plugins install @clawdbot/voice-call # install from npm
|
clawdbot plugins install @clawdbot/voice-call # install from npm
|
||||||
|
clawdbot plugins update <id>
|
||||||
|
clawdbot plugins update --all
|
||||||
clawdbot plugins enable <id>
|
clawdbot plugins enable <id>
|
||||||
clawdbot plugins disable <id>
|
clawdbot plugins disable <id>
|
||||||
clawdbot plugins doctor
|
clawdbot plugins doctor
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`plugins update` only works for npm installs tracked under `plugins.installs`.
|
||||||
|
|
||||||
Plugins may also register their own top‑level commands (example: `clawdbot voicecall`).
|
Plugins may also register their own top‑level commands (example: `clawdbot voicecall`).
|
||||||
|
|
||||||
## Plugin API (overview)
|
## Plugin API (overview)
|
||||||
|
|||||||
@@ -38,10 +38,32 @@ 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 on the gateway host (wizard can run it for you)
|
# Run setup-token locally (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
|
||||||
@@ -58,7 +80,7 @@ clawdbot onboard --auth-choice claude-cli
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- The wizard can run `claude setup-token` on the gateway host and store the token.
|
- The wizard can run `claude setup-token` locally and store the token, or you can paste a token generated elsewhere.
|
||||||
- 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,6 +12,7 @@ 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.
|
||||||
|
|||||||
+136
-148
@@ -16,6 +16,7 @@ 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)
|
||||||
@@ -33,6 +34,7 @@ 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)
|
||||||
@@ -54,6 +56,7 @@ 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)
|
||||||
@@ -67,11 +70,14 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
|||||||
- [I set `COPILOT_GITHUB_TOKEN`, but models status shows “Shell env: off.” Why?](#i-set-copilot_github_token-but-models-status-shows-shell-env-off-why)
|
- [I set `COPILOT_GITHUB_TOKEN`, but models status shows “Shell env: off.” Why?](#i-set-copilot_github_token-but-models-status-shows-shell-env-off-why)
|
||||||
- [Sessions & multiple chats](#sessions-multiple-chats)
|
- [Sessions & multiple chats](#sessions-multiple-chats)
|
||||||
- [How do I start a fresh conversation?](#how-do-i-start-a-fresh-conversation)
|
- [How do I start a fresh conversation?](#how-do-i-start-a-fresh-conversation)
|
||||||
- [How do I completely reset Clawdbot (but keep it installed)?](#how-do-i-completely-reset-clawdbot-but-keep-it-installed)
|
- [Do sessions reset automatically if I never send `/new`?](#do-sessions-reset-automatically-if-i-never-send-new)
|
||||||
|
- [How do I completely reset Clawdbot but keep it installed?](#how-do-i-completely-reset-clawdbot-but-keep-it-installed)
|
||||||
- [I’m getting “context too large” errors — how do I reset or compact?](#im-getting-context-too-large-errors-how-do-i-reset-or-compact)
|
- [I’m getting “context too large” errors — how do I reset or compact?](#im-getting-context-too-large-errors-how-do-i-reset-or-compact)
|
||||||
|
- [Why am I getting heartbeat messages every 30 minutes?](#why-am-i-getting-heartbeat-messages-every-30-minutes)
|
||||||
- [Do I need to add a “bot account” to a WhatsApp group?](#do-i-need-to-add-a-bot-account-to-a-whatsapp-group)
|
- [Do I need to add a “bot account” to a WhatsApp group?](#do-i-need-to-add-a-bot-account-to-a-whatsapp-group)
|
||||||
- [Why doesn’t Clawdbot reply in a group?](#why-doesnt-clawdbot-reply-in-a-group)
|
- [Why doesn’t Clawdbot reply in a group?](#why-doesnt-clawdbot-reply-in-a-group)
|
||||||
- [Do groups/threads share context with DMs?](#do-groupsthreads-share-context-with-dms)
|
- [Do groups/threads share context with DMs?](#do-groupsthreads-share-context-with-dms)
|
||||||
|
- [How many workspaces and agents can I create?](#how-many-workspaces-and-agents-can-i-create)
|
||||||
- [Models: defaults, selection, aliases, switching](#models-defaults-selection-aliases-switching)
|
- [Models: defaults, selection, aliases, switching](#models-defaults-selection-aliases-switching)
|
||||||
- [What is the “default model”?](#what-is-the-default-model)
|
- [What is the “default model”?](#what-is-the-default-model)
|
||||||
- [How do I switch models on the fly (without restarting)?](#how-do-i-switch-models-on-the-fly-without-restarting)
|
- [How do I switch models on the fly (without restarting)?](#how-do-i-switch-models-on-the-fly-without-restarting)
|
||||||
@@ -104,25 +110,17 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
|||||||
- [Logging and debugging](#logging-and-debugging)
|
- [Logging and debugging](#logging-and-debugging)
|
||||||
- [Where are logs?](#where-are-logs)
|
- [Where are logs?](#where-are-logs)
|
||||||
- [How do I start/stop/restart the Gateway daemon?](#how-do-i-startstoprestart-the-gateway-daemon)
|
- [How do I start/stop/restart the Gateway daemon?](#how-do-i-startstoprestart-the-gateway-daemon)
|
||||||
|
- [ELI5: `clawdbot daemon restart` vs `clawdbot gateway`](#eli5-clawdbot-daemon-restart-vs-clawdbot-gateway)
|
||||||
- [What’s the fastest way to get more details when something fails?](#whats-the-fastest-way-to-get-more-details-when-something-fails)
|
- [What’s the fastest way to get more details when something fails?](#whats-the-fastest-way-to-get-more-details-when-something-fails)
|
||||||
- [Media & attachments](#media-attachments)
|
- [Media & attachments](#media-attachments)
|
||||||
- [My skill generated an image/PDF, but nothing was sent](#my-skill-generated-an-imagepdf-but-nothing-was-sent)
|
- [My skill generated an image/PDF, but nothing was sent](#my-skill-generated-an-imagepdf-but-nothing-was-sent)
|
||||||
- [Security and access control](#security-and-access-control)
|
- [Security and access control](#security-and-access-control)
|
||||||
- [Is it safe to expose Clawdbot to inbound DMs?](#is-it-safe-to-expose-clawdbot-to-inbound-dms)
|
- [Is it safe to expose Clawdbot to inbound DMs?](#is-it-safe-to-expose-clawdbot-to-inbound-dms)
|
||||||
|
- [I ran `/start` in Telegram but didn’t get a pairing code](#i-ran-start-in-telegram-but-didnt-get-a-pairing-code)
|
||||||
- [WhatsApp: will it message my contacts? How does pairing work?](#whatsapp-will-it-message-my-contacts-how-does-pairing-work)
|
- [WhatsApp: will it message my contacts? How does pairing work?](#whatsapp-will-it-message-my-contacts-how-does-pairing-work)
|
||||||
- [Chat commands, aborting tasks, and “it won’t stop”](#chat-commands-aborting-tasks-and-it-wont-stop)
|
- [Chat commands, aborting tasks, and “it won’t stop”](#chat-commands-aborting-tasks-and-it-wont-stop)
|
||||||
- [How do I stop/cancel a running task?](#how-do-i-stopcancel-a-running-task)
|
- [How do I stop/cancel a running task?](#how-do-i-stopcancel-a-running-task)
|
||||||
- [Why does it feel like the bot “ignores” rapid‑fire messages?](#why-does-it-feel-like-the-bot-ignores-rapidfire-messages)
|
- [Why does it feel like the bot “ignores” rapid‑fire messages?](#why-does-it-feel-like-the-bot-ignores-rapidfire-messages)
|
||||||
- [Common troubleshooting](#common-troubleshooting)
|
|
||||||
- [“All models failed” — what should I check first?](#all-models-failed-what-should-i-check-first)
|
|
||||||
- [I’m running on my personal WhatsApp number — why is self-chat weird?](#im-running-on-my-personal-whatsapp-number-why-is-self-chat-weird)
|
|
||||||
- [WhatsApp logged me out. How do I re‑auth?](#whatsapp-logged-me-out-how-do-i-reauth)
|
|
||||||
- [Build errors on `main` — what’s the standard fix path?](#build-errors-on-main-whats-the-standard-fix-path)
|
|
||||||
- [npm install fails (allow-build-scripts / missing tar or yargs). What now?](#npm-install-fails-allow-build-scripts--missing-tar-or-yargs-what-now)
|
|
||||||
- [How do I switch between git installs and npm installs?](#how-do-i-switch-between-git-installs-and-npm-installs)
|
|
||||||
- [Telegram block streaming isn’t splitting text between tool calls. Why?](#telegram-block-streaming-isnt-splitting-text-between-tool-calls-why)
|
|
||||||
- [Discord doesn’t reply in my server even with `requireMention: false`. Why?](#discord-doesnt-reply-in-my-server-even-with-requiremention-false-why)
|
|
||||||
- [Cloud Code Assist API error: invalid tool schema (400). What now?](#cloud-code-assist-api-error-invalid-tool-schema-400-what-now)
|
|
||||||
|
|
||||||
## First 60 seconds if something's broken
|
## First 60 seconds if something's broken
|
||||||
|
|
||||||
@@ -239,15 +237,25 @@ 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?
|
||||||
|
|
||||||
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).
|
`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).
|
||||||
|
|
||||||
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** 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).
|
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).
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@@ -387,6 +395,40 @@ 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.
|
||||||
@@ -652,6 +694,11 @@ 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?
|
||||||
@@ -806,7 +853,21 @@ See [/concepts/model-providers](/concepts/model-providers) and [/environment](/e
|
|||||||
|
|
||||||
Send `/new` or `/reset` as a standalone message. See [Session management](/concepts/session).
|
Send `/new` or `/reset` as a standalone message. See [Session management](/concepts/session).
|
||||||
|
|
||||||
### How do I completely reset Clawdbot (but keep it installed)?
|
### Do sessions reset automatically if I never send `/new`?
|
||||||
|
|
||||||
|
Yes. Sessions expire after `session.idleMinutes` (default **60**). The **next**
|
||||||
|
message starts a fresh session id for that chat key. This does not delete
|
||||||
|
transcripts — it just starts a new session.
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
session: {
|
||||||
|
idleMinutes: 240
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### How do I completely reset Clawdbot but keep it installed?
|
||||||
|
|
||||||
Use the reset command:
|
Use the reset command:
|
||||||
|
|
||||||
@@ -853,6 +914,24 @@ If it keeps happening:
|
|||||||
|
|
||||||
Docs: [Compaction](/concepts/compaction), [Session pruning](/concepts/session-pruning), [Session management](/concepts/session).
|
Docs: [Compaction](/concepts/compaction), [Session pruning](/concepts/session-pruning), [Session management](/concepts/session).
|
||||||
|
|
||||||
|
### Why am I getting heartbeat messages every 30 minutes?
|
||||||
|
|
||||||
|
Heartbeats run every **30m** by default. Tune or disable them:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
heartbeat: {
|
||||||
|
every: "2h" // or "0m" to disable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Per-agent overrides use `agents.list[].heartbeat`. Docs: [Heartbeat](/gateway/heartbeat).
|
||||||
|
|
||||||
### Do I need to add a “bot account” to a WhatsApp group?
|
### Do I need to add a “bot account” to a WhatsApp group?
|
||||||
|
|
||||||
No. Clawdbot runs on **your own account**, so if you’re in the group, Clawdbot can see it.
|
No. Clawdbot runs on **your own account**, so if you’re in the group, Clawdbot can see it.
|
||||||
@@ -883,6 +962,19 @@ See [Groups](/concepts/groups) and [Group messages](/concepts/group-messages).
|
|||||||
|
|
||||||
Direct chats collapse to the main session by default. Groups/channels have their own session keys, and Telegram topics / Discord threads are separate sessions. See [Groups](/concepts/groups) and [Group messages](/concepts/group-messages).
|
Direct chats collapse to the main session by default. Groups/channels have their own session keys, and Telegram topics / Discord threads are separate sessions. See [Groups](/concepts/groups) and [Group messages](/concepts/group-messages).
|
||||||
|
|
||||||
|
### How many workspaces and agents can I create?
|
||||||
|
|
||||||
|
No hard limits. Dozens (even hundreds) are fine, but watch for:
|
||||||
|
|
||||||
|
- **Disk growth:** sessions + transcripts live under `~/.clawdbot/agents/<agentId>/sessions/`.
|
||||||
|
- **Token cost:** more agents means more concurrent model usage.
|
||||||
|
- **Ops overhead:** per-agent auth profiles, workspaces, and channel routing.
|
||||||
|
|
||||||
|
Tips:
|
||||||
|
- Keep one **active** workspace per agent (`agents.defaults.workspace`).
|
||||||
|
- Prune old sessions (delete JSONL or store entries) if disk grows.
|
||||||
|
- Use `clawdbot doctor` to spot stray workspaces and profile mismatches.
|
||||||
|
|
||||||
## Models: defaults, selection, aliases, switching
|
## Models: defaults, selection, aliases, switching
|
||||||
|
|
||||||
### What is the “default model”?
|
### What is the “default model”?
|
||||||
@@ -1112,6 +1204,10 @@ 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`:
|
||||||
@@ -1351,6 +1447,14 @@ clawdbot daemon restart
|
|||||||
|
|
||||||
If you run the gateway manually, `clawdbot gateway --force` can reclaim the port. See [Gateway](/gateway).
|
If you run the gateway manually, `clawdbot gateway --force` can reclaim the port. See [Gateway](/gateway).
|
||||||
|
|
||||||
|
### ELI5: `clawdbot daemon restart` vs `clawdbot gateway`
|
||||||
|
|
||||||
|
- `clawdbot daemon restart`: restarts the **background service** (launchd/systemd).
|
||||||
|
- `clawdbot gateway`: runs the gateway **in the foreground** for this terminal session.
|
||||||
|
|
||||||
|
If you installed the daemon, use the daemon commands. Use `clawdbot gateway` when
|
||||||
|
you want a one-off, foreground run.
|
||||||
|
|
||||||
### What’s the fastest way to get more details when something fails?
|
### What’s the fastest way to get more details when something fails?
|
||||||
|
|
||||||
Start the Gateway with `--verbose` to get more console detail. Then inspect the log file for channel auth, model routing, and RPC errors.
|
Start the Gateway with `--verbose` to get more console detail. Then inspect the log file for channel auth, model routing, and RPC errors.
|
||||||
@@ -1367,7 +1471,11 @@ CLI sending:
|
|||||||
clawdbot message send --to +15555550123 --message "Here you go" --media /path/to/file.png
|
clawdbot message send --to +15555550123 --message "Here you go" --media /path/to/file.png
|
||||||
```
|
```
|
||||||
|
|
||||||
Note: images are resized/recompressed (max side 2048px) to hit size limits. See [Images](/nodes/images).
|
Also check:
|
||||||
|
- The target channel supports outbound media and isn’t blocked by allowlists.
|
||||||
|
- The file is within the provider’s size limits (images are resized to max 2048px).
|
||||||
|
|
||||||
|
See [Images](/nodes/images).
|
||||||
|
|
||||||
## Security and access control
|
## Security and access control
|
||||||
|
|
||||||
@@ -1383,6 +1491,19 @@ Treat inbound DMs as untrusted input. Defaults are designed to reduce risk:
|
|||||||
|
|
||||||
Run `clawdbot doctor` to surface risky DM policies.
|
Run `clawdbot doctor` to surface risky DM policies.
|
||||||
|
|
||||||
|
### I ran `/start` in Telegram but didn’t get a pairing code
|
||||||
|
|
||||||
|
Pairing codes are sent **only** when an unknown sender messages the bot and
|
||||||
|
`dmPolicy: "pairing"` is enabled. `/start` by itself doesn’t generate a code.
|
||||||
|
|
||||||
|
Check pending requests:
|
||||||
|
```bash
|
||||||
|
clawdbot pairing list telegram
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want immediate access, allowlist your sender id or set `dmPolicy: "open"`
|
||||||
|
for that account.
|
||||||
|
|
||||||
### WhatsApp: will it message my contacts? How does pairing work?
|
### WhatsApp: will it message my contacts? How does pairing work?
|
||||||
|
|
||||||
No. Default WhatsApp DM policy is **pairing**. Unknown senders only get a pairing code and their message is **not processed**. Clawdbot only replies to chats it receives or to explicit sends you trigger.
|
No. Default WhatsApp DM policy is **pairing**. Unknown senders only get a pairing code and their message is **not processed**. Clawdbot only replies to chats it receives or to explicit sends you trigger.
|
||||||
@@ -1440,139 +1561,6 @@ Queue mode controls how new messages interact with an in‑flight run. Use `/que
|
|||||||
|
|
||||||
You can add options like `debounce:2s cap:25 drop:summarize` for followup modes.
|
You can add options like `debounce:2s cap:25 drop:summarize` for followup modes.
|
||||||
|
|
||||||
## Common troubleshooting
|
|
||||||
|
|
||||||
### “All models failed” — what should I check first?
|
|
||||||
|
|
||||||
- **Credentials** present for the provider(s) being tried (auth profiles + env vars).
|
|
||||||
- **Model routing**: confirm `agents.defaults.model.primary` and fallbacks are models you can access.
|
|
||||||
- **Gateway logs** in `/tmp/clawdbot/…` for the exact provider error.
|
|
||||||
- **`/model status`** to see current configured models + shorthands.
|
|
||||||
|
|
||||||
### I’m running on my personal WhatsApp number — why is self-chat weird?
|
|
||||||
|
|
||||||
Enable self-chat mode and allowlist your own number:
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
channels: {
|
|
||||||
whatsapp: {
|
|
||||||
selfChatMode: true,
|
|
||||||
dmPolicy: "allowlist",
|
|
||||||
allowFrom: ["+15555550123"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
See [WhatsApp setup](/channels/whatsapp).
|
|
||||||
|
|
||||||
### WhatsApp logged me out. How do I re‑auth?
|
|
||||||
|
|
||||||
Run the login command again and scan the QR code:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot channels login
|
|
||||||
```
|
|
||||||
|
|
||||||
### Build errors on `main` — what’s the standard fix path?
|
|
||||||
|
|
||||||
1) `git pull origin main && pnpm install`
|
|
||||||
2) `pnpm clawdbot doctor`
|
|
||||||
3) Check GitHub issues or Discord
|
|
||||||
4) Temporary workaround: check out an older commit
|
|
||||||
|
|
||||||
### npm install fails (allow-build-scripts / missing tar or yargs). What now?
|
|
||||||
|
|
||||||
If you’re running from source, use the repo’s package manager: **pnpm** (preferred).
|
|
||||||
The repo declares `packageManager: "pnpm@…"`, and pnpm patches are tracked in `pnpm.patchedDependencies`.
|
|
||||||
|
|
||||||
Typical recovery:
|
|
||||||
```bash
|
|
||||||
git status # ensure you’re in the repo root
|
|
||||||
pnpm install
|
|
||||||
pnpm build
|
|
||||||
pnpm clawdbot doctor
|
|
||||||
clawdbot daemon restart
|
|
||||||
```
|
|
||||||
|
|
||||||
Why: pnpm is the configured package manager for this repo, and the dependency
|
|
||||||
patching workflow relies on it.
|
|
||||||
|
|
||||||
### How do I switch between git installs and npm installs?
|
|
||||||
|
|
||||||
Use the **website installer** and select the install method with a flag. It
|
|
||||||
upgrades in place and rewrites the gateway service to point at the new install.
|
|
||||||
|
|
||||||
Switch **to git install**:
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://clawd.bot/install.sh | bash -s -- --install-method git --no-onboard
|
|
||||||
```
|
|
||||||
|
|
||||||
Switch **to npm global**:
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://clawd.bot/install.sh | bash
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- The git flow only rebases if the repo is clean. Commit or stash changes first.
|
|
||||||
- After switching, run:
|
|
||||||
```bash
|
|
||||||
clawdbot doctor
|
|
||||||
clawdbot daemon restart
|
|
||||||
```
|
|
||||||
|
|
||||||
### Telegram block streaming isn’t splitting text between tool calls. Why?
|
|
||||||
|
|
||||||
Block streaming only sends **completed text blocks**. Common reasons you see a single message:
|
|
||||||
- `agents.defaults.blockStreamingDefault` is still `"off"`.
|
|
||||||
- `channels.telegram.blockStreaming` is set to `false`.
|
|
||||||
- `channels.telegram.streamMode` is `partial` or `block` **and draft streaming is active**
|
|
||||||
(private chat + topics). Draft streaming disables block streaming in that case.
|
|
||||||
- Your `minChars` / coalesce settings are too high, so chunks get merged.
|
|
||||||
- The model emits one large text block (no mid‑reply flush points).
|
|
||||||
|
|
||||||
Fix checklist:
|
|
||||||
1) Put block streaming settings under `agents.defaults`, not the root.
|
|
||||||
2) Set `channels.telegram.streamMode: "off"` if you want real multi‑message block replies.
|
|
||||||
3) Use smaller chunk/coalesce thresholds while debugging.
|
|
||||||
|
|
||||||
See [Streaming](/concepts/streaming).
|
|
||||||
|
|
||||||
### Discord doesn’t reply in my server even with `requireMention: false`. Why?
|
|
||||||
|
|
||||||
`requireMention` only controls mention‑gating **after** the channel passes allowlists.
|
|
||||||
By default `channels.discord.groupPolicy` is **allowlist**, so guilds must be explicitly enabled.
|
|
||||||
If you set `channels.discord.guilds.<guildId>.channels`, only the listed channels are allowed; omit it to allow all channels in the guild.
|
|
||||||
|
|
||||||
Fix checklist:
|
|
||||||
1) Set `channels.discord.groupPolicy: "open"` **or** add a guild allowlist entry (and optionally a channel allowlist).
|
|
||||||
2) Use **numeric channel IDs** in `channels.discord.guilds.<guildId>.channels`.
|
|
||||||
3) Put `requireMention: false` **under** `channels.discord.guilds` (global or per‑channel).
|
|
||||||
Top‑level `channels.discord.requireMention` is not a supported key.
|
|
||||||
4) Ensure the bot has **Message Content Intent** and channel permissions.
|
|
||||||
5) Run `clawdbot channels status --probe` for audit hints.
|
|
||||||
|
|
||||||
Docs: [Discord](/channels/discord), [Channels troubleshooting](/channels/troubleshooting).
|
|
||||||
|
|
||||||
### Cloud Code Assist API error: invalid tool schema (400). What now?
|
|
||||||
|
|
||||||
This is almost always a **tool schema compatibility** issue. The Cloud Code Assist
|
|
||||||
endpoint accepts a strict subset of JSON Schema. Clawdbot scrubs/normalizes tool
|
|
||||||
schemas in current `main`, but the fix is not in the last release yet (as of
|
|
||||||
January 13, 2026).
|
|
||||||
|
|
||||||
Fix checklist:
|
|
||||||
1) **Update Clawdbot**:
|
|
||||||
- If you can run from source, pull `main` and restart the gateway.
|
|
||||||
- Otherwise, wait for the next release that includes the schema scrubber.
|
|
||||||
2) Avoid unsupported keywords like `anyOf/oneOf/allOf`, `patternProperties`,
|
|
||||||
`additionalProperties`, `minLength`, `maxLength`, `format`, etc.
|
|
||||||
3) If you define custom tools, keep the top‑level schema as `type: "object"` with
|
|
||||||
`properties` and simple enums.
|
|
||||||
|
|
||||||
See [Tools](/tools) and [TypeBox schemas](/concepts/typebox).
|
|
||||||
|
|
||||||
## Answer the exact question from the screenshot/chat log
|
## Answer the exact question from the screenshot/chat log
|
||||||
|
|
||||||
**Q: “What’s the default model for Anthropic with an API key?”**
|
**Q: “What’s the default model for Anthropic with an API key?”**
|
||||||
|
|||||||
@@ -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="Multi-Agent Swarm (14+ Agents)" icon="robot" href="https://github.com/adam91holt/clawdspace">
|
<Card title="Kev's Dream Team (14+ Agents)" icon="robot" href="https://github.com/adam91holt/orchestrated-ai-articles">
|
||||||
**@adam91holt** • `multi-agent` `slack` `orchestration` `swarm`
|
**@adam91holt** • `multi-agent` `orchestration` `architecture` `manifesto`
|
||||||
|
|
||||||
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.
|
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/).
|
||||||
</Card>
|
</Card>
|
||||||
</CardGroup>
|
</CardGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
|
|||||||
|
|
||||||
1) **Existing config detection**
|
1) **Existing config detection**
|
||||||
- If `~/.clawdbot/clawdbot.json` exists, choose **Keep / Modify / Reset**.
|
- If `~/.clawdbot/clawdbot.json` exists, choose **Keep / Modify / Reset**.
|
||||||
|
- Re-running the wizard does **not** wipe anything unless you explicitly choose **Reset**
|
||||||
|
(or pass `--reset`).
|
||||||
- If the config is invalid or contains legacy keys, the wizard stops and asks
|
- If the config is invalid or contains legacy keys, the wizard stops and asks
|
||||||
you to run `clawdbot doctor` before continuing.
|
you to run `clawdbot doctor` before continuing.
|
||||||
- Reset uses `trash` (never `rm`) and offers scopes:
|
- Reset uses `trash` (never `rm`) and offers scopes:
|
||||||
@@ -77,7 +79,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` on the gateway host (the wizard can run it for you and reuse the token).
|
- **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 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/Chromium CDP startup issues for Clawdbot browser control on Linux"
|
summary: "Fix Chrome/Brave/Edge/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/Chromium with the error:
|
Clawdbot's browser control server fails to launch Chrome/Brave/Edge/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,8 +107,23 @@ 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 Chrome/Chromium binary | auto-detected |
|
| `browser.executablePath` | Path to a Chromium-based browser binary (Chrome/Brave/Edge/Chromium) | auto-detected (prefers default browser when Chromium-based) |
|
||||||
| `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` |
|
||||||
| `browser.cdpPort` | Chrome DevTools Protocol port | `18800` |
|
| `browser.cdpPort` | Chrome DevTools Protocol port | `18800` |
|
||||||
|
|
||||||
|
### Problem: "Chrome extension relay is running, but no tab is connected"
|
||||||
|
|
||||||
|
You’re using the `chrome` profile (extension relay). It expects the Clawdbot
|
||||||
|
browser extension to be attached to a live tab.
|
||||||
|
|
||||||
|
Fix options:
|
||||||
|
1. **Use the managed browser:** `clawdbot browser start --browser-profile clawd`
|
||||||
|
(or set `browser.defaultProfile: "clawd"`).
|
||||||
|
2. **Use the extension relay:** install the extension, open a tab, and click the
|
||||||
|
Clawdbot extension icon to attach it.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- The `chrome` profile uses your **system default Chromium browser** when possible.
|
||||||
|
- Local `clawd` profiles auto-assign `cdpPort`/`cdpUrl`; only set those for remote CDP.
|
||||||
+71
-17
@@ -8,14 +8,16 @@ read_when:
|
|||||||
|
|
||||||
# Browser (clawd-managed)
|
# Browser (clawd-managed)
|
||||||
|
|
||||||
Clawdbot can run a **dedicated Chrome/Chromium profile** that the agent controls.
|
Clawdbot can run a **dedicated Chrome/Brave/Edge/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 Chrome profile.
|
- The `clawd` profile does **not** touch your personal browser 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.
|
||||||
|
- The default `chrome` profile uses the **system default Chromium browser** via the
|
||||||
|
extension relay; switch to `clawd` for the isolated managed browser.
|
||||||
|
|
||||||
## What you get
|
## What you get
|
||||||
|
|
||||||
@@ -39,6 +41,14 @@ clawdbot browser --browser-profile clawd snapshot
|
|||||||
If you get “Browser disabled”, enable it in config (see below) and restart the
|
If you get “Browser disabled”, enable it in config (see below) and restart the
|
||||||
Gateway.
|
Gateway.
|
||||||
|
|
||||||
|
## Profiles: `clawd` vs `chrome`
|
||||||
|
|
||||||
|
- `clawd`: managed, isolated browser (no extension required).
|
||||||
|
- `chrome`: extension relay to your **system browser** (requires the Clawdbot
|
||||||
|
extension to be attached to a tab).
|
||||||
|
|
||||||
|
Set `browser.defaultProfile: "clawd"` if you want managed mode by default.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Browser settings live in `~/.clawdbot/clawdbot.json`.
|
Browser settings live in `~/.clawdbot/clawdbot.json`.
|
||||||
@@ -54,7 +64,7 @@ Browser settings live in `~/.clawdbot/clawdbot.json`.
|
|||||||
headless: false,
|
headless: false,
|
||||||
noSandbox: false,
|
noSandbox: false,
|
||||||
attachOnly: false,
|
attachOnly: false,
|
||||||
executablePath: "/Applications/Chromium.app/Contents/MacOS/Chromium",
|
executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
||||||
profiles: {
|
profiles: {
|
||||||
clawd: { cdpPort: 18800, color: "#FF4500" },
|
clawd: { cdpPort: 18800, color: "#FF4500" },
|
||||||
work: { cdpPort: 18801, color: "#0066CC" },
|
work: { cdpPort: 18801, color: "#0066CC" },
|
||||||
@@ -69,17 +79,55 @@ 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 Chrome; only attach if it is already running.”
|
- `attachOnly: true` means “never launch a local browser; 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.
|
||||||
|
- Default profile is `chrome` (extension relay). Use `defaultProfile: "clawd"` for the managed browser.
|
||||||
|
- Auto-detect order: system default browser if Chromium-based; otherwise Chrome → Brave → Edge → Chromium → Chrome Canary.
|
||||||
|
- Local `clawd` profiles auto-assign `cdpPort`/`cdpUrl` — set those only for remote CDP.
|
||||||
|
|
||||||
|
## Use Brave (or another Chromium-based browser)
|
||||||
|
|
||||||
|
If your **system default** browser is Chromium-based (Chrome/Brave/Edge/etc),
|
||||||
|
Clawdbot uses it automatically. Set `browser.executablePath` to override
|
||||||
|
auto-detection:
|
||||||
|
|
||||||
|
CLI example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot config set browser.executablePath "/usr/bin/google-chrome"
|
||||||
|
```
|
||||||
|
|
||||||
|
```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 Chrome.
|
The Gateway starts the control server and can launch a local browser.
|
||||||
- **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 Chrome. In this case, Clawdbot will not launch a local browser.
|
attach to a remote Chromium-based browser. In this case, Clawdbot will not launch a local browser.
|
||||||
|
|
||||||
## Remote browser (control server)
|
## Remote browser (control server)
|
||||||
|
|
||||||
@@ -88,7 +136,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 Chrome/Chromium via **CDP**.
|
- The **control server** speaks to Chromium-based browsers (Chrome/Brave/Edge/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.
|
||||||
|
|
||||||
@@ -104,14 +152,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 Chrome instance without a remote control server.
|
directly to a Chromium-based browser 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
|
# on the machine that runs Chrome/Brave/Edge
|
||||||
clawdbot browser serve --bind <browser-host> --port 18791 --token <token>
|
clawdbot browser serve --bind <browser-host> --port 18791 --token <token>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -198,8 +246,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 Chrome instance with its own user data directory + CDP port
|
- **clawd-managed**: a dedicated Chromium-based browser instance with its own user data directory + CDP port
|
||||||
- **remote**: an explicit CDP URL (Chrome running elsewhere)
|
- **remote**: an explicit CDP URL (Chromium-based browser 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:
|
||||||
@@ -264,22 +312,24 @@ Notes:
|
|||||||
|
|
||||||
## Isolation guarantees
|
## Isolation guarantees
|
||||||
|
|
||||||
- **Dedicated user data dir**: never touches your personal Chrome profile.
|
- **Dedicated user data dir**: never touches your personal browser 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 Canary
|
1. Chrome
|
||||||
2. Chromium
|
2. Brave
|
||||||
3. Chrome
|
3. Edge
|
||||||
|
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`, `chromium`, etc.
|
- Linux: looks for `google-chrome`, `brave`, `microsoft-edge`, `chromium`, etc.
|
||||||
- Windows: checks common install locations.
|
- Windows: checks common install locations.
|
||||||
|
|
||||||
## Control API (optional)
|
## Control API (optional)
|
||||||
@@ -309,11 +359,15 @@ Playwright. If Playwright isn’t installed, those endpoints return a clear 501
|
|||||||
error. ARIA snapshots and basic screenshots still work for clawd-managed Chrome.
|
error. ARIA snapshots and basic screenshots still work for clawd-managed Chrome.
|
||||||
For the Chrome extension relay driver, ARIA snapshots and screenshots require Playwright.
|
For the Chrome extension relay driver, ARIA snapshots and screenshots require Playwright.
|
||||||
|
|
||||||
|
If you see `Playwright is not available in this gateway build`, install the full
|
||||||
|
Playwright package (not `playwright-core`) and restart the gateway, or reinstall
|
||||||
|
Clawdbot with browser support.
|
||||||
|
|
||||||
## How it works (internal)
|
## How it works (internal)
|
||||||
|
|
||||||
High-level flow:
|
High-level flow:
|
||||||
- A small **control server** accepts HTTP requests.
|
- A small **control server** accepts HTTP requests.
|
||||||
- It connects to Chrome/Chromium via **CDP**.
|
- It connects to Chromium-based browsers (Chrome/Brave/Edge/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,7 +16,9 @@ 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,
|
||||||
@@ -42,6 +44,8 @@ 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
|
||||||
@@ -57,4 +61,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 new session.
|
- Changes to skills are picked up on the next agent turn when the watcher is enabled.
|
||||||
@@ -181,6 +181,29 @@ 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:
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026.1.15
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Version alignment with core Clawdbot release numbers.
|
||||||
|
|
||||||
## 2026.1.14
|
## 2026.1.14
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,11 +6,19 @@ const saveMediaBufferMock = vi.fn(async () => ({
|
|||||||
contentType: "image/png",
|
contentType: "image/png",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../media/mime.js", () => ({
|
const modulePaths = vi.hoisted(() => {
|
||||||
|
const downloadModuleUrl = new URL("./attachments/download.js", import.meta.url);
|
||||||
|
return {
|
||||||
|
mimeModulePath: new URL("../../../../src/media/mime.js", downloadModuleUrl).pathname,
|
||||||
|
storeModulePath: new URL("../../../../src/media/store.js", downloadModuleUrl).pathname,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock(modulePaths.mimeModulePath, () => ({
|
||||||
detectMime: (...args: unknown[]) => detectMimeMock(...args),
|
detectMime: (...args: unknown[]) => detectMimeMock(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../media/store.js", () => ({
|
vi.mock(modulePaths.storeModulePath, () => ({
|
||||||
saveMediaBuffer: (...args: unknown[]) => saveMediaBufferMock(...args),
|
saveMediaBuffer: (...args: unknown[]) => saveMediaBufferMock(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
File renamed without changes.
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
import { detectMime } from "../../media/mime.js";
|
import { detectMime } from "../../../../src/media/mime.js";
|
||||||
import { saveMediaBuffer } from "../../media/store.js";
|
import { saveMediaBuffer } from "../../../../src/media/store.js";
|
||||||
import {
|
import {
|
||||||
extractInlineImageCandidates,
|
extractInlineImageCandidates,
|
||||||
inferPlaceholder,
|
inferPlaceholder,
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { detectMime } from "../../media/mime.js";
|
import { detectMime } from "../../../../src/media/mime.js";
|
||||||
import { saveMediaBuffer } from "../../media/store.js";
|
import { saveMediaBuffer } from "../../../../src/media/store.js";
|
||||||
import { downloadMSTeamsImageAttachments } from "./download.js";
|
import { downloadMSTeamsImageAttachments } from "./download.js";
|
||||||
import { GRAPH_ROOT, isRecord, normalizeContentType, resolveAllowedHosts } from "./shared.js";
|
import { GRAPH_ROOT, isRecord, normalizeContentType, resolveAllowedHosts } from "./shared.js";
|
||||||
import type {
|
import type {
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,12 +1,12 @@
|
|||||||
import { chunkMarkdownText } from "../../auto-reply/chunk.js";
|
import type { ClawdbotConfig } from "../../../src/config/config.js";
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
|
||||||
import { createMSTeamsPollStoreFs } from "../../msteams/polls.js";
|
import { PAIRING_APPROVED_MESSAGE } from "../../../src/channels/plugins/pairing-message.js";
|
||||||
import { sendMessageMSTeams, sendPollMSTeams } from "../../msteams/send.js";
|
import type { ChannelMessageActionName, ChannelPlugin } from "../../../src/channels/plugins/types.js";
|
||||||
import { resolveMSTeamsCredentials } from "../../msteams/token.js";
|
|
||||||
import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
|
import { msteamsOnboardingAdapter } from "./onboarding.js";
|
||||||
import { msteamsOnboardingAdapter } from "./onboarding/msteams.js";
|
import { msteamsOutbound } from "./outbound.js";
|
||||||
import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js";
|
import { sendMessageMSTeams } from "./send.js";
|
||||||
import type { ChannelMessageActionName, ChannelPlugin } from "./types.js";
|
import { resolveMSTeamsCredentials } from "./token.js";
|
||||||
|
|
||||||
type ResolvedMSTeamsAccount = {
|
type ResolvedMSTeamsAccount = {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
@@ -17,10 +17,12 @@ type ResolvedMSTeamsAccount = {
|
|||||||
const meta = {
|
const meta = {
|
||||||
id: "msteams",
|
id: "msteams",
|
||||||
label: "Microsoft Teams",
|
label: "Microsoft Teams",
|
||||||
selectionLabel: "Microsoft Teams (Bot)",
|
selectionLabel: "Microsoft Teams (Bot Framework)",
|
||||||
docsPath: "/msteams",
|
docsPath: "/channels/msteams",
|
||||||
docsLabel: "msteams",
|
docsLabel: "msteams",
|
||||||
blurb: "bot via Microsoft Teams.",
|
blurb: "Bot Framework; enterprise support.",
|
||||||
|
aliases: ["teams"],
|
||||||
|
order: 60,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
||||||
@@ -120,58 +122,7 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
|||||||
return ["poll"] satisfies ChannelMessageActionName[];
|
return ["poll"] satisfies ChannelMessageActionName[];
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
outbound: {
|
outbound: msteamsOutbound,
|
||||||
deliveryMode: "direct",
|
|
||||||
chunker: chunkMarkdownText,
|
|
||||||
textChunkLimit: 4000,
|
|
||||||
pollMaxOptions: 12,
|
|
||||||
resolveTarget: ({ to }) => {
|
|
||||||
const trimmed = to?.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
error: new Error(
|
|
||||||
"Delivering to MS Teams requires --to <conversationId|user:ID|conversation:ID>",
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { ok: true, to: trimmed };
|
|
||||||
},
|
|
||||||
sendText: async ({ cfg, to, text, deps }) => {
|
|
||||||
const send = deps?.sendMSTeams ?? ((to, text) => sendMessageMSTeams({ cfg, to, text }));
|
|
||||||
const result = await send(to, text);
|
|
||||||
return { channel: "msteams", ...result };
|
|
||||||
},
|
|
||||||
sendMedia: async ({ cfg, to, text, mediaUrl, deps }) => {
|
|
||||||
const send =
|
|
||||||
deps?.sendMSTeams ??
|
|
||||||
((to, text, opts) => sendMessageMSTeams({ cfg, to, text, mediaUrl: opts?.mediaUrl }));
|
|
||||||
const result = await send(to, text, { mediaUrl });
|
|
||||||
return { channel: "msteams", ...result };
|
|
||||||
},
|
|
||||||
sendPoll: async ({ cfg, to, poll }) => {
|
|
||||||
const maxSelections = poll.maxSelections ?? 1;
|
|
||||||
const result = await sendPollMSTeams({
|
|
||||||
cfg,
|
|
||||||
to,
|
|
||||||
question: poll.question,
|
|
||||||
options: poll.options,
|
|
||||||
maxSelections,
|
|
||||||
});
|
|
||||||
const pollStore = createMSTeamsPollStoreFs();
|
|
||||||
await pollStore.createPoll({
|
|
||||||
id: result.pollId,
|
|
||||||
question: poll.question,
|
|
||||||
options: poll.options,
|
|
||||||
maxSelections,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
conversationId: result.conversationId,
|
|
||||||
messageId: result.messageId,
|
|
||||||
votes: {},
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
status: {
|
status: {
|
||||||
defaultRuntime: {
|
defaultRuntime: {
|
||||||
accountId: DEFAULT_ACCOUNT_ID,
|
accountId: DEFAULT_ACCOUNT_ID,
|
||||||
@@ -204,7 +155,7 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
|||||||
},
|
},
|
||||||
gateway: {
|
gateway: {
|
||||||
startAccount: async (ctx) => {
|
startAccount: async (ctx) => {
|
||||||
const { monitorMSTeamsProvider } = await import("../../msteams/index.js");
|
const { monitorMSTeamsProvider } = await import("./index.js");
|
||||||
const port = ctx.cfg.channels?.msteams?.webhook?.port ?? 3978;
|
const port = ctx.cfg.channels?.msteams?.webhook?.port ?? 3978;
|
||||||
ctx.setStatus({ accountId: ctx.accountId, port });
|
ctx.setStatus({ accountId: ctx.accountId, port });
|
||||||
ctx.log?.info(`starting provider (port ${port})`);
|
ctx.log?.info(`starting provider (port ${port})`);
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
import { SILENT_REPLY_TOKEN } from "../../../src/auto-reply/tokens.js";
|
||||||
import type { StoredConversationReference } from "./conversation-store.js";
|
import type { StoredConversationReference } from "./conversation-store.js";
|
||||||
import {
|
import {
|
||||||
type MSTeamsAdapter,
|
type MSTeamsAdapter,
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { chunkMarkdownText } from "../auto-reply/chunk.js";
|
import { chunkMarkdownText } from "../../../src/auto-reply/chunk.js";
|
||||||
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../../src/auto-reply/tokens.js";
|
||||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
import type { ReplyPayload } from "../../../src/auto-reply/types.js";
|
||||||
import type { MSTeamsReplyStyle } from "../config/types.js";
|
import type { MSTeamsReplyStyle } from "../../../src/config/types.js";
|
||||||
import type { StoredConversationReference } from "./conversation-store.js";
|
import type { StoredConversationReference } from "./conversation-store.js";
|
||||||
import { classifyMSTeamsSendError } from "./errors.js";
|
import { classifyMSTeamsSendError } from "./errors.js";
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ClawdbotConfig } from "../config/types.js";
|
import type { ClawdbotConfig } from "../../../src/config/types.js";
|
||||||
import { danger } from "../globals.js";
|
import { danger } from "../../../src/globals.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../../../src/runtime.js";
|
||||||
import type { MSTeamsConversationStore } from "./conversation-store.js";
|
import type { MSTeamsConversationStore } from "./conversation-store.js";
|
||||||
import type { MSTeamsAdapter } from "./messenger.js";
|
import type { MSTeamsAdapter } from "./messenger.js";
|
||||||
import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.js";
|
import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.js";
|
||||||
File renamed without changes.
Loaded 100 of 307 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user