Compare commits

..
Author SHA1 Message Date
Peter Steinberger 432d1481dc fix: add nextcloud talk manifest (#1297) (thanks @ysqander) 2026-01-20 11:37:46 +00:00
Peter Steinberger c9e3c14f9c fix: finalize exec fish fallback (#1297) (thanks @ysqander) 2026-01-20 11:25:49 +00:00
Peter Steinberger 636a8e3181 fix: merge plugin manifest types 2026-01-20 11:22:28 +00:00
Peter Steinberger 660f87278c refactor: plugin catalog + nextcloud policy 2026-01-20 11:22:27 +00:00
Peter Steinberger 9ec1fb4a80 Merge pull request #1297 from ysqander/fix/fish-shell-bash
exec: prefer bash when fish is default shell
2026-01-20 11:13:35 +00:00
Peter Steinberger 74757cd5af fix: stabilize gateway defaults 2026-01-20 11:11:26 +00:00
Peter Steinberger 9f75550702 Merge pull request #1294 from bradleypriest/pr/sessions-labels
ui(sessions): support editing session labels
2026-01-20 11:05:46 +00:00
Peter Steinberger 8214ab507c Merge pull request #1272 from clawdbot/shadow/config-plugin-validation
Config: validate plugin config
2026-01-20 11:03:38 +00:00
Shadowandthewilloftheshadow 2f6d5805de fix: enforce plugin config schemas (#1272) (thanks @thewilloftheshadow)
Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
2026-01-20 11:03:17 +00:00
Peter Steinberger 48f733e4b3 refactor: use command lane enum 2026-01-20 10:51:25 +00:00
Peter Steinberger e5f7435d9f fix: sync device scopes on token rotation 2026-01-20 10:46:33 +00:00
Peter Steinberger 322c8dc4fc Merge pull request #1208 from 24601/fix/slack-bolt-import
fix(slack): handle bolt import for CJS/ESM compatibility
2026-01-20 10:46:27 +00:00
Peter Steinberger cf04b0e3bf fix: align gateway presence + config defaults tests (#1208) (thanks @24601) 2026-01-20 10:45:59 +00:00
Peter Steinberger 0f9f510dd9 refactor: centralize concurrency resolution 2026-01-20 10:41:56 +00:00
Peter Steinberger e110cf4fb1 chore: keep vitest workers at 4 2026-01-20 10:41:09 +00:00
Peter Steinberger 6942ceb7a9 test: update gateway node/e2e tests 2026-01-20 10:41:09 +00:00
Peter Steinberger 47cf28f6b6 fix: prevent duplicate cron runs across hot reloads 2026-01-20 10:36:46 +00:00
Peter Steinberger 115b4379bf fix: handle Slack Bolt import + gateway node ids (#1208) (thanks @24601) 2026-01-20 10:33:40 +00:00
Basit Mustafa a6db1edee3 test(slack): mock HTTPReceiver 2026-01-20 10:33:01 +00:00
Basit Mustafa 4ed1b7c7ed fix(slack): resolve bolt constructors 2026-01-20 10:33:01 +00:00
Basit Mustafa 7ef7b94bc0 fix(slack): handle bolt import for CJS/ESM compatibility 2026-01-20 10:33:01 +00:00
Peter Steinberger da7da30b22 style: format agent defaults import 2026-01-20 10:31:16 +00:00
Peter Steinberger 213d9b47b0 refactor: centralize agent concurrency defaults 2026-01-20 10:31:16 +00:00
Peter Steinberger d88b239d3c feat: add device token auth and devices cli 2026-01-20 10:30:53 +00:00
Peter Steinberger 1c02de1309 chore: raise default agent concurrency 2026-01-20 10:08:26 +00:00
Peter Steinbergerandhumanwritten d3c2b83f88 fix: avoid context-window-too-small misclassification (#1266, thanks @humanwritten)
Co-authored-by: humanwritten <humanwritten@users.noreply.github.com>
2026-01-20 10:07:05 +00:00
humanwrittenandClaude 93f80894a3 fix: prevent context-window-too-small error from being misclassified
The regex `/context window/i` was matching "Model context window too small"
and rewriting it to generic "Context overflow" message, hiding the actual
problem from users.

Add exclusion for "too small|minimum is" patterns so the original
informative error message passes through.

🤖 AI-assisted (Claude) - tested on local instance

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-20 10:07:05 +00:00
Peter Steinberger c440cc2f84 fix: preserve gateway presence instanceId 2026-01-20 09:52:26 +00:00
ysqander 8ddedc3fc5 exec: prefer bash when fish is default shell 2026-01-20 17:42:02 +08:00
Peter Steinberger 2439c31844 chore: tune vitest parallelism 2026-01-20 09:40:10 +00:00
Peter Steinberger 292f21ae78 docs: add changelog entry for codex-cli dedupe 2026-01-20 09:38:56 +00:00
Peter SteinbergerandOliver Drobnik 9c2c4b1138 fix(auth): dedupe codex-cli profiles
Co-authored-by: Oliver Drobnik <oliver@cocoanetics.com>
2026-01-20 09:38:56 +00:00
George Pickett e0e33e12d1 Docs: frame skills eval gap in testing 2026-01-20 09:37:59 +00:00
Peter Steinberger eb5145c5d1 docs: mention tool_result_persist hook 2026-01-20 09:36:10 +00:00
Peter SteinbergerandZach Knickerbocker aedf4ce328 fix: treat OAuth refresh failures as auth errors (#1261) (thanks @zknicker)
Co-authored-by: Zach Knickerbocker <zknicker@users.noreply.github.com>
2026-01-20 09:31:41 +00:00
Peter Steinberger 94af5a72fc fix: prevent duplicate agent event emission 2026-01-20 09:25:20 +00:00
Peter Steinberger 9dbc1435a6 fix: enforce ws3 roles + node allowlist 2026-01-20 09:24:01 +00:00
Peter Steinberger 32a668e4d9 refactor: streamline TUI stream assembly updates 2026-01-20 08:57:42 +00:00
Peter Steinberger c17c7b4e24 Merge pull request #1235 from dougvk/feat/tool-dispatch-skill-commands
Plugin API: tool-dispatched skill commands + tool_result_persist hook
2026-01-20 08:52:05 +00:00
Peter Steinberger c5e732951b fix: prefer bundled plugin schema 2026-01-20 08:47:56 +00:00
Peter Steinberger 069b50635b fix: lazy-load pdf/image input deps 2026-01-20 08:47:56 +00:00
Bradley Priest 51f1f23235 ui(sessions): support editing session labels
Expose session "label" as an editable field in the Sessions view and persist changes via sessions.patch.
2026-01-20 21:07:41 +13:00
Doug von Kohorn c3a34408f3 feat: add tool_result_persist hook 2026-01-19 13:11:31 +01:00
Doug von Kohorn 9f280454ba feat: tool-dispatch skill commands 2026-01-19 13:11:25 +01:00
178 changed files with 10195 additions and 1281 deletions

No files matched your search

+4 -2
View File
@@ -6,12 +6,14 @@ Docs: https://docs.clawd.bot
### Changes ### Changes
- Repo: remove the Peekaboo git submodule now that the SPM release is used. - Repo: remove the Peekaboo git submodule now that the SPM release is used.
- Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow.
- Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel.
### Fixes ### Fixes
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter). - Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs. - TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.
- CLI: avoid duplicating --profile/--dev flags when formatting commands. - CLI: avoid duplicating --profile/--dev flags when formatting commands.
- Agents: enforce tool policy/sandbox rules for tool-dispatched skill commands and forward tool media outputs. (#1235) — thanks @dougvk. - Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297) — thanks @ysqander.
- Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297) — thanks @ysqander.
## 2026.1.19-3 ## 2026.1.19-3
+21 -19
View File
@@ -471,24 +471,26 @@ Thanks to all clawtributors:
<p align="left"> <p align="left">
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a>
<a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a>
<a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a>
<a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a>
<a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a>
<a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/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/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a>
<a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a>
<a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/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/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a>
<a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/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/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a>
<a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/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/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a>
<a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a>
<a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a>
<a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a>
<a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/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/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a>
<a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a>
<a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a>
<a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a>
<a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=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/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a>
<a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a>
<a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a>
<a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
</p> </p>
File diff suppressed because it is too large. Load diff
+1
View File
@@ -19,6 +19,7 @@ Text is supported everywhere; media and reactions vary by channel.
- [iMessage](/channels/imessage) — macOS only; native integration. - [iMessage](/channels/imessage) — macOS only; native integration.
- [BlueBubbles](/channels/bluebubbles) — iMessage via BlueBubbles macOS server (bundled plugin, disabled by default). - [BlueBubbles](/channels/bluebubbles) — iMessage via BlueBubbles macOS server (bundled plugin, disabled by default).
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately). - [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately).
- [Nextcloud Talk](/channels/nextcloud-talk) — Self-hosted chat via Nextcloud Talk (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).
- [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately). - [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately).
+119
View File
@@ -0,0 +1,119 @@
---
summary: "Nextcloud Talk support status, capabilities, and configuration"
read_when:
- Working on Nextcloud Talk channel features
---
# Nextcloud Talk (plugin)
Status: supported via plugin (webhook bot). Direct messages, rooms, reactions, and markdown messages are supported.
## Plugin required
Nextcloud Talk ships as a plugin and is not bundled with the core install.
Install via CLI (npm registry):
```bash
clawdbot plugins install @clawdbot/nextcloud-talk
```
Local checkout (when running from a git repo):
```bash
clawdbot plugins install ./extensions/nextcloud-talk
```
If you choose Nextcloud Talk during configure/onboarding and a git checkout is detected,
Clawdbot will offer the local install path automatically.
Details: [Plugins](/plugin)
## Quick setup (beginner)
1) Install the Nextcloud Talk plugin.
2) On your Nextcloud server, create a bot:
```bash
./occ talk:bot:install "Clawdbot" "<shared-secret>" "<webhook-url>" --feature reaction
```
3) Enable the bot in the target room settings.
4) Configure Clawdbot:
- Config: `channels.nextcloud-talk.baseUrl` + `channels.nextcloud-talk.botSecret`
- Or env: `NEXTCLOUD_TALK_BOT_SECRET` (default account only)
5) Restart the gateway (or finish onboarding).
Minimal config:
```json5
{
channels: {
"nextcloud-talk": {
enabled: true,
baseUrl: "https://cloud.example.com",
botSecret: "shared-secret",
dmPolicy: "pairing"
}
}
}
```
## Notes
- Bots cannot initiate DMs. The user must message the bot first.
- Webhook URL must be reachable by the Gateway; set `webhookPublicUrl` if behind a proxy.
- Media uploads are not supported by the bot API; media is sent as URLs.
- The webhook payload does not distinguish DMs vs rooms; set `apiUser` + `apiPassword` to enable room-type lookups (otherwise DMs are treated as rooms).
## Access control (DMs)
- Default: `channels.nextcloud-talk.dmPolicy = "pairing"`. Unknown senders get a pairing code.
- Approve via:
- `clawdbot pairing list nextcloud-talk`
- `clawdbot pairing approve nextcloud-talk <CODE>`
- Public DMs: `channels.nextcloud-talk.dmPolicy="open"` plus `channels.nextcloud-talk.allowFrom=["*"]`.
## Rooms (groups)
- Default: `channels.nextcloud-talk.groupPolicy = "allowlist"` (mention-gated).
- Allowlist rooms with `channels.nextcloud-talk.rooms`:
```json5
{
channels: {
"nextcloud-talk": {
rooms: {
"room-token": { requireMention: true }
}
}
}
}
```
- To allow no rooms, keep the allowlist empty or set `channels.nextcloud-talk.groupPolicy="disabled"`.
## Capabilities
| Feature | Status |
|---------|--------|
| Direct messages | Supported |
| Rooms | Supported |
| Threads | Not supported |
| Media | URL-only |
| Reactions | Supported |
| Native commands | Not supported |
## Configuration reference (Nextcloud Talk)
Full configuration: [Configuration](/gateway/configuration)
Provider options:
- `channels.nextcloud-talk.enabled`: enable/disable channel startup.
- `channels.nextcloud-talk.baseUrl`: Nextcloud instance URL.
- `channels.nextcloud-talk.botSecret`: bot shared secret.
- `channels.nextcloud-talk.botSecretFile`: secret file path.
- `channels.nextcloud-talk.apiUser`: API user for room lookups (DM detection).
- `channels.nextcloud-talk.apiPassword`: API/app password for room lookups.
- `channels.nextcloud-talk.apiPasswordFile`: API password file path.
- `channels.nextcloud-talk.webhookPort`: webhook listener port (default: 8788).
- `channels.nextcloud-talk.webhookHost`: webhook host (default: 0.0.0.0).
- `channels.nextcloud-talk.webhookPath`: webhook path (default: /nextcloud-talk-webhook).
- `channels.nextcloud-talk.webhookPublicUrl`: externally reachable webhook URL.
- `channels.nextcloud-talk.dmPolicy`: `pairing | allowlist | open | disabled`.
- `channels.nextcloud-talk.allowFrom`: DM allowlist (user IDs). `open` requires `"*"`.
- `channels.nextcloud-talk.groupPolicy`: `allowlist | open | disabled`.
- `channels.nextcloud-talk.groupAllowFrom`: group allowlist (user IDs).
- `channels.nextcloud-talk.rooms`: per-room settings and allowlist.
- `channels.nextcloud-talk.historyLimit`: group history limit (0 disables).
- `channels.nextcloud-talk.dmHistoryLimit`: DM history limit (0 disables).
- `channels.nextcloud-talk.dms`: per-DM overrides (historyLimit).
- `channels.nextcloud-talk.textChunkLimit`: outbound text chunk size (chars).
- `channels.nextcloud-talk.blockStreaming`: disable block streaming for this channel.
- `channels.nextcloud-talk.blockStreamingCoalesce`: block streaming coalesce tuning.
- `channels.nextcloud-talk.mediaMaxMb`: inbound media cap (MB).
+66
View File
@@ -0,0 +1,66 @@
---
summary: "CLI reference for `clawdbot devices` (device pairing + token rotation/revocation)"
read_when:
- You are approving device pairing requests
- You need to rotate or revoke device tokens
---
# `clawdbot devices`
Manage device pairing requests and device-scoped tokens.
## Commands
### `clawdbot devices list`
List pending pairing requests and paired devices.
```
clawdbot devices list
clawdbot devices list --json
```
### `clawdbot devices approve <requestId>`
Approve a pending device pairing request.
```
clawdbot devices approve <requestId>
```
### `clawdbot devices reject <requestId>`
Reject a pending device pairing request.
```
clawdbot devices reject <requestId>
```
### `clawdbot devices rotate --device <id> --role <role> [--scope <scope...>]`
Rotate a device token for a specific role (optionally updating scopes).
```
clawdbot devices rotate --device <deviceId> --role operator --scope operator.read --scope operator.write
```
### `clawdbot devices revoke --device <id> --role <role>`
Revoke a device token for a specific role.
```
clawdbot devices revoke --device <deviceId> --role node
```
## Common options
- `--url <url>`: Gateway WebSocket URL (defaults to `gateway.remote.url` when configured).
- `--token <token>`: Gateway token (if required).
- `--password <password>`: Gateway password (password auth).
- `--timeout <ms>`: RPC timeout.
- `--json`: JSON output (recommended for scripting).
## Notes
- Token rotation returns a new token (sensitive). Treat it like a secret.
- These commands require `operator.pairing` (or `operator.admin`) scope.
+3 -2
View File
@@ -116,17 +116,18 @@ clawdbot gateway call logs.tail --params '{"sinceMs": 60000}'
## Discover gateways (Bonjour) ## Discover gateways (Bonjour)
`gateway discover` scans for Gateway bridge beacons (`_clawdbot-bridge._tcp`). `gateway discover` scans for Gateway beacons (`_clawdbot-gateway._tcp`).
- Multicast DNS-SD: `local.` - Multicast DNS-SD: `local.`
- Unicast DNS-SD (Wide-Area Bonjour): `clawdbot.internal.` (requires split DNS + DNS server; see [/gateway/bonjour](/gateway/bonjour)) - Unicast DNS-SD (Wide-Area Bonjour): `clawdbot.internal.` (requires split DNS + DNS server; see [/gateway/bonjour](/gateway/bonjour))
Only gateways with the **bridge enabled** will advertise the discovery beacon. Only gateways with Bonjour discovery enabled (default) advertise the beacon.
Wide-Area discovery records include (TXT): Wide-Area discovery records include (TXT):
- `gatewayPort` (WebSocket port, usually `18789`) - `gatewayPort` (WebSocket port, usually `18789`)
- `sshPort` (SSH port; defaults to `22` if not present) - `sshPort` (SSH port; defaults to `22` if not present)
- `tailnetDns` (MagicDNS hostname, when available) - `tailnetDns` (MagicDNS hostname, when available)
- `gatewayTls` / `gatewayTlsSha256` (TLS enabled + cert fingerprint)
- `cliPath` (optional hint for remote installs) - `cliPath` (optional hint for remote installs)
### `gateway discover` ### `gateway discover`
+2
View File
@@ -34,6 +34,7 @@ This page describes the current CLI behavior. If commands change, update this do
- [`models`](/cli/models) - [`models`](/cli/models)
- [`memory`](/cli/memory) - [`memory`](/cli/memory)
- [`nodes`](/cli/nodes) - [`nodes`](/cli/nodes)
- [`devices`](/cli/devices)
- [`node`](/cli/node) - [`node`](/cli/node)
- [`approvals`](/cli/approvals) - [`approvals`](/cli/approvals)
- [`sandbox`](/cli/sandbox) - [`sandbox`](/cli/sandbox)
@@ -188,6 +189,7 @@ clawdbot [--dev] [--profile <name>] <command>
runs runs
run run
nodes nodes
devices
node node
start start
daemon daemon
+5
View File
@@ -11,6 +11,7 @@ Manage Gateway plugins/extensions (loaded in-process).
Related: Related:
- Plugin system: [Plugins](/plugin) - Plugin system: [Plugins](/plugin)
- Plugin manifest + schema: [Plugin manifest](/plugins/manifest)
- Security hardening: [Security](/gateway/security) - Security hardening: [Security](/gateway/security)
## Commands ## Commands
@@ -28,6 +29,10 @@ clawdbot plugins update --all
Bundled plugins ship with Clawdbot but start disabled. Use `plugins enable` to Bundled plugins ship with Clawdbot but start disabled. Use `plugins enable` to
activate them. activate them.
All plugins must ship a `clawdbot.plugin.json` file with an inline JSON Schema
(`configSchema`, even if empty). Missing/invalid manifests or schemas prevent
the plugin from loading and fail config validation.
### Install ### Install
```bash ```bash
+1 -1
View File
@@ -12,7 +12,7 @@ We serialize inbound auto-reply runs (all channels) through a tiny in-process qu
- Serializing avoids competing for shared resources (session files, logs, CLI stdin) and reduces the chance of upstream rate limits. - Serializing avoids competing for shared resources (session files, logs, CLI stdin) and reduces the chance of upstream rate limits.
## How it works ## How it works
- A lane-aware FIFO queue drains each lane with a configurable concurrency cap (default 1). - A lane-aware FIFO queue drains each lane with a configurable concurrency cap (default 1 for unconfigured lanes; main defaults to 4, subagent to 8).
- `runEmbeddedPiAgent` enqueues by **session key** (lane `session:<key>`) to guarantee only one active run per session. - `runEmbeddedPiAgent` enqueues by **session key** (lane `session:<key>`) to guarantee only one active run per session.
- Each session run is then queued into a **global lane** (`main` by default) so overall parallelism is capped by `agents.defaults.maxConcurrent`. - Each session run is then queued into a **global lane** (`main` by default) so overall parallelism is capped by `agents.defaults.maxConcurrent`.
- When verbose logging is enabled, queued runs emit a short notice if they waited more than ~2s before starting. - When verbose logging is enabled, queued runs emit a short notice if they waited more than ~2s before starting.
+29 -3
View File
@@ -1840,7 +1840,7 @@ Example:
`agents.defaults.subagents` configures sub-agent defaults: `agents.defaults.subagents` configures sub-agent defaults:
- `model`: default model for spawned sub-agents (string or `{ primary, fallbacks }`). If omitted, sub-agents inherit the caller’s model unless overridden per agent or per call. - `model`: default model for spawned sub-agents (string or `{ primary, fallbacks }`). If omitted, sub-agents inherit the caller’s model unless overridden per agent or per call.
- `maxConcurrent`: max concurrent sub-agent runs (default 1) - `maxConcurrent`: max concurrent sub-agent runs (default 8)
- `archiveAfterMinutes`: auto-archive sub-agent sessions after N minutes (default 60; set `0` to disable) - `archiveAfterMinutes`: auto-archive sub-agent sessions after N minutes (default 60; set `0` to disable)
- Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny` (deny wins) - Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny` (deny wins)
@@ -1974,7 +1974,7 @@ Notes:
`agents.defaults.maxConcurrent` sets the maximum number of embedded agent runs that can `agents.defaults.maxConcurrent` sets the maximum number of embedded agent runs that can
execute in parallel across sessions. Each session is still serialized (one run execute in parallel across sessions. Each session is still serialized (one run
per session key at a time). Default: 1. per session key at a time). Default: 4.
### `agents.defaults.sandbox` ### `agents.defaults.sandbox`
@@ -2677,7 +2677,7 @@ Notes:
Auth and Tailscale: Auth and Tailscale:
- `gateway.auth.mode` sets the handshake requirements (`token` or `password`). - `gateway.auth.mode` sets the handshake requirements (`token` or `password`).
- `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine). - `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine and as the bootstrap credential for device pairing).
- When `gateway.auth.mode` is set, only that method is accepted (plus optional Tailscale headers). - When `gateway.auth.mode` is set, only that method is accepted (plus optional Tailscale headers).
- `gateway.auth.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended). - `gateway.auth.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended).
- `gateway.auth.allowTailscale` allows Tailscale Serve identity headers - `gateway.auth.allowTailscale` allows Tailscale Serve identity headers
@@ -2686,6 +2686,9 @@ Auth and Tailscale:
`true`, Serve requests do not need a token/password; set `false` to require `true`, Serve requests do not need a token/password; set `false` to require
explicit credentials. Defaults to `true` when `tailscale.mode = "serve"` and explicit credentials. Defaults to `true` when `tailscale.mode = "serve"` and
auth mode is not `password`. auth mode is not `password`.
- After pairing, the Gateway issues **device tokens** scoped to the device role + scopes.
These are returned in `hello-ok.auth.deviceToken`; clients should persist and reuse them
instead of the shared token. Rotate/revoke via `device.token.rotate`/`device.token.revoke`.
- `gateway.tailscale.mode: "serve"` uses Tailscale Serve (tailnet only, loopback bind). - `gateway.tailscale.mode: "serve"` uses Tailscale Serve (tailnet only, loopback bind).
- `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth. - `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth.
- `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown. - `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown.
@@ -2713,6 +2716,29 @@ macOS app behavior:
} }
``` ```
### `gateway.nodes` (Node command allowlist)
The Gateway enforces a per-platform command allowlist for `node.invoke`. Nodes must both
**declare** a command and have it **allowed** by the Gateway to run it.
Use this section to extend or deny commands:
```json5
{
gateway: {
nodes: {
allowCommands: ["custom.vendor.command"], // extra commands beyond defaults
denyCommands: ["sms.send"] // block a command even if declared
}
}
}
```
Notes:
- `allowCommands` extends the built-in per-platform defaults.
- `denyCommands` always wins (even if the node claims the command).
- `node.invoke` rejects commands that are not declared by the node.
### `gateway.reload` (Config hot reload) ### `gateway.reload` (Config hot reload)
The Gateway watches `~/.clawdbot/clawdbot.json` (or `CLAWDBOT_CONFIG_PATH`) and applies changes automatically. The Gateway watches `~/.clawdbot/clawdbot.json` (or `CLAWDBOT_CONFIG_PATH`) and applies changes automatically.
+23
View File
@@ -59,6 +59,18 @@ Gateway → Client:
} }
``` ```
When a device token is issued, `hello-ok` also includes:
```json
{
"auth": {
"deviceToken": "…",
"role": "operator",
"scopes": ["operator.read", "operator.write"]
}
}
```
### Node example ### Node example
```json ```json
@@ -123,6 +135,11 @@ Nodes declare capability claims at connect time:
The Gateway treats these as **claims** and enforces server-side allowlists. The Gateway treats these as **claims** and enforces server-side allowlists.
### Node helper methods
- Nodes may call `skills.bins` to fetch the current list of skill executables
for auto-allow checks.
## Versioning ## Versioning
- `PROTOCOL_VERSION` lives in `src/gateway/protocol/schema.ts`. - `PROTOCOL_VERSION` lives in `src/gateway/protocol/schema.ts`.
@@ -136,6 +153,11 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
- If `CLAWDBOT_GATEWAY_TOKEN` (or `--token`) is set, `connect.params.auth.token` - If `CLAWDBOT_GATEWAY_TOKEN` (or `--token`) is set, `connect.params.auth.token`
must match or the socket is closed. must match or the socket is closed.
- After pairing, the Gateway issues a **device token** scoped to the connection
role + scopes. It is returned in `hello-ok.auth.deviceToken` and should be
persisted by the client for future connects.
- Device tokens can be rotated/revoked via `device.token.rotate` and
`device.token.revoke` (requires `operator.pairing` scope).
## Device identity + pairing ## Device identity + pairing
@@ -144,6 +166,7 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
- Gateways issue tokens per device + role. - Gateways issue tokens per device + role.
- Pairing approvals are required for new device IDs unless local auto-approval - Pairing approvals are required for new device IDs unless local auto-approval
is enabled. is enabled.
- All WS clients must include `device` identity during `connect` (operator + node).
## TLS + pinning ## TLS + pinning
+6
View File
@@ -235,6 +235,12 @@ Triggered when the gateway starts:
- **`gateway:startup`**: After channels start and hooks are loaded - **`gateway:startup`**: After channels start and hooks are loaded
### Tool Result Hooks (Plugin API)
These hooks are not event-stream listeners; they let plugins synchronously adjust tool results before Clawdbot persists them.
- **`tool_result_persist`**: transform tool results before they are written to the session transcript. Must be synchronous; return the updated tool result payload or `undefined` to keep it as-is. See [Agent Loop](/concepts/agent-loop).
### Future Events ### Future Events
Planned event types: Planned event types:
+64 -14
View File
@@ -48,8 +48,11 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
- Qwen OAuth (provider auth) — bundled as `qwen-portal-auth` (disabled by default) - Qwen OAuth (provider auth) — bundled as `qwen-portal-auth` (disabled by default)
- Copilot Proxy (provider auth) — local VS Code Copilot Proxy bridge; distinct from built-in `github-copilot` device login (bundled, disabled by default) - Copilot Proxy (provider auth) — local VS Code Copilot Proxy bridge; distinct from built-in `github-copilot` device login (bundled, disabled by default)
Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. **Config
register: validation does not execute plugin code**; it uses the plugin manifest and JSON
Schema instead. See [Plugin manifest](/plugins/manifest).
Plugins can register:
- Gateway RPC methods - Gateway RPC methods
- Gateway HTTP handlers - Gateway HTTP handlers
@@ -83,6 +86,10 @@ Bundled plugins must be enabled explicitly via `plugins.entries.<id>.enabled`
or `clawdbot plugins enable <id>`. Installed plugins are enabled by default, or `clawdbot plugins enable <id>`. Installed plugins are enabled by default,
but can be disabled the same way. but can be disabled the same way.
Each plugin must include a `clawdbot.plugin.json` file in its root. If a path
points at a file, the plugin root is the file's directory and must contain the
manifest.
If multiple plugins resolve to the same id, the first match in the order above If multiple plugins resolve to the same id, the first match in the order above
wins and lower-precedence copies are ignored. wins and lower-precedence copies are ignored.
@@ -105,6 +112,37 @@ becomes `name/<fileBase>`.
If your plugin imports npm deps, install them in that directory so If your plugin imports npm deps, install them in that directory so
`node_modules` is available (`npm install` / `pnpm install`). `node_modules` is available (`npm install` / `pnpm install`).
### Channel catalog metadata
Channel plugins can advertise onboarding metadata via `clawdbot.channel` and
install hints via `clawdbot.install`. This keeps the core catalog data-free.
Example:
```json
{
"name": "@clawdbot/nextcloud-talk",
"clawdbot": {
"extensions": ["./index.ts"],
"channel": {
"id": "nextcloud-talk",
"label": "Nextcloud Talk",
"selectionLabel": "Nextcloud Talk (self-hosted)",
"docsPath": "/channels/nextcloud-talk",
"docsLabel": "nextcloud-talk",
"blurb": "Self-hosted chat via Nextcloud Talk webhook bots.",
"order": 65,
"aliases": ["nc-talk", "nc"]
},
"install": {
"npmSpec": "@clawdbot/nextcloud-talk",
"localPath": "extensions/nextcloud-talk",
"defaultChoice": "npm"
}
}
}
```
## Plugin IDs ## Plugin IDs
Default plugin ids: Default plugin ids:
@@ -140,6 +178,14 @@ Fields:
Config changes **require a gateway restart**. Config changes **require a gateway restart**.
Validation rules (strict):
- Unknown plugin ids in `entries`, `allow`, `deny`, or `slots` are **errors**.
- Unknown `channels.<id>` keys are **errors** unless a plugin manifest declares
the channel id.
- Plugin config is validated using the JSON Schema embedded in
`clawdbot.plugin.json` (`configSchema`).
- If a plugin is disabled, its config is preserved and a **warning** is emitted.
## Plugin slots (exclusive categories) ## Plugin slots (exclusive categories)
Some plugin categories are **exclusive** (only one active at a time). Use Some plugin categories are **exclusive** (only one active at a time). Use
@@ -169,22 +215,26 @@ Clawdbot augments `uiHints` at runtime based on discovered plugins:
`plugins.entries.<id>.config.<field>` `plugins.entries.<id>.config.<field>`
If you want your plugin config fields to show good labels/placeholders (and mark secrets as sensitive), If you want your plugin config fields to show good labels/placeholders (and mark secrets as sensitive),
provide `configSchema.uiHints`. provide `uiHints` alongside your JSON Schema in the plugin manifest.
Example: Example:
```ts ```json
export default { {
id: "my-plugin", "id": "my-plugin",
configSchema: { "configSchema": {
parse: (v) => v, "type": "object",
uiHints: { "additionalProperties": false,
"apiKey": { label: "API Key", sensitive: true }, "properties": {
"region": { label: "Region", placeholder: "us-east-1" }, "apiKey": { "type": "string" },
}, "region": { "type": "string" }
}
}, },
register(api) {}, "uiHints": {
}; "apiKey": { "label": "API Key", "sensitive": true },
"region": { "label": "Region", "placeholder": "us-east-1" }
}
}
``` ```
## CLI ## CLI
+63
View File
@@ -0,0 +1,63 @@
---
summary: "Plugin manifest + JSON schema requirements (strict config validation)"
read_when:
- You are building a Clawdbot plugin
- You need to ship a plugin config schema or debug plugin validation errors
---
# Plugin manifest (clawdbot.plugin.json)
Every plugin **must** ship a `clawdbot.plugin.json` file in the **plugin root**.
Clawdbot uses this manifest to validate configuration **without executing plugin
code**. Missing or invalid manifests are treated as plugin errors and block
config validation.
See the full plugin system guide: [Plugins](/plugin).
## Required fields
```json
{
"id": "voice-call",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
```
Required keys:
- `id` (string): canonical plugin id.
- `configSchema` (object): JSON Schema for plugin config (inline).
Optional keys:
- `kind` (string): plugin kind (example: `"memory"`).
- `channels` (array): channel ids registered by this plugin (example: `["matrix"]`).
- `providers` (array): provider ids registered by this plugin.
- `name` (string): display name for the plugin.
- `description` (string): short plugin summary.
- `uiHints` (object): config field labels/placeholders/sensitive flags for UI rendering.
- `version` (string): plugin version (informational).
## JSON Schema requirements
- **Every plugin must ship a JSON Schema**, even if it accepts no config.
- An empty schema is acceptable (for example, `{ "type": "object", "additionalProperties": false }`).
- Schemas are validated at config read/write time, not at runtime.
## Validation behavior
- Unknown `channels.*` keys are **errors**, unless the channel id is declared by
a plugin manifest.
- `plugins.entries.<id>`, `plugins.allow`, `plugins.deny`, and `plugins.slots.*`
must reference **discoverable** plugin ids. Unknown ids are **errors**.
- If a plugin is installed but has a broken or missing manifest or schema,
validation fails and Doctor reports the plugin error.
- If plugin config exists but the plugin is **disabled**, the config is kept and
a **warning** is surfaced in Doctor + logs.
## Notes
- The manifest is **required for all plugins**, including local filesystem loads.
- Runtime still loads the plugin module separately; the manifest is only for
discovery + validation.
+5 -2
View File
@@ -22,17 +22,20 @@ read_when:
- Unknown keys are validation errors (no passthrough at root or nested). - Unknown keys are validation errors (no passthrough at root or nested).
- `plugins.entries.<id>.config` must be validated by the plugin’s schema. - `plugins.entries.<id>.config` must be validated by the plugin’s schema.
- If a plugin lacks a schema, **reject plugin load** and surface a clear error. - If a plugin lacks a schema, **reject plugin load** and surface a clear error.
- Unknown `channels.<id>` keys are errors unless a plugin manifest declares the channel id.
- Plugin manifests (`clawdbot.plugin.json`) are required for all plugins.
## Plugin schema enforcement ## Plugin schema enforcement
- Each plugin provides a strict schema for its config (no passthrough). - Each plugin provides a strict JSON Schema for its config (inline in the manifest).
- Plugin load flow: - Plugin load flow:
1) Resolve plugin schema by plugin id. 1) Resolve plugin manifest + schema (`clawdbot.plugin.json`).
2) Validate config against the schema. 2) Validate config against the schema.
3) If missing schema or invalid config: block plugin load, record error. 3) If missing schema or invalid config: block plugin load, record error.
- Error message includes: - Error message includes:
- Plugin id - Plugin id
- Reason (missing schema / invalid config) - Reason (missing schema / invalid config)
- Path(s) that failed validation - Path(s) that failed validation
- Disabled plugins keep their config, but Doctor + logs surface a warning.
## Doctor flow ## Doctor flow
- Doctor runs **every time** config is loaded (dry-run by default). - Doctor runs **every time** config is loaded (dry-run by default).
+16
View File
@@ -323,6 +323,22 @@ These are “real pipeline” regressions without real providers:
- Gateway tool calling (mock OpenAI, real gateway + agent loop): `src/gateway/gateway.tool-calling.mock-openai.test.ts` - Gateway tool calling (mock OpenAI, real gateway + agent loop): `src/gateway/gateway.tool-calling.mock-openai.test.ts`
- Gateway wizard (WS `wizard.start`/`wizard.next`, writes config + auth enforced): `src/gateway/gateway.wizard.e2e.test.ts` - Gateway wizard (WS `wizard.start`/`wizard.next`, writes config + auth enforced): `src/gateway/gateway.wizard.e2e.test.ts`
## Agent reliability evals (skills)
We already have a few CI-safe tests that behave like “agent reliability evals”:
- Mock tool-calling through the real gateway + agent loop (`src/gateway/gateway.tool-calling.mock-openai.test.ts`).
- End-to-end wizard flows that validate session wiring and config effects (`src/gateway/gateway.wizard.e2e.test.ts`).
What’s still missing for skills (see [Skills](/tools/skills)):
- **Decisioning:** when skills are listed in the prompt, does the agent pick the right skill (or avoid irrelevant ones)?
- **Compliance:** does the agent read `SKILL.md` before use and follow required steps/args?
- **Workflow contracts:** multi-turn scenarios that assert tool order, session history carryover, and sandbox boundaries.
Future evals should stay deterministic first:
- A scenario runner using mock providers to assert tool calls + order, skill file reads, and session wiring.
- A small suite of skill-focused scenarios (use vs avoid, gating, prompt injection).
- Optional live evals (opt-in, env-gated) only after the CI-safe suite is in place.
## Adding regressions (guidance) ## Adding regressions (guidance)
When you fix a provider/model issue discovered in live: When you fix a provider/model issue discovered in live:
+2
View File
@@ -32,6 +32,8 @@ Notes:
- `gateway`/`node` approvals are controlled by `~/.clawdbot/exec-approvals.json`. - `gateway`/`node` approvals are controlled by `~/.clawdbot/exec-approvals.json`.
- `node` requires a paired node (companion app or headless node host). - `node` requires a paired node (companion app or headless node host).
- If multiple nodes are available, set `exec.node` or `tools.exec.node` to select one. - If multiple nodes are available, set `exec.node` or `tools.exec.node` to select one.
- On non-Windows hosts, exec uses `SHELL` when set; if `SHELL` is `fish`, it prefers `bash` (or `sh`)
from `PATH` to avoid fish-incompatible scripts, then falls back to `SHELL` if neither exists.
## Config ## Config
-2
View File
@@ -88,8 +88,6 @@ Notes:
The tool is invoked with params: The tool is invoked with params:
`{ command: "<raw args>", commandName: "<slash command>", skillName: "<skill name>" }`. `{ command: "<raw args>", commandName: "<slash command>", skillName: "<skill name>" }`.
Tool-dispatch commands still respect tool policies/sandbox rules (same as normal model tools).
Replies are derived from tool results; include `MEDIA:` tokens or media URLs in tool output to send attachments.
## Gating (load-time filters) ## Gating (load-time filters)
-1
View File
@@ -104,7 +104,6 @@ Notes:
- **Skill commands:** `user-invocable` skills are exposed as slash commands. Names are sanitized to `a-z0-9_` (max 32 chars); collisions get numeric suffixes (e.g. `_2`). - **Skill commands:** `user-invocable` skills are exposed as slash commands. Names are sanitized to `a-z0-9_` (max 32 chars); collisions get numeric suffixes (e.g. `_2`).
- By default, skill commands are forwarded to the model as a normal request. - By default, skill commands are forwarded to the model as a normal request.
- Skills may optionally declare `command-dispatch: tool` to route the command directly to a tool (deterministic, no model). - Skills may optionally declare `command-dispatch: tool` to route the command directly to a tool (deterministic, no model).
- Tool-dispatch still respects tool policies/sandbox rules; replies use the tool result text/media (including `MEDIA:` tokens).
- **Native command arguments:** Discord uses autocomplete for dynamic options (and button menus when you omit required args). Telegram and Slack show a button menu when a command supports choices and you omit the arg. - **Native command arguments:** Discord uses autocomplete for dynamic options (and button menus when you omit required args). Telegram and Slack show a button menu when a command supports choices and you omit the arg.
## Usage surfaces (what shows where) ## Usage surfaces (what shows where)
+1 -1
View File
@@ -118,7 +118,7 @@ Override via config:
Sub-agents use a dedicated in-process queue lane: Sub-agents use a dedicated in-process queue lane:
- Lane name: `subagent` - Lane name: `subagent`
- Concurrency: `agents.defaults.subagents.maxConcurrent` (default `1`) - Concurrency: `agents.defaults.subagents.maxConcurrent` (default `8`)
## Stopping ## Stopping
@@ -0,0 +1,11 @@
{
"id": "bluebubbles",
"channels": [
"bluebubbles"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
+15 -1
View File
@@ -4,6 +4,20 @@
"type": "module", "type": "module",
"description": "Clawdbot BlueBubbles channel plugin", "description": "Clawdbot BlueBubbles channel plugin",
"clawdbot": { "clawdbot": {
"extensions": ["./index.ts"] "extensions": ["./index.ts"],
"channel": {
"id": "bluebubbles",
"label": "BlueBubbles",
"selectionLabel": "BlueBubbles (macOS app)",
"docsPath": "/channels/bluebubbles",
"docsLabel": "bluebubbles",
"blurb": "iMessage via the BlueBubbles mac app + REST API.",
"order": 75
},
"install": {
"npmSpec": "@clawdbot/bluebubbles",
"localPath": "extensions/bluebubbles",
"defaultChoice": "npm"
}
} }
} }
@@ -0,0 +1,11 @@
{
"id": "copilot-proxy",
"providers": [
"copilot-proxy"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"id": "discord",
"channels": [
"discord"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
@@ -0,0 +1,11 @@
{
"id": "google-antigravity-auth",
"providers": [
"google-antigravity"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
@@ -0,0 +1,11 @@
{
"id": "google-gemini-cli-auth",
"providers": [
"google-gemini-cli"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"id": "imessage",
"channels": [
"imessage"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"id": "matrix",
"channels": [
"matrix"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
+16 -1
View File
@@ -6,7 +6,22 @@
"clawdbot": { "clawdbot": {
"extensions": [ "extensions": [
"./index.ts" "./index.ts"
] ],
"channel": {
"id": "matrix",
"label": "Matrix",
"selectionLabel": "Matrix (plugin)",
"docsPath": "/channels/matrix",
"docsLabel": "matrix",
"blurb": "open protocol; install the plugin to enable.",
"order": 70,
"quickstartAllowFrom": true
},
"install": {
"npmSpec": "@clawdbot/matrix",
"localPath": "extensions/matrix",
"defaultChoice": "npm"
}
}, },
"dependencies": { "dependencies": {
"clawdbot": "workspace:*", "clawdbot": "workspace:*",
@@ -0,0 +1,9 @@
{
"id": "memory-core",
"kind": "memory",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
@@ -0,0 +1,67 @@
{
"id": "memory-lancedb",
"kind": "memory",
"uiHints": {
"embedding.apiKey": {
"label": "OpenAI API Key",
"sensitive": true,
"placeholder": "sk-proj-...",
"help": "API key for OpenAI embeddings (or use ${OPENAI_API_KEY})"
},
"embedding.model": {
"label": "Embedding Model",
"placeholder": "text-embedding-3-small",
"help": "OpenAI embedding model to use"
},
"dbPath": {
"label": "Database Path",
"placeholder": "~/.clawdbot/memory/lancedb",
"advanced": true
},
"autoCapture": {
"label": "Auto-Capture",
"help": "Automatically capture important information from conversations"
},
"autoRecall": {
"label": "Auto-Recall",
"help": "Automatically inject relevant memories into context"
}
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"embedding": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": {
"type": "string"
},
"model": {
"type": "string",
"enum": [
"text-embedding-3-small",
"text-embedding-3-large"
]
}
},
"required": [
"apiKey"
]
},
"dbPath": {
"type": "string"
},
"autoCapture": {
"type": "boolean"
},
"autoRecall": {
"type": "boolean"
}
},
"required": [
"embedding"
]
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"id": "msteams",
"channels": [
"msteams"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
+16 -1
View File
@@ -6,7 +6,22 @@
"clawdbot": { "clawdbot": {
"extensions": [ "extensions": [
"./index.ts" "./index.ts"
] ],
"channel": {
"id": "msteams",
"label": "Microsoft Teams",
"selectionLabel": "Microsoft Teams (Bot Framework)",
"docsPath": "/channels/msteams",
"docsLabel": "msteams",
"blurb": "Bot Framework; enterprise support.",
"aliases": ["teams"],
"order": 60
},
"install": {
"npmSpec": "@clawdbot/msteams",
"localPath": "extensions/msteams",
"defaultChoice": "npm"
}
}, },
"dependencies": { "dependencies": {
"@microsoft/agents-hosting": "^1.2.2", "@microsoft/agents-hosting": "^1.2.2",
@@ -0,0 +1,11 @@
{
"id": "nextcloud-talk",
"channels": [
"nextcloud-talk"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
+18
View File
@@ -0,0 +1,18 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { nextcloudTalkPlugin } from "./src/channel.js";
import { setNextcloudTalkRuntime } from "./src/runtime.js";
const plugin = {
id: "nextcloud-talk",
name: "Nextcloud Talk",
description: "Nextcloud Talk channel plugin",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) {
setNextcloudTalkRuntime(api.runtime);
api.registerChannel({ plugin: nextcloudTalkPlugin });
},
};
export default plugin;
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@clawdbot/nextcloud-talk",
"version": "2026.1.17-1",
"type": "module",
"description": "Clawdbot Nextcloud Talk channel plugin",
"clawdbot": {
"extensions": ["./index.ts"],
"channel": {
"id": "nextcloud-talk",
"label": "Nextcloud Talk",
"selectionLabel": "Nextcloud Talk (self-hosted)",
"docsPath": "/channels/nextcloud-talk",
"docsLabel": "nextcloud-talk",
"blurb": "Self-hosted chat via Nextcloud Talk webhook bots.",
"aliases": ["nc-talk", "nc"],
"order": 65,
"quickstartAllowFrom": true
},
"install": {
"npmSpec": "@clawdbot/nextcloud-talk",
"localPath": "extensions/nextcloud-talk",
"defaultChoice": "npm"
}
}
}
+154
View File
@@ -0,0 +1,154 @@
import { readFileSync } from "node:fs";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
import type { CoreConfig, NextcloudTalkAccountConfig } from "./types.js";
const TRUTHY_ENV = new Set(["true", "1", "yes", "on"]);
function isTruthyEnvValue(value?: string): boolean {
if (!value) return false;
return TRUTHY_ENV.has(value.trim().toLowerCase());
}
const debugAccounts = (...args: unknown[]) => {
if (isTruthyEnvValue(process.env.CLAWDBOT_DEBUG_NEXTCLOUD_TALK_ACCOUNTS)) {
console.warn("[nextcloud-talk:accounts]", ...args);
}
};
export type ResolvedNextcloudTalkAccount = {
accountId: string;
enabled: boolean;
name?: string;
baseUrl: string;
secret: string;
secretSource: "env" | "secretFile" | "config" | "none";
config: NextcloudTalkAccountConfig;
};
function listConfiguredAccountIds(cfg: CoreConfig): string[] {
const accounts = cfg.channels?.["nextcloud-talk"]?.accounts;
if (!accounts || typeof accounts !== "object") return [];
const ids = new Set<string>();
for (const key of Object.keys(accounts)) {
if (!key) continue;
ids.add(normalizeAccountId(key));
}
return [...ids];
}
export function listNextcloudTalkAccountIds(cfg: CoreConfig): string[] {
const ids = listConfiguredAccountIds(cfg);
debugAccounts("listNextcloudTalkAccountIds", ids);
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
return ids.sort((a, b) => a.localeCompare(b));
}
export function resolveDefaultNextcloudTalkAccountId(cfg: CoreConfig): string {
const ids = listNextcloudTalkAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
return ids[0] ?? DEFAULT_ACCOUNT_ID;
}
function resolveAccountConfig(
cfg: CoreConfig,
accountId: string,
): NextcloudTalkAccountConfig | undefined {
const accounts = cfg.channels?.["nextcloud-talk"]?.accounts;
if (!accounts || typeof accounts !== "object") return undefined;
const direct = accounts[accountId] as NextcloudTalkAccountConfig | undefined;
if (direct) return direct;
const normalized = normalizeAccountId(accountId);
const matchKey = Object.keys(accounts).find((key) => normalizeAccountId(key) === normalized);
return matchKey ? (accounts[matchKey] as NextcloudTalkAccountConfig | undefined) : undefined;
}
function mergeNextcloudTalkAccountConfig(
cfg: CoreConfig,
accountId: string,
): NextcloudTalkAccountConfig {
const { accounts: _ignored, ...base } = (cfg.channels?.["nextcloud-talk"] ??
{}) as NextcloudTalkAccountConfig & { accounts?: unknown };
const account = resolveAccountConfig(cfg, accountId) ?? {};
return { ...base, ...account };
}
function resolveNextcloudTalkSecret(
cfg: CoreConfig,
opts: { accountId?: string },
): { secret: string; source: ResolvedNextcloudTalkAccount["secretSource"] } {
const merged = mergeNextcloudTalkAccountConfig(cfg, opts.accountId ?? DEFAULT_ACCOUNT_ID);
const envSecret = process.env.NEXTCLOUD_TALK_BOT_SECRET?.trim();
if (envSecret && (!opts.accountId || opts.accountId === DEFAULT_ACCOUNT_ID)) {
return { secret: envSecret, source: "env" };
}
if (merged.botSecretFile) {
try {
const fileSecret = readFileSync(merged.botSecretFile, "utf-8").trim();
if (fileSecret) return { secret: fileSecret, source: "secretFile" };
} catch {
// File not found or unreadable, fall through.
}
}
if (merged.botSecret?.trim()) {
return { secret: merged.botSecret.trim(), source: "config" };
}
return { secret: "", source: "none" };
}
export function resolveNextcloudTalkAccount(params: {
cfg: CoreConfig;
accountId?: string | null;
}): ResolvedNextcloudTalkAccount {
const hasExplicitAccountId = Boolean(params.accountId?.trim());
const baseEnabled = params.cfg.channels?.["nextcloud-talk"]?.enabled !== false;
const resolve = (accountId: string) => {
const merged = mergeNextcloudTalkAccountConfig(params.cfg, accountId);
const accountEnabled = merged.enabled !== false;
const enabled = baseEnabled && accountEnabled;
const secretResolution = resolveNextcloudTalkSecret(params.cfg, { accountId });
const baseUrl = merged.baseUrl?.trim()?.replace(/\/$/, "") ?? "";
debugAccounts("resolve", {
accountId,
enabled,
secretSource: secretResolution.source,
baseUrl: baseUrl ? "[set]" : "[missing]",
});
return {
accountId,
enabled,
name: merged.name?.trim() || undefined,
baseUrl,
secret: secretResolution.secret,
secretSource: secretResolution.source,
config: merged,
} satisfies ResolvedNextcloudTalkAccount;
};
const normalized = normalizeAccountId(params.accountId);
const primary = resolve(normalized);
if (hasExplicitAccountId) return primary;
if (primary.secretSource !== "none") return primary;
const fallbackId = resolveDefaultNextcloudTalkAccountId(params.cfg);
if (fallbackId === primary.accountId) return primary;
const fallback = resolve(fallbackId);
if (fallback.secretSource === "none") return primary;
return fallback;
}
export function listEnabledNextcloudTalkAccounts(
cfg: CoreConfig,
): ResolvedNextcloudTalkAccount[] {
return listNextcloudTalkAccountIds(cfg)
.map((accountId) => resolveNextcloudTalkAccount({ cfg, accountId }))
.filter((account) => account.enabled);
}
+401
View File
@@ -0,0 +1,401 @@
import {
applyAccountNameToChannelSection,
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
formatPairingApproveHint,
normalizeAccountId,
setAccountEnabledInConfigSection,
type ChannelPlugin,
type ClawdbotConfig,
type ChannelSetupInput,
} from "clawdbot/plugin-sdk";
import {
listNextcloudTalkAccountIds,
resolveDefaultNextcloudTalkAccountId,
resolveNextcloudTalkAccount,
type ResolvedNextcloudTalkAccount,
} from "./accounts.js";
import { NextcloudTalkConfigSchema } from "./config-schema.js";
import { monitorNextcloudTalkProvider } from "./monitor.js";
import { looksLikeNextcloudTalkTargetId, normalizeNextcloudTalkMessagingTarget } from "./normalize.js";
import { nextcloudTalkOnboardingAdapter } from "./onboarding.js";
import { getNextcloudTalkRuntime } from "./runtime.js";
import { sendMessageNextcloudTalk } from "./send.js";
import type { CoreConfig } from "./types.js";
const meta = {
id: "nextcloud-talk",
label: "Nextcloud Talk",
selectionLabel: "Nextcloud Talk (self-hosted)",
docsPath: "/channels/nextcloud-talk",
docsLabel: "nextcloud-talk",
blurb: "Self-hosted chat via Nextcloud Talk webhook bots.",
aliases: ["nc-talk", "nc"],
order: 65,
quickstartAllowFrom: true,
};
type NextcloudSetupInput = ChannelSetupInput & {
baseUrl?: string;
secret?: string;
secretFile?: string;
useEnv?: boolean;
};
export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> = {
id: "nextcloud-talk",
meta,
onboarding: nextcloudTalkOnboardingAdapter,
pairing: {
idLabel: "nextcloudUserId",
normalizeAllowEntry: (entry) =>
entry.replace(/^(nextcloud-talk|nc-talk|nc):/i, "").toLowerCase(),
notifyApproval: async ({ id }) => {
console.log(`[nextcloud-talk] User ${id} approved for pairing`);
},
},
capabilities: {
chatTypes: ["direct", "group"],
reactions: true,
threads: false,
media: true,
nativeCommands: false,
blockStreaming: true,
},
reload: { configPrefixes: ["channels.nextcloud-talk"] },
configSchema: buildChannelConfigSchema(NextcloudTalkConfigSchema),
config: {
listAccountIds: (cfg) => listNextcloudTalkAccountIds(cfg as CoreConfig),
resolveAccount: (cfg, accountId) =>
resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }),
defaultAccountId: (cfg) => resolveDefaultNextcloudTalkAccountId(cfg as CoreConfig),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg,
sectionKey: "nextcloud-talk",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg,
sectionKey: "nextcloud-talk",
accountId,
clearBaseFields: ["botSecret", "botSecretFile", "baseUrl", "name"],
}),
isConfigured: (account) => Boolean(account.secret?.trim() && account.baseUrl?.trim()),
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: Boolean(account.secret?.trim() && account.baseUrl?.trim()),
secretSource: account.secretSource,
baseUrl: account.baseUrl ? "[set]" : "[missing]",
}),
resolveAllowFrom: ({ cfg, accountId }) =>
(resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }).config.allowFrom ?? []).map(
(entry) => String(entry).toLowerCase(),
),
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => entry.replace(/^(nextcloud-talk|nc-talk|nc):/i, ""))
.map((entry) => entry.toLowerCase()),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean(
cfg.channels?.["nextcloud-talk"]?.accounts?.[resolvedAccountId],
);
const basePath = useAccountPath
? `channels.nextcloud-talk.accounts.${resolvedAccountId}.`
: "channels.nextcloud-talk.";
return {
policy: account.config.dmPolicy ?? "pairing",
allowFrom: account.config.allowFrom ?? [],
policyPath: `${basePath}dmPolicy`,
allowFromPath: basePath,
approveHint: formatPairingApproveHint("nextcloud-talk"),
normalizeEntry: (raw) =>
raw.replace(/^(nextcloud-talk|nc-talk|nc):/i, "").toLowerCase(),
};
},
collectWarnings: ({ account, cfg }) => {
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
if (groupPolicy !== "open") return [];
const roomAllowlistConfigured =
account.config.rooms && Object.keys(account.config.rooms).length > 0;
if (roomAllowlistConfigured) {
return [
`- Nextcloud Talk rooms: groupPolicy="open" allows any member in allowed rooms to trigger (mention-gated). Set channels.nextcloud-talk.groupPolicy="allowlist" + channels.nextcloud-talk.groupAllowFrom to restrict senders.`,
];
}
return [
`- Nextcloud Talk rooms: groupPolicy="open" with no channels.nextcloud-talk.rooms allowlist; any room can add + ping (mention-gated). Set channels.nextcloud-talk.groupPolicy="allowlist" + channels.nextcloud-talk.groupAllowFrom or configure channels.nextcloud-talk.rooms.`,
];
},
},
groups: {
resolveRequireMention: ({ cfg, accountId, groupId }) => {
const account = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId });
const rooms = account.config.rooms;
if (!rooms || !groupId) return true;
const roomConfig = rooms[groupId];
if (roomConfig?.requireMention !== undefined) {
return roomConfig.requireMention;
}
const wildcardConfig = rooms["*"];
if (wildcardConfig?.requireMention !== undefined) {
return wildcardConfig.requireMention;
}
return true;
},
},
messaging: {
normalizeTarget: normalizeNextcloudTalkMessagingTarget,
targetResolver: {
looksLikeId: looksLikeNextcloudTalkTargetId,
hint: "<roomToken>",
},
},
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg: cfg as ClawdbotConfig,
channelKey: "nextcloud-talk",
accountId,
name,
}),
validateInput: ({ accountId, input }) => {
const setupInput = input as NextcloudSetupInput;
if (setupInput.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
return "NEXTCLOUD_TALK_BOT_SECRET can only be used for the default account.";
}
if (!setupInput.useEnv && !setupInput.secret && !setupInput.secretFile) {
return "Nextcloud Talk requires bot secret or --secret-file (or --use-env).";
}
if (!setupInput.baseUrl) {
return "Nextcloud Talk requires --base-url.";
}
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const setupInput = input as NextcloudSetupInput;
const namedConfig = applyAccountNameToChannelSection({
cfg: cfg as ClawdbotConfig,
channelKey: "nextcloud-talk",
accountId,
name: setupInput.name,
});
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...namedConfig,
channels: {
...namedConfig.channels,
"nextcloud-talk": {
...namedConfig.channels?.["nextcloud-talk"],
enabled: true,
baseUrl: setupInput.baseUrl,
...(setupInput.useEnv
? {}
: setupInput.secretFile
? { botSecretFile: setupInput.secretFile }
: setupInput.secret
? { botSecret: setupInput.secret }
: {}),
},
},
} as ClawdbotConfig;
}
return {
...namedConfig,
channels: {
...namedConfig.channels,
"nextcloud-talk": {
...namedConfig.channels?.["nextcloud-talk"],
enabled: true,
accounts: {
...namedConfig.channels?.["nextcloud-talk"]?.accounts,
[accountId]: {
...namedConfig.channels?.["nextcloud-talk"]?.accounts?.[accountId],
enabled: true,
baseUrl: setupInput.baseUrl,
...(setupInput.secretFile
? { botSecretFile: setupInput.secretFile }
: setupInput.secret
? { botSecret: setupInput.secret }
: {}),
},
},
},
},
} as ClawdbotConfig;
},
},
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => getNextcloudTalkRuntime().channel.text.chunkMarkdownText(text, limit),
textChunkLimit: 4000,
sendText: async ({ to, text, accountId, replyToId }) => {
const result = await sendMessageNextcloudTalk(to, text, {
accountId: accountId ?? undefined,
replyTo: replyToId ?? undefined,
});
return { channel: "nextcloud-talk", ...result };
},
sendMedia: async ({ to, text, mediaUrl, accountId, replyToId }) => {
const messageWithMedia = mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text;
const result = await sendMessageNextcloudTalk(to, messageWithMedia, {
accountId: accountId ?? undefined,
replyTo: replyToId ?? undefined,
});
return { channel: "nextcloud-talk", ...result };
},
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
secretSource: snapshot.secretSource ?? "none",
running: snapshot.running ?? false,
mode: "webhook",
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
}),
buildAccountSnapshot: ({ account, runtime }) => {
const configured = Boolean(account.secret?.trim() && account.baseUrl?.trim());
return {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured,
secretSource: account.secretSource,
baseUrl: account.baseUrl ? "[set]" : "[missing]",
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
mode: "webhook",
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
};
},
},
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
if (!account.secret || !account.baseUrl) {
throw new Error(
`Nextcloud Talk not configured for account "${account.accountId}" (missing secret or baseUrl)`,
);
}
ctx.log?.info(`[${account.accountId}] starting Nextcloud Talk webhook server`);
const { stop } = await monitorNextcloudTalkProvider({
accountId: account.accountId,
config: ctx.cfg as CoreConfig,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
});
return { stop };
},
logoutAccount: async ({ accountId, cfg }) => {
const nextCfg = { ...cfg } as ClawdbotConfig;
const nextSection = cfg.channels?.["nextcloud-talk"]
? { ...cfg.channels["nextcloud-talk"] }
: undefined;
let cleared = false;
let changed = false;
if (nextSection) {
if (accountId === DEFAULT_ACCOUNT_ID && nextSection.botSecret) {
delete nextSection.botSecret;
cleared = true;
changed = true;
}
const accounts =
nextSection.accounts && typeof nextSection.accounts === "object"
? { ...nextSection.accounts }
: undefined;
if (accounts && accountId in accounts) {
const entry = accounts[accountId];
if (entry && typeof entry === "object") {
const nextEntry = { ...entry } as Record<string, unknown>;
if ("botSecret" in nextEntry) {
const secret = nextEntry.botSecret;
if (typeof secret === "string" ? secret.trim() : secret) {
cleared = true;
}
delete nextEntry.botSecret;
changed = true;
}
if (Object.keys(nextEntry).length === 0) {
delete accounts[accountId];
changed = true;
} else {
accounts[accountId] = nextEntry as typeof entry;
}
}
}
if (accounts) {
if (Object.keys(accounts).length === 0) {
delete nextSection.accounts;
changed = true;
} else {
nextSection.accounts = accounts;
}
}
}
if (changed) {
if (nextSection && Object.keys(nextSection).length > 0) {
nextCfg.channels = { ...nextCfg.channels, "nextcloud-talk": nextSection };
} else {
const nextChannels = { ...nextCfg.channels } as Record<string, unknown>;
delete nextChannels["nextcloud-talk"];
if (Object.keys(nextChannels).length > 0) {
nextCfg.channels = nextChannels as ClawdbotConfig["channels"];
} else {
delete nextCfg.channels;
}
}
}
const resolved = resolveNextcloudTalkAccount({
cfg: (changed ? (nextCfg as CoreConfig) : (cfg as CoreConfig)),
accountId,
});
const loggedOut = resolved.secretSource === "none";
if (changed) {
await getNextcloudTalkRuntime().config.writeConfigFile(nextCfg);
}
return {
cleared,
envSecret: Boolean(process.env.NEXTCLOUD_TALK_BOT_SECRET?.trim()),
loggedOut,
};
},
},
};
@@ -0,0 +1,73 @@
import {
BlockStreamingCoalesceSchema,
DmConfigSchema,
DmPolicySchema,
GroupPolicySchema,
requireOpenAllowFrom,
} from "clawdbot/plugin-sdk";
import { z } from "zod";
export const NextcloudTalkRoomSchema = z
.object({
requireMention: z.boolean().optional(),
skills: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
allowFrom: z.array(z.string()).optional(),
systemPrompt: z.string().optional(),
})
.strict();
export const NextcloudTalkAccountSchemaBase = z
.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
baseUrl: z.string().optional(),
botSecret: z.string().optional(),
botSecretFile: z.string().optional(),
apiUser: z.string().optional(),
apiPassword: z.string().optional(),
apiPasswordFile: z.string().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
webhookPort: z.number().int().positive().optional(),
webhookHost: z.string().optional(),
webhookPath: z.string().optional(),
webhookPublicUrl: z.string().optional(),
allowFrom: z.array(z.string()).optional(),
groupAllowFrom: z.array(z.string()).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
rooms: z.record(z.string(), NextcloudTalkRoomSchema.optional()).optional(),
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
mediaMaxMb: z.number().positive().optional(),
})
.strict();
export const NextcloudTalkAccountSchema = NextcloudTalkAccountSchemaBase.superRefine(
(value, ctx) => {
requireOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message:
'channels.nextcloud-talk.dmPolicy="open" requires channels.nextcloud-talk.allowFrom to include "*"',
});
},
);
export const NextcloudTalkConfigSchema = NextcloudTalkAccountSchemaBase.extend({
accounts: z.record(z.string(), NextcloudTalkAccountSchema.optional()).optional(),
}).superRefine((value, ctx) => {
requireOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message:
'channels.nextcloud-talk.dmPolicy="open" requires channels.nextcloud-talk.allowFrom to include "*"',
});
});
+79
View File
@@ -0,0 +1,79 @@
/**
* Format utilities for Nextcloud Talk messages.
*
* Nextcloud Talk supports markdown natively, so most formatting passes through.
* This module handles any edge cases or transformations needed.
*/
/**
* Convert markdown to Nextcloud Talk compatible format.
* Nextcloud Talk supports standard markdown, so minimal transformation needed.
*/
export function markdownToNextcloudTalk(text: string): string {
return text.trim();
}
/**
* Escape special characters in text to prevent markdown interpretation.
*/
export function escapeNextcloudTalkMarkdown(text: string): string {
return text.replace(/([*_`~[\]()#>+\-=|{}!\\])/g, "\\$1");
}
/**
* Format a mention for a Nextcloud user.
* Nextcloud Talk uses @user format for mentions.
*/
export function formatNextcloudTalkMention(userId: string): string {
return `@${userId.replace(/^@/, "")}`;
}
/**
* Format a code block for Nextcloud Talk.
*/
export function formatNextcloudTalkCodeBlock(code: string, language?: string): string {
const lang = language ?? "";
return `\`\`\`${lang}\n${code}\n\`\`\``;
}
/**
* Format inline code for Nextcloud Talk.
*/
export function formatNextcloudTalkInlineCode(code: string): string {
if (code.includes("`")) {
return `\`\` ${code} \`\``;
}
return `\`${code}\``;
}
/**
* Strip Nextcloud Talk specific formatting from text.
* Useful for extracting plain text content.
*/
export function stripNextcloudTalkFormatting(text: string): string {
return (
text
.replace(/```[\s\S]*?```/g, "")
.replace(/`[^`]+`/g, "")
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/\*([^*]+)\*/g, "$1")
.replace(/_([^_]+)_/g, "$1")
.replace(/~~([^~]+)~~/g, "$1")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/\s+/g, " ")
.trim()
);
}
/**
* Truncate text to a maximum length, preserving word boundaries.
*/
export function truncateNextcloudTalkText(text: string, maxLength: number, suffix = "..."): string {
if (text.length <= maxLength) return text;
const truncated = text.slice(0, maxLength - suffix.length);
const lastSpace = truncated.lastIndexOf(" ");
if (lastSpace > maxLength * 0.7) {
return truncated.slice(0, lastSpace) + suffix;
}
return truncated + suffix;
}
+331
View File
@@ -0,0 +1,331 @@
import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk";
import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
import {
normalizeNextcloudTalkAllowlist,
resolveNextcloudTalkAllowlistMatch,
resolveNextcloudTalkGroupAllow,
resolveNextcloudTalkMentionGate,
resolveNextcloudTalkRequireMention,
resolveNextcloudTalkRoomMatch,
} from "./policy.js";
import { resolveNextcloudTalkRoomKind } from "./room-info.js";
import { sendMessageNextcloudTalk } from "./send.js";
import { getNextcloudTalkRuntime } from "./runtime.js";
import type { CoreConfig, NextcloudTalkInboundMessage } from "./types.js";
const CHANNEL_ID = "nextcloud-talk" as const;
async function deliverNextcloudTalkReply(params: {
payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string; replyToId?: string };
roomToken: string;
accountId: string;
statusSink?: (patch: { lastOutboundAt?: number }) => void;
}): Promise<void> {
const { payload, roomToken, accountId, statusSink } = params;
const text = payload.text ?? "";
const mediaList = payload.mediaUrls?.length
? payload.mediaUrls
: payload.mediaUrl
? [payload.mediaUrl]
: [];
if (!text.trim() && mediaList.length === 0) return;
const mediaBlock = mediaList.length
? mediaList.map((url) => `Attachment: ${url}`).join("\n")
: "";
const combined = text.trim()
? mediaBlock
? `${text.trim()}\n\n${mediaBlock}`
: text.trim()
: mediaBlock;
await sendMessageNextcloudTalk(roomToken, combined, {
accountId,
replyTo: payload.replyToId,
});
statusSink?.({ lastOutboundAt: Date.now() });
}
export async function handleNextcloudTalkInbound(params: {
message: NextcloudTalkInboundMessage;
account: ResolvedNextcloudTalkAccount;
config: CoreConfig;
runtime: RuntimeEnv;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
}): Promise<void> {
const { message, account, config, runtime, statusSink } = params;
const core = getNextcloudTalkRuntime();
const rawBody = message.text?.trim() ?? "";
if (!rawBody) return;
const roomKind = await resolveNextcloudTalkRoomKind({
account,
roomToken: message.roomToken,
runtime,
});
const isGroup =
roomKind === "direct" ? false : roomKind === "group" ? true : message.isGroupChat;
const senderId = message.senderId;
const senderName = message.senderName;
const roomToken = message.roomToken;
const roomName = message.roomName;
statusSink?.({ lastInboundAt: message.timestamp });
const dmPolicy = account.config.dmPolicy ?? "pairing";
const defaultGroupPolicy = config.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
const configAllowFrom = normalizeNextcloudTalkAllowlist(account.config.allowFrom);
const configGroupAllowFrom = normalizeNextcloudTalkAllowlist(account.config.groupAllowFrom);
const storeAllowFrom = await core.channel.pairing
.readAllowFromStore(CHANNEL_ID)
.catch(() => []);
const storeAllowList = normalizeNextcloudTalkAllowlist(storeAllowFrom);
const roomMatch = resolveNextcloudTalkRoomMatch({
rooms: account.config.rooms,
roomToken,
roomName,
});
const roomConfig = roomMatch.roomConfig;
if (isGroup && !roomMatch.allowed) {
runtime.log?.(`nextcloud-talk: drop room ${roomToken} (not allowlisted)`);
return;
}
if (roomConfig?.enabled === false) {
runtime.log?.(`nextcloud-talk: drop room ${roomToken} (disabled)`);
return;
}
const roomAllowFrom = normalizeNextcloudTalkAllowlist(roomConfig?.allowFrom);
const baseGroupAllowFrom =
configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom;
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowList].filter(Boolean);
const effectiveGroupAllowFrom = [...baseGroupAllowFrom, ...storeAllowList].filter(Boolean);
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
cfg: config as ClawdbotConfig,
surface: CHANNEL_ID,
});
const useAccessGroups = config.commands?.useAccessGroups !== false;
const senderAllowedForCommands = resolveNextcloudTalkAllowlistMatch({
allowFrom: isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom,
senderId,
senderName,
}).allowed;
const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{
configured:
(isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom).length > 0,
allowed: senderAllowedForCommands,
},
],
});
if (isGroup) {
const groupAllow = resolveNextcloudTalkGroupAllow({
groupPolicy,
outerAllowFrom: effectiveGroupAllowFrom,
innerAllowFrom: roomAllowFrom,
senderId,
senderName,
});
if (!groupAllow.allowed) {
runtime.log?.(
`nextcloud-talk: drop group sender ${senderId} (policy=${groupPolicy})`,
);
return;
}
} else {
if (dmPolicy === "disabled") {
runtime.log?.(`nextcloud-talk: drop DM sender=${senderId} (dmPolicy=disabled)`);
return;
}
if (dmPolicy !== "open") {
const dmAllowed = resolveNextcloudTalkAllowlistMatch({
allowFrom: effectiveAllowFrom,
senderId,
senderName,
}).allowed;
if (!dmAllowed) {
if (dmPolicy === "pairing") {
const { code, created } = await core.channel.pairing.upsertPairingRequest({
channel: CHANNEL_ID,
id: senderId,
meta: { name: senderName || undefined },
});
if (created) {
try {
await sendMessageNextcloudTalk(
roomToken,
core.channel.pairing.buildPairingReply({
channel: CHANNEL_ID,
idLine: `Your Nextcloud user id: ${senderId}`,
code,
}),
{ accountId: account.accountId },
);
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
runtime.error?.(
`nextcloud-talk: pairing reply failed for ${senderId}: ${String(err)}`,
);
}
}
}
runtime.log?.(
`nextcloud-talk: drop DM sender ${senderId} (dmPolicy=${dmPolicy})`,
);
return;
}
}
}
if (
isGroup &&
allowTextCommands &&
core.channel.text.hasControlCommand(rawBody, config as ClawdbotConfig) &&
commandAuthorized !== true
) {
runtime.log?.(
`nextcloud-talk: drop control command from unauthorized sender ${senderId}`,
);
return;
}
const mentionRegexes = core.channel.mentions.buildMentionRegexes(
config as ClawdbotConfig,
);
const wasMentioned = mentionRegexes.length
? core.channel.mentions.matchesMentionPatterns(rawBody, mentionRegexes)
: false;
const shouldRequireMention = isGroup
? resolveNextcloudTalkRequireMention({
roomConfig,
wildcardConfig: roomMatch.wildcardConfig,
})
: false;
const hasControlCommand = core.channel.text.hasControlCommand(
rawBody,
config as ClawdbotConfig,
);
const mentionGate = resolveNextcloudTalkMentionGate({
isGroup,
requireMention: shouldRequireMention,
wasMentioned,
allowTextCommands,
hasControlCommand,
commandAuthorized,
});
if (isGroup && mentionGate.shouldSkip) {
runtime.log?.(`nextcloud-talk: drop room ${roomToken} (no mention)`);
return;
}
const route = core.channel.routing.resolveAgentRoute({
cfg: config as ClawdbotConfig,
channel: CHANNEL_ID,
accountId: account.accountId,
peer: {
kind: isGroup ? "group" : "dm",
id: isGroup ? roomToken : senderId,
},
});
const fromLabel = isGroup
? `room:${roomName || roomToken}`
: senderName || `user:${senderId}`;
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
agentId: route.agentId,
});
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(
config as ClawdbotConfig,
);
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
const body = core.channel.reply.formatAgentEnvelope({
channel: "Nextcloud Talk",
from: fromLabel,
timestamp: message.timestamp,
previousTimestamp,
envelope: envelopeOptions,
body: rawBody,
});
const groupSystemPrompt = roomConfig?.systemPrompt?.trim() || undefined;
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: body,
RawBody: rawBody,
CommandBody: rawBody,
From: isGroup ? `nextcloud-talk:room:${roomToken}` : `nextcloud-talk:${senderId}`,
To: `nextcloud-talk:${roomToken}`,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isGroup ? "group" : "direct",
ConversationLabel: fromLabel,
SenderName: senderName || undefined,
SenderId: senderId,
GroupSubject: isGroup ? roomName || roomToken : undefined,
GroupSystemPrompt: isGroup ? groupSystemPrompt : undefined,
Provider: CHANNEL_ID,
Surface: CHANNEL_ID,
WasMentioned: isGroup ? wasMentioned : undefined,
MessageSid: message.messageId,
Timestamp: message.timestamp,
OriginatingChannel: CHANNEL_ID,
OriginatingTo: `nextcloud-talk:${roomToken}`,
CommandAuthorized: commandAuthorized,
});
void core.channel.session
.recordSessionMetaFromInbound({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
})
.catch((err) => {
runtime.error?.(`nextcloud-talk: failed updating session meta: ${String(err)}`);
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config as ClawdbotConfig,
dispatcherOptions: {
deliver: async (payload) => {
await deliverNextcloudTalkReply({
payload: payload as {
text?: string;
mediaUrls?: string[];
mediaUrl?: string;
replyToId?: string;
},
roomToken,
accountId: account.accountId,
statusSink,
});
},
onError: (err, info) => {
runtime.error?.(
`nextcloud-talk ${info.kind} reply failed: ${String(err)}`,
);
},
},
replyOptions: {
skillFilter: roomConfig?.skills,
disableBlockStreaming:
typeof account.config.blockStreaming === "boolean"
? !account.config.blockStreaming
: undefined,
},
});
}
+246
View File
@@ -0,0 +1,246 @@
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
import { resolveNextcloudTalkAccount } from "./accounts.js";
import { handleNextcloudTalkInbound } from "./inbound.js";
import { getNextcloudTalkRuntime } from "./runtime.js";
import { extractNextcloudTalkHeaders, verifyNextcloudTalkSignature } from "./signature.js";
import type {
CoreConfig,
NextcloudTalkInboundMessage,
NextcloudTalkWebhookPayload,
NextcloudTalkWebhookServerOptions,
} from "./types.js";
const DEFAULT_WEBHOOK_PORT = 8788;
const DEFAULT_WEBHOOK_HOST = "0.0.0.0";
const DEFAULT_WEBHOOK_PATH = "/nextcloud-talk-webhook";
const HEALTH_PATH = "/healthz";
function formatError(err: unknown): string {
if (err instanceof Error) return err.message;
return typeof err === "string" ? err : JSON.stringify(err);
}
function parseWebhookPayload(body: string): NextcloudTalkWebhookPayload | null {
try {
const data = JSON.parse(body);
if (
!data.type ||
!data.actor?.type ||
!data.actor?.id ||
!data.object?.type ||
!data.object?.id ||
!data.target?.type ||
!data.target?.id
) {
return null;
}
return data as NextcloudTalkWebhookPayload;
} catch {
return null;
}
}
function payloadToInboundMessage(
payload: NextcloudTalkWebhookPayload,
): NextcloudTalkInboundMessage {
// Payload doesn't indicate DM vs room; mark as group and let inbound handler refine.
const isGroupChat = true;
return {
messageId: String(payload.object.id),
roomToken: payload.target.id,
roomName: payload.target.name,
senderId: payload.actor.id,
senderName: payload.actor.name,
text: payload.object.content || payload.object.name || "",
mediaType: payload.object.mediaType || "text/plain",
timestamp: Date.now(),
isGroupChat,
};
}
function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
req.on("error", reject);
});
}
export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServerOptions): {
server: Server;
start: () => Promise<void>;
stop: () => void;
} {
const { port, host, path, secret, onMessage, onError, abortSignal } = opts;
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
if (req.url === HEALTH_PATH) {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("ok");
return;
}
if (req.url !== path || req.method !== "POST") {
res.writeHead(404);
res.end();
return;
}
try {
const body = await readBody(req);
const headers = extractNextcloudTalkHeaders(
req.headers as Record<string, string | string[] | undefined>,
);
if (!headers) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Missing signature headers" }));
return;
}
const isValid = verifyNextcloudTalkSignature({
signature: headers.signature,
random: headers.random,
body,
secret,
});
if (!isValid) {
res.writeHead(401, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Invalid signature" }));
return;
}
const payload = parseWebhookPayload(body);
if (!payload) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Invalid payload format" }));
return;
}
if (payload.type !== "Create") {
res.writeHead(200);
res.end();
return;
}
const message = payloadToInboundMessage(payload);
res.writeHead(200);
res.end();
try {
await onMessage(message);
} catch (err) {
onError?.(err instanceof Error ? err : new Error(formatError(err)));
}
} catch (err) {
const error = err instanceof Error ? err : new Error(formatError(err));
onError?.(error);
if (!res.headersSent) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Internal server error" }));
}
}
});
const start = (): Promise<void> => {
return new Promise((resolve) => {
server.listen(port, host, () => resolve());
});
};
const stop = () => {
server.close();
};
if (abortSignal) {
abortSignal.addEventListener("abort", stop, { once: true });
}
return { server, start, stop };
}
export type NextcloudTalkMonitorOptions = {
accountId?: string;
config?: CoreConfig;
runtime?: RuntimeEnv;
abortSignal?: AbortSignal;
onMessage?: (message: NextcloudTalkInboundMessage) => void | Promise<void>;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
};
export async function monitorNextcloudTalkProvider(
opts: NextcloudTalkMonitorOptions,
): Promise<{ stop: () => void }> {
const core = getNextcloudTalkRuntime();
const cfg = opts.config ?? (core.config.loadConfig() as CoreConfig);
const account = resolveNextcloudTalkAccount({
cfg,
accountId: opts.accountId,
});
const runtime: RuntimeEnv = opts.runtime ?? {
log: (message: string) => core.logging.getChildLogger().info(message),
error: (message: string) => core.logging.getChildLogger().error(message),
exit: () => {
throw new Error("Runtime exit not available");
},
};
if (!account.secret) {
throw new Error(`Nextcloud Talk bot secret not configured for account "${account.accountId}"`);
}
const port = account.config.webhookPort ?? DEFAULT_WEBHOOK_PORT;
const host = account.config.webhookHost ?? DEFAULT_WEBHOOK_HOST;
const path = account.config.webhookPath ?? DEFAULT_WEBHOOK_PATH;
const logger = core.logging.getChildLogger({
channel: "nextcloud-talk",
accountId: account.accountId,
});
const { start, stop } = createNextcloudTalkWebhookServer({
port,
host,
path,
secret: account.secret,
onMessage: async (message) => {
core.channel.activity.record({
channel: "nextcloud-talk",
accountId: account.accountId,
direction: "inbound",
at: message.timestamp,
});
if (opts.onMessage) {
await opts.onMessage(message);
return;
}
await handleNextcloudTalkInbound({
message,
account,
config: cfg,
runtime,
statusSink: opts.statusSink,
});
},
onError: (error) => {
logger.error(`[nextcloud-talk:${account.accountId}] webhook error: ${error.message}`);
},
abortSignal: opts.abortSignal,
});
await start();
const publicUrl =
account.config.webhookPublicUrl ??
`http://${host === "0.0.0.0" ? "localhost" : host}:${port}${path}`;
logger.info(`[nextcloud-talk:${account.accountId}] webhook listening on ${publicUrl}`);
return { stop };
}
@@ -0,0 +1,31 @@
export function normalizeNextcloudTalkMessagingTarget(raw: string): string | undefined {
const trimmed = raw.trim();
if (!trimmed) return undefined;
let normalized = trimmed;
if (normalized.startsWith("nextcloud-talk:")) {
normalized = normalized.slice("nextcloud-talk:".length).trim();
} else if (normalized.startsWith("nc-talk:")) {
normalized = normalized.slice("nc-talk:".length).trim();
} else if (normalized.startsWith("nc:")) {
normalized = normalized.slice("nc:".length).trim();
}
if (normalized.startsWith("room:")) {
normalized = normalized.slice("room:".length).trim();
}
if (!normalized) return undefined;
return `nextcloud-talk:${normalized}`.toLowerCase();
}
export function looksLikeNextcloudTalkTargetId(raw: string): boolean {
const trimmed = raw.trim();
if (!trimmed) return false;
if (/^(nextcloud-talk|nc-talk|nc):/i.test(trimmed)) return true;
return /^[a-z0-9]{8,}$/i.test(trimmed);
}
+341
View File
@@ -0,0 +1,341 @@
import {
addWildcardAllowFrom,
formatDocsLink,
promptAccountId,
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
type ChannelOnboardingAdapter,
type ChannelOnboardingDmPolicy,
type WizardPrompter,
} from "clawdbot/plugin-sdk";
import {
listNextcloudTalkAccountIds,
resolveDefaultNextcloudTalkAccountId,
resolveNextcloudTalkAccount,
} from "./accounts.js";
import type { CoreConfig, DmPolicy } from "./types.js";
const channel = "nextcloud-talk" as const;
function setNextcloudTalkDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig {
const existingConfig = cfg.channels?.["nextcloud-talk"];
const existingAllowFrom: string[] = (existingConfig?.allowFrom ?? []).map((x) => String(x));
const allowFrom: string[] =
dmPolicy === "open" ? (addWildcardAllowFrom(existingAllowFrom) as string[]) : existingAllowFrom;
const newNextcloudTalkConfig = {
...existingConfig,
dmPolicy,
allowFrom,
};
return {
...cfg,
channels: {
...cfg.channels,
"nextcloud-talk": newNextcloudTalkConfig,
},
} as CoreConfig;
}
async function noteNextcloudTalkSecretHelp(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
"1) SSH into your Nextcloud server",
'2) Run: ./occ talk:bot:install "Clawdbot" "<shared-secret>" "<webhook-url>" --feature reaction',
"3) Copy the shared secret you used in the command",
"4) Enable the bot in your Nextcloud Talk room settings",
"Tip: you can also set NEXTCLOUD_TALK_BOT_SECRET in your env.",
`Docs: ${formatDocsLink("/channels/nextcloud-talk", "channels/nextcloud-talk")}`,
].join("\n"),
"Nextcloud Talk bot setup",
);
}
async function noteNextcloudTalkUserIdHelp(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
"1) Check the Nextcloud admin panel for user IDs",
"2) Or look at the webhook payload logs when someone messages",
"3) User IDs are typically lowercase usernames in Nextcloud",
`Docs: ${formatDocsLink("/channels/nextcloud-talk", "channels/nextcloud-talk")}`,
].join("\n"),
"Nextcloud Talk user id",
);
}
async function promptNextcloudTalkAllowFrom(params: {
cfg: CoreConfig;
prompter: WizardPrompter;
accountId: string;
}): Promise<CoreConfig> {
const { cfg, prompter, accountId } = params;
const resolved = resolveNextcloudTalkAccount({ cfg, accountId });
const existingAllowFrom = resolved.config.allowFrom ?? [];
await noteNextcloudTalkUserIdHelp(prompter);
const parseInput = (value: string) =>
value
.split(/[\n,;]+/g)
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean);
let resolvedIds: string[] = [];
while (resolvedIds.length === 0) {
const entry = await prompter.text({
message: "Nextcloud Talk allowFrom (user id)",
placeholder: "username",
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
resolvedIds = parseInput(String(entry));
if (resolvedIds.length === 0) {
await prompter.note("Please enter at least one valid user ID.", "Nextcloud Talk allowlist");
}
}
const merged = [
...existingAllowFrom.map((item) => String(item).trim().toLowerCase()).filter(Boolean),
...resolvedIds,
];
const unique = [...new Set(merged)];
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
"nextcloud-talk": {
...cfg.channels?.["nextcloud-talk"],
enabled: true,
dmPolicy: "allowlist",
allowFrom: unique,
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
"nextcloud-talk": {
...cfg.channels?.["nextcloud-talk"],
enabled: true,
accounts: {
...cfg.channels?.["nextcloud-talk"]?.accounts,
[accountId]: {
...cfg.channels?.["nextcloud-talk"]?.accounts?.[accountId],
enabled: cfg.channels?.["nextcloud-talk"]?.accounts?.[accountId]?.enabled ?? true,
dmPolicy: "allowlist",
allowFrom: unique,
},
},
},
},
};
}
async function promptNextcloudTalkAllowFromForAccount(params: {
cfg: CoreConfig;
prompter: WizardPrompter;
accountId?: string;
}): Promise<CoreConfig> {
const accountId =
params.accountId && normalizeAccountId(params.accountId)
? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID)
: resolveDefaultNextcloudTalkAccountId(params.cfg);
return promptNextcloudTalkAllowFrom({
cfg: params.cfg,
prompter: params.prompter,
accountId,
});
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "Nextcloud Talk",
channel,
policyKey: "channels.nextcloud-talk.dmPolicy",
allowFromKey: "channels.nextcloud-talk.allowFrom",
getCurrent: (cfg) => cfg.channels?.["nextcloud-talk"]?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) => setNextcloudTalkDmPolicy(cfg as CoreConfig, policy as DmPolicy),
promptAllowFrom: promptNextcloudTalkAllowFromForAccount,
};
export const nextcloudTalkOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const configured = listNextcloudTalkAccountIds(cfg as CoreConfig).some((accountId) => {
const account = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId });
return Boolean(account.secret && account.baseUrl);
});
return {
channel,
configured,
statusLines: [`Nextcloud Talk: ${configured ? "configured" : "needs setup"}`],
selectionHint: configured ? "configured" : "self-hosted chat",
quickstartScore: configured ? 1 : 5,
};
},
configure: async ({
cfg,
prompter,
accountOverrides,
shouldPromptAccountIds,
forceAllowFrom,
}) => {
const nextcloudTalkOverride = accountOverrides["nextcloud-talk"]?.trim();
const defaultAccountId = resolveDefaultNextcloudTalkAccountId(cfg as CoreConfig);
let accountId = nextcloudTalkOverride
? normalizeAccountId(nextcloudTalkOverride)
: defaultAccountId;
if (shouldPromptAccountIds && !nextcloudTalkOverride) {
accountId = await promptAccountId({
cfg: cfg as CoreConfig,
prompter,
label: "Nextcloud Talk",
currentId: accountId,
listAccountIds: listNextcloudTalkAccountIds,
defaultAccountId,
});
}
let next = cfg as CoreConfig;
const resolvedAccount = resolveNextcloudTalkAccount({
cfg: next,
accountId,
});
const accountConfigured = Boolean(resolvedAccount.secret && resolvedAccount.baseUrl);
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
const canUseEnv = allowEnv && Boolean(process.env.NEXTCLOUD_TALK_BOT_SECRET?.trim());
const hasConfigSecret = Boolean(
resolvedAccount.config.botSecret || resolvedAccount.config.botSecretFile,
);
let baseUrl = resolvedAccount.baseUrl;
if (!baseUrl) {
baseUrl = String(
await prompter.text({
message: "Enter Nextcloud instance URL (e.g., https://cloud.example.com)",
validate: (value) => {
const v = String(value ?? "").trim();
if (!v) return "Required";
if (!v.startsWith("http://") && !v.startsWith("https://")) {
return "URL must start with http:// or https://";
}
return undefined;
},
}),
).trim();
}
let secret: string | null = null;
if (!accountConfigured) {
await noteNextcloudTalkSecretHelp(prompter);
}
if (canUseEnv && !resolvedAccount.config.botSecret) {
const keepEnv = await prompter.confirm({
message: "NEXTCLOUD_TALK_BOT_SECRET detected. Use env var?",
initialValue: true,
});
if (keepEnv) {
next = {
...next,
channels: {
...next.channels,
"nextcloud-talk": {
...next.channels?.["nextcloud-talk"],
enabled: true,
baseUrl,
},
},
};
} else {
secret = String(
await prompter.text({
message: "Enter Nextcloud Talk bot secret",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
} else if (hasConfigSecret) {
const keep = await prompter.confirm({
message: "Nextcloud Talk secret already configured. Keep it?",
initialValue: true,
});
if (!keep) {
secret = String(
await prompter.text({
message: "Enter Nextcloud Talk bot secret",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
} else {
secret = String(
await prompter.text({
message: "Enter Nextcloud Talk bot secret",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
if (secret || baseUrl !== resolvedAccount.baseUrl) {
if (accountId === DEFAULT_ACCOUNT_ID) {
next = {
...next,
channels: {
...next.channels,
"nextcloud-talk": {
...next.channels?.["nextcloud-talk"],
enabled: true,
baseUrl,
...(secret ? { botSecret: secret } : {}),
},
},
};
} else {
next = {
...next,
channels: {
...next.channels,
"nextcloud-talk": {
...next.channels?.["nextcloud-talk"],
enabled: true,
accounts: {
...next.channels?.["nextcloud-talk"]?.accounts,
[accountId]: {
...next.channels?.["nextcloud-talk"]?.accounts?.[accountId],
enabled: next.channels?.["nextcloud-talk"]?.accounts?.[accountId]?.enabled ?? true,
baseUrl,
...(secret ? { botSecret: secret } : {}),
},
},
},
},
};
}
}
if (forceAllowFrom) {
next = await promptNextcloudTalkAllowFrom({
cfg: next,
prompter,
accountId,
});
}
return { cfg: next, accountId };
},
dmPolicy,
disable: (cfg) => ({
...cfg,
channels: {
...cfg.channels,
"nextcloud-talk": { ...cfg.channels?.["nextcloud-talk"], enabled: false },
},
}),
};
+160
View File
@@ -0,0 +1,160 @@
import type { AllowlistMatch, GroupPolicy } from "clawdbot/plugin-sdk";
import {
buildChannelKeyCandidates,
normalizeChannelSlug,
resolveChannelEntryMatchWithFallback,
resolveMentionGatingWithBypass,
resolveNestedAllowlistDecision,
} from "clawdbot/plugin-sdk";
import type { NextcloudTalkRoomConfig } from "./types.js";
function normalizeAllowEntry(raw: string): string {
return raw.trim().toLowerCase().replace(/^(nextcloud-talk|nc-talk|nc):/i, "");
}
export function normalizeNextcloudTalkAllowlist(
values: Array<string | number> | undefined,
): string[] {
return (values ?? []).map((value) => normalizeAllowEntry(String(value))).filter(Boolean);
}
export function resolveNextcloudTalkAllowlistMatch(params: {
allowFrom: Array<string | number> | undefined;
senderId: string;
senderName?: string | null;
}): AllowlistMatch<"wildcard" | "id" | "name"> {
const allowFrom = normalizeNextcloudTalkAllowlist(params.allowFrom);
if (allowFrom.length === 0) return { allowed: false };
if (allowFrom.includes("*")) {
return { allowed: true, matchKey: "*", matchSource: "wildcard" };
}
const senderId = normalizeAllowEntry(params.senderId);
if (allowFrom.includes(senderId)) {
return { allowed: true, matchKey: senderId, matchSource: "id" };
}
const senderName = params.senderName ? normalizeAllowEntry(params.senderName) : "";
if (senderName && allowFrom.includes(senderName)) {
return { allowed: true, matchKey: senderName, matchSource: "name" };
}
return { allowed: false };
}
export type NextcloudTalkRoomMatch = {
roomConfig?: NextcloudTalkRoomConfig;
wildcardConfig?: NextcloudTalkRoomConfig;
roomKey?: string;
matchSource?: "direct" | "parent" | "wildcard";
allowed: boolean;
allowlistConfigured: boolean;
};
export function resolveNextcloudTalkRoomMatch(params: {
rooms?: Record<string, NextcloudTalkRoomConfig>;
roomToken: string;
roomName?: string | null;
}): NextcloudTalkRoomMatch {
const rooms = params.rooms ?? {};
const allowlistConfigured = Object.keys(rooms).length > 0;
const roomName = params.roomName?.trim() || undefined;
const roomCandidates = buildChannelKeyCandidates(
params.roomToken,
roomName,
roomName ? normalizeChannelSlug(roomName) : undefined,
);
const match = resolveChannelEntryMatchWithFallback({
entries: rooms,
keys: roomCandidates,
wildcardKey: "*",
normalizeKey: normalizeChannelSlug,
});
const roomConfig = match.entry;
const allowed = resolveNestedAllowlistDecision({
outerConfigured: allowlistConfigured,
outerMatched: Boolean(roomConfig),
innerConfigured: false,
innerMatched: false,
});
return {
roomConfig,
wildcardConfig: match.wildcardEntry,
roomKey: match.matchKey ?? match.key,
matchSource: match.matchSource,
allowed,
allowlistConfigured,
};
}
export function resolveNextcloudTalkRequireMention(params: {
roomConfig?: NextcloudTalkRoomConfig;
wildcardConfig?: NextcloudTalkRoomConfig;
}): boolean {
if (typeof params.roomConfig?.requireMention === "boolean") {
return params.roomConfig.requireMention;
}
if (typeof params.wildcardConfig?.requireMention === "boolean") {
return params.wildcardConfig.requireMention;
}
return true;
}
export function resolveNextcloudTalkGroupAllow(params: {
groupPolicy: GroupPolicy;
outerAllowFrom: Array<string | number> | undefined;
innerAllowFrom: Array<string | number> | undefined;
senderId: string;
senderName?: string | null;
}): { allowed: boolean; outerMatch: AllowlistMatch; innerMatch: AllowlistMatch } {
if (params.groupPolicy === "disabled") {
return { allowed: false, outerMatch: { allowed: false }, innerMatch: { allowed: false } };
}
if (params.groupPolicy === "open") {
return { allowed: true, outerMatch: { allowed: true }, innerMatch: { allowed: true } };
}
const outerAllow = normalizeNextcloudTalkAllowlist(params.outerAllowFrom);
const innerAllow = normalizeNextcloudTalkAllowlist(params.innerAllowFrom);
if (outerAllow.length === 0 && innerAllow.length === 0) {
return { allowed: false, outerMatch: { allowed: false }, innerMatch: { allowed: false } };
}
const outerMatch = resolveNextcloudTalkAllowlistMatch({
allowFrom: params.outerAllowFrom,
senderId: params.senderId,
senderName: params.senderName,
});
const innerMatch = resolveNextcloudTalkAllowlistMatch({
allowFrom: params.innerAllowFrom,
senderId: params.senderId,
senderName: params.senderName,
});
const allowed = resolveNestedAllowlistDecision({
outerConfigured: outerAllow.length > 0 || innerAllow.length > 0,
outerMatched: outerAllow.length > 0 ? outerMatch.allowed : true,
innerConfigured: innerAllow.length > 0,
innerMatched: innerMatch.allowed,
});
return { allowed, outerMatch, innerMatch };
}
export function resolveNextcloudTalkMentionGate(params: {
isGroup: boolean;
requireMention: boolean;
wasMentioned: boolean;
allowTextCommands: boolean;
hasControlCommand: boolean;
commandAuthorized: boolean;
}): { shouldSkip: boolean; shouldBypassMention: boolean } {
const result = resolveMentionGatingWithBypass({
isGroup: params.isGroup,
requireMention: params.requireMention,
canDetectMention: true,
wasMentioned: params.wasMentioned,
allowTextCommands: params.allowTextCommands,
hasControlCommand: params.hasControlCommand,
commandAuthorized: params.commandAuthorized,
});
return { shouldSkip: result.shouldSkip, shouldBypassMention: result.shouldBypassMention };
}
+111
View File
@@ -0,0 +1,111 @@
import { readFileSync } from "node:fs";
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
const ROOM_CACHE_TTL_MS = 5 * 60 * 1000;
const ROOM_CACHE_ERROR_TTL_MS = 30 * 1000;
const roomCache = new Map<
string,
{ kind?: "direct" | "group"; fetchedAt: number; error?: string }
>();
function resolveRoomCacheKey(params: { accountId: string; roomToken: string }) {
return `${params.accountId}:${params.roomToken}`;
}
function readApiPassword(params: {
apiPassword?: string;
apiPasswordFile?: string;
}): string | undefined {
if (params.apiPassword?.trim()) return params.apiPassword.trim();
if (!params.apiPasswordFile) return undefined;
try {
const value = readFileSync(params.apiPasswordFile, "utf-8").trim();
return value || undefined;
} catch {
return undefined;
}
}
function coerceRoomType(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
function resolveRoomKindFromType(type: number | undefined): "direct" | "group" | undefined {
if (!type) return undefined;
if (type === 1 || type === 5 || type === 6) return "direct";
return "group";
}
export async function resolveNextcloudTalkRoomKind(params: {
account: ResolvedNextcloudTalkAccount;
roomToken: string;
runtime?: RuntimeEnv;
}): Promise<"direct" | "group" | undefined> {
const { account, roomToken, runtime } = params;
const key = resolveRoomCacheKey({ accountId: account.accountId, roomToken });
const cached = roomCache.get(key);
if (cached) {
const age = Date.now() - cached.fetchedAt;
if (cached.kind && age < ROOM_CACHE_TTL_MS) return cached.kind;
if (cached.error && age < ROOM_CACHE_ERROR_TTL_MS) return undefined;
}
const apiUser = account.config.apiUser?.trim();
const apiPassword = readApiPassword({
apiPassword: account.config.apiPassword,
apiPasswordFile: account.config.apiPasswordFile,
});
if (!apiUser || !apiPassword) return undefined;
const baseUrl = account.baseUrl?.trim();
if (!baseUrl) return undefined;
const url = `${baseUrl}/ocs/v2.php/apps/spreed/api/v4/room/${roomToken}`;
const auth = Buffer.from(`${apiUser}:${apiPassword}`, "utf-8").toString("base64");
try {
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Basic ${auth}`,
"OCS-APIRequest": "true",
Accept: "application/json",
},
});
if (!response.ok) {
roomCache.set(key, {
fetchedAt: Date.now(),
error: `status:${response.status}`,
});
runtime?.log?.(
`nextcloud-talk: room lookup failed (${response.status}) token=${roomToken}`,
);
return undefined;
}
const payload = (await response.json()) as {
ocs?: { data?: { type?: number | string } };
};
const type = coerceRoomType(payload.ocs?.data?.type);
const kind = resolveRoomKindFromType(type);
roomCache.set(key, { fetchedAt: Date.now(), kind });
return kind;
} catch (err) {
roomCache.set(key, {
fetchedAt: Date.now(),
error: err instanceof Error ? err.message : String(err),
});
runtime?.error?.(`nextcloud-talk: room lookup error: ${String(err)}`);
return undefined;
}
}
+14
View File
@@ -0,0 +1,14 @@
import type { PluginRuntime } from "clawdbot/plugin-sdk";
let runtime: PluginRuntime | null = null;
export function setNextcloudTalkRuntime(next: PluginRuntime) {
runtime = next;
}
export function getNextcloudTalkRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("Nextcloud Talk runtime not initialized");
}
return runtime;
}
+196
View File
@@ -0,0 +1,196 @@
import { resolveNextcloudTalkAccount } from "./accounts.js";
import { getNextcloudTalkRuntime } from "./runtime.js";
import { generateNextcloudTalkSignature } from "./signature.js";
import type { CoreConfig, NextcloudTalkSendResult } from "./types.js";
type NextcloudTalkSendOpts = {
baseUrl?: string;
secret?: string;
accountId?: string;
replyTo?: string;
verbose?: boolean;
};
function resolveCredentials(
explicit: { baseUrl?: string; secret?: string },
account: { baseUrl: string; secret: string; accountId: string },
): { baseUrl: string; secret: string } {
const baseUrl = explicit.baseUrl?.trim() ?? account.baseUrl;
const secret = explicit.secret?.trim() ?? account.secret;
if (!baseUrl) {
throw new Error(
`Nextcloud Talk baseUrl missing for account "${account.accountId}" (set channels.nextcloud-talk.baseUrl).`,
);
}
if (!secret) {
throw new Error(
`Nextcloud Talk bot secret missing for account "${account.accountId}" (set channels.nextcloud-talk.botSecret/botSecretFile or NEXTCLOUD_TALK_BOT_SECRET for default).`,
);
}
return { baseUrl, secret };
}
function normalizeRoomToken(to: string): string {
const trimmed = to.trim();
if (!trimmed) throw new Error("Room token is required for Nextcloud Talk sends");
let normalized = trimmed;
if (normalized.startsWith("nextcloud-talk:")) {
normalized = normalized.slice("nextcloud-talk:".length).trim();
} else if (normalized.startsWith("nc:")) {
normalized = normalized.slice("nc:".length).trim();
}
if (normalized.startsWith("room:")) {
normalized = normalized.slice("room:".length).trim();
}
if (!normalized) throw new Error("Room token is required for Nextcloud Talk sends");
return normalized;
}
export async function sendMessageNextcloudTalk(
to: string,
text: string,
opts: NextcloudTalkSendOpts = {},
): Promise<NextcloudTalkSendResult> {
const cfg = getNextcloudTalkRuntime().config.loadConfig() as CoreConfig;
const account = resolveNextcloudTalkAccount({
cfg,
accountId: opts.accountId,
});
const { baseUrl, secret } = resolveCredentials(
{ baseUrl: opts.baseUrl, secret: opts.secret },
account,
);
const roomToken = normalizeRoomToken(to);
if (!text?.trim()) {
throw new Error("Message must be non-empty for Nextcloud Talk sends");
}
const body: Record<string, unknown> = {
message: text.trim(),
};
if (opts.replyTo) {
body.replyTo = opts.replyTo;
}
const bodyStr = JSON.stringify(body);
const { random, signature } = generateNextcloudTalkSignature({
body: bodyStr,
secret,
});
const url = `${baseUrl}/ocs/v2.php/apps/spreed/api/v1/bot/${roomToken}/message`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"OCS-APIRequest": "true",
"X-Nextcloud-Talk-Bot-Random": random,
"X-Nextcloud-Talk-Bot-Signature": signature,
},
body: bodyStr,
});
if (!response.ok) {
const errorBody = await response.text().catch(() => "");
const status = response.status;
let errorMsg = `Nextcloud Talk send failed (${status})`;
if (status === 400) {
errorMsg = `Nextcloud Talk: bad request - ${errorBody || "invalid message format"}`;
} else if (status === 401) {
errorMsg = "Nextcloud Talk: authentication failed - check bot secret";
} else if (status === 403) {
errorMsg = "Nextcloud Talk: forbidden - bot may not have permission in this room";
} else if (status === 404) {
errorMsg = `Nextcloud Talk: room not found (token=${roomToken})`;
} else if (errorBody) {
errorMsg = `Nextcloud Talk send failed: ${errorBody}`;
}
throw new Error(errorMsg);
}
let messageId = "unknown";
let timestamp: number | undefined;
try {
const data = (await response.json()) as {
ocs?: {
data?: {
id?: number | string;
timestamp?: number;
};
};
};
if (data.ocs?.data?.id != null) {
messageId = String(data.ocs.data.id);
}
if (typeof data.ocs?.data?.timestamp === "number") {
timestamp = data.ocs.data.timestamp;
}
} catch {
// Response parsing failed, but message was sent.
}
if (opts.verbose) {
console.log(`[nextcloud-talk] Sent message ${messageId} to room ${roomToken}`);
}
getNextcloudTalkRuntime().channel.activity.record({
channel: "nextcloud-talk",
accountId: account.accountId,
direction: "outbound",
});
return { messageId, roomToken, timestamp };
}
export async function sendReactionNextcloudTalk(
roomToken: string,
messageId: string,
reaction: string,
opts: Omit<NextcloudTalkSendOpts, "replyTo"> = {},
): Promise<{ ok: true }> {
const cfg = getNextcloudTalkRuntime().config.loadConfig() as CoreConfig;
const account = resolveNextcloudTalkAccount({
cfg,
accountId: opts.accountId,
});
const { baseUrl, secret } = resolveCredentials(
{ baseUrl: opts.baseUrl, secret: opts.secret },
account,
);
const normalizedToken = normalizeRoomToken(roomToken);
const body = JSON.stringify({ reaction });
const { random, signature } = generateNextcloudTalkSignature({
body,
secret,
});
const url = `${baseUrl}/ocs/v2.php/apps/spreed/api/v1/bot/${normalizedToken}/reaction/${messageId}`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"OCS-APIRequest": "true",
"X-Nextcloud-Talk-Bot-Random": random,
"X-Nextcloud-Talk-Bot-Signature": signature,
},
body,
});
if (!response.ok) {
const errorBody = await response.text().catch(() => "");
throw new Error(`Nextcloud Talk reaction failed: ${response.status} ${errorBody}`.trim());
}
return { ok: true };
}
@@ -0,0 +1,67 @@
import { createHmac, randomBytes } from "node:crypto";
import type { NextcloudTalkWebhookHeaders } from "./types.js";
const SIGNATURE_HEADER = "x-nextcloud-talk-signature";
const RANDOM_HEADER = "x-nextcloud-talk-random";
const BACKEND_HEADER = "x-nextcloud-talk-backend";
/**
* Verify the HMAC-SHA256 signature of an incoming webhook request.
* Signature is calculated as: HMAC-SHA256(random + body, secret)
*/
export function verifyNextcloudTalkSignature(params: {
signature: string;
random: string;
body: string;
secret: string;
}): boolean {
const { signature, random, body, secret } = params;
if (!signature || !random || !secret) return false;
const expected = createHmac("sha256", secret)
.update(random + body)
.digest("hex");
if (signature.length !== expected.length) return false;
let result = 0;
for (let i = 0; i < signature.length; i++) {
result |= signature.charCodeAt(i) ^ expected.charCodeAt(i);
}
return result === 0;
}
/**
* Extract webhook headers from an incoming request.
*/
export function extractNextcloudTalkHeaders(
headers: Record<string, string | string[] | undefined>,
): NextcloudTalkWebhookHeaders | null {
const getHeader = (name: string): string | undefined => {
const value = headers[name] ?? headers[name.toLowerCase()];
return Array.isArray(value) ? value[0] : value;
};
const signature = getHeader(SIGNATURE_HEADER);
const random = getHeader(RANDOM_HEADER);
const backend = getHeader(BACKEND_HEADER);
if (!signature || !random || !backend) return null;
return { signature, random, backend };
}
/**
* Generate signature headers for an outbound request to Nextcloud Talk.
*/
export function generateNextcloudTalkSignature(params: { body: string; secret: string }): {
random: string;
signature: string;
} {
const { body, secret } = params;
const random = randomBytes(32).toString("hex");
const signature = createHmac("sha256", secret)
.update(random + body)
.digest("hex");
return { random, signature };
}
+175
View File
@@ -0,0 +1,175 @@
import type {
BlockStreamingCoalesceConfig,
DmConfig,
DmPolicy,
GroupPolicy,
} from "clawdbot/plugin-sdk";
export type NextcloudTalkRoomConfig = {
requireMention?: boolean;
/** If specified, only load these skills for this room. Omit = all skills; empty = no skills. */
skills?: string[];
/** If false, disable the bot for this room. */
enabled?: boolean;
/** Optional allowlist for room senders (user ids). */
allowFrom?: string[];
/** Optional system prompt snippet for this room. */
systemPrompt?: string;
};
export type NextcloudTalkAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
name?: string;
/** If false, do not start this Nextcloud Talk account. Default: true. */
enabled?: boolean;
/** Base URL of the Nextcloud instance (e.g., "https://cloud.example.com"). */
baseUrl?: string;
/** Bot shared secret from occ talk:bot:install output. */
botSecret?: string;
/** Path to file containing bot secret (for secret managers). */
botSecretFile?: string;
/** Optional API user for room lookups (DM detection). */
apiUser?: string;
/** Optional API password/app password for room lookups. */
apiPassword?: string;
/** Path to file containing API password/app password. */
apiPasswordFile?: string;
/** Direct message policy (default: pairing). */
dmPolicy?: DmPolicy;
/** Webhook server port. Default: 8788. */
webhookPort?: number;
/** Webhook server host. Default: "0.0.0.0". */
webhookHost?: string;
/** Webhook endpoint path. Default: "/nextcloud-talk-webhook". */
webhookPath?: string;
/** Public URL for the webhook (used if behind reverse proxy). */
webhookPublicUrl?: string;
/** Optional allowlist of user IDs allowed to DM the bot. */
allowFrom?: string[];
/** Optional allowlist for Nextcloud Talk room senders (user ids). */
groupAllowFrom?: string[];
/** Group message policy (default: allowlist). */
groupPolicy?: GroupPolicy;
/** Per-room configuration (key is room token). */
rooms?: Record<string, NextcloudTalkRoomConfig>;
/** Max group messages to keep as history context (0 disables). */
historyLimit?: number;
/** Max DM turns to keep as history context. */
dmHistoryLimit?: number;
/** Per-DM config overrides keyed by user ID. */
dms?: Record<string, DmConfig>;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
/** Disable block streaming for this account. */
blockStreaming?: boolean;
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
/** Media upload max size in MB. */
mediaMaxMb?: number;
};
export type NextcloudTalkConfig = {
/** Optional per-account Nextcloud Talk configuration (multi-account). */
accounts?: Record<string, NextcloudTalkAccountConfig>;
} & NextcloudTalkAccountConfig;
export type CoreConfig = {
channels?: {
"nextcloud-talk"?: NextcloudTalkConfig;
};
[key: string]: unknown;
};
/**
* Nextcloud Talk webhook payload types based on Activity Streams 2.0 format.
* Reference: https://nextcloud-talk.readthedocs.io/en/latest/bots/
*/
/** Actor in the activity (the message sender). */
export type NextcloudTalkActor = {
type: "Person";
/** User ID in Nextcloud. */
id: string;
/** Display name of the user. */
name: string;
};
/** The message object in the activity. */
export type NextcloudTalkObject = {
type: "Note";
/** Message ID. */
id: string;
/** Message text (same as content for text/plain). */
name: string;
/** Message content. */
content: string;
/** Media type of the content. */
mediaType: string;
};
/** Target conversation/room. */
export type NextcloudTalkTarget = {
type: "Collection";
/** Room token. */
id: string;
/** Room display name. */
name: string;
};
/** Incoming webhook payload from Nextcloud Talk. */
export type NextcloudTalkWebhookPayload = {
type: "Create" | "Update" | "Delete";
actor: NextcloudTalkActor;
object: NextcloudTalkObject;
target: NextcloudTalkTarget;
};
/** Result from sending a message to Nextcloud Talk. */
export type NextcloudTalkSendResult = {
messageId: string;
roomToken: string;
timestamp?: number;
};
/** Parsed incoming message context. */
export type NextcloudTalkInboundMessage = {
messageId: string;
roomToken: string;
roomName: string;
senderId: string;
senderName: string;
text: string;
mediaType: string;
timestamp: number;
isGroupChat: boolean;
};
/** Headers sent by Nextcloud Talk webhook. */
export type NextcloudTalkWebhookHeaders = {
/** HMAC-SHA256 signature of the request. */
signature: string;
/** Random string used in signature calculation. */
random: string;
/** Backend Nextcloud server URL. */
backend: string;
};
/** Options for the webhook server. */
export type NextcloudTalkWebhookServerOptions = {
port: number;
host: string;
path: string;
secret: string;
onMessage: (message: NextcloudTalkInboundMessage) => void | Promise<void>;
onError?: (error: Error) => void;
abortSignal?: AbortSignal;
};
/** Options for sending a message. */
export type NextcloudTalkSendOptions = {
baseUrl: string;
secret: string;
roomToken: string;
message: string;
replyTo?: string;
};
@@ -0,0 +1,11 @@
{
"id": "qwen-portal-auth",
"providers": [
"qwen-portal"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"id": "signal",
"channels": [
"signal"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"id": "slack",
"channels": [
"slack"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"id": "telegram",
"channels": [
"telegram"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
+405
View File
@@ -0,0 +1,405 @@
{
"id": "voice-call",
"uiHints": {
"provider": {
"label": "Provider",
"help": "Use twilio, telnyx, or mock for dev/no-network."
},
"fromNumber": {
"label": "From Number",
"placeholder": "+15550001234"
},
"toNumber": {
"label": "Default To Number",
"placeholder": "+15550001234"
},
"inboundPolicy": {
"label": "Inbound Policy"
},
"allowFrom": {
"label": "Inbound Allowlist"
},
"inboundGreeting": {
"label": "Inbound Greeting",
"advanced": true
},
"telnyx.apiKey": {
"label": "Telnyx API Key",
"sensitive": true
},
"telnyx.connectionId": {
"label": "Telnyx Connection ID"
},
"telnyx.publicKey": {
"label": "Telnyx Public Key",
"sensitive": true
},
"twilio.accountSid": {
"label": "Twilio Account SID"
},
"twilio.authToken": {
"label": "Twilio Auth Token",
"sensitive": true
},
"outbound.defaultMode": {
"label": "Default Call Mode"
},
"outbound.notifyHangupDelaySec": {
"label": "Notify Hangup Delay (sec)",
"advanced": true
},
"serve.port": {
"label": "Webhook Port"
},
"serve.bind": {
"label": "Webhook Bind"
},
"serve.path": {
"label": "Webhook Path"
},
"tailscale.mode": {
"label": "Tailscale Mode",
"advanced": true
},
"tailscale.path": {
"label": "Tailscale Path",
"advanced": true
},
"tunnel.provider": {
"label": "Tunnel Provider",
"advanced": true
},
"tunnel.ngrokAuthToken": {
"label": "ngrok Auth Token",
"sensitive": true,
"advanced": true
},
"tunnel.ngrokDomain": {
"label": "ngrok Domain",
"advanced": true
},
"tunnel.allowNgrokFreeTier": {
"label": "Allow ngrok Free Tier",
"advanced": true
},
"streaming.enabled": {
"label": "Enable Streaming",
"advanced": true
},
"streaming.openaiApiKey": {
"label": "OpenAI Realtime API Key",
"sensitive": true,
"advanced": true
},
"streaming.sttModel": {
"label": "Realtime STT Model",
"advanced": true
},
"streaming.streamPath": {
"label": "Media Stream Path",
"advanced": true
},
"tts.model": {
"label": "TTS Model",
"advanced": true
},
"tts.voice": {
"label": "TTS Voice",
"advanced": true
},
"tts.instructions": {
"label": "TTS Instructions",
"advanced": true
},
"publicUrl": {
"label": "Public Webhook URL",
"advanced": true
},
"skipSignatureVerification": {
"label": "Skip Signature Verification",
"advanced": true
},
"store": {
"label": "Call Log Store Path",
"advanced": true
},
"responseModel": {
"label": "Response Model",
"advanced": true
},
"responseSystemPrompt": {
"label": "Response System Prompt",
"advanced": true
},
"responseTimeoutMs": {
"label": "Response Timeout (ms)",
"advanced": true
}
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"provider": {
"type": "string",
"enum": [
"telnyx",
"twilio",
"plivo",
"mock"
]
},
"telnyx": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": {
"type": "string"
},
"connectionId": {
"type": "string"
},
"publicKey": {
"type": "string"
}
}
},
"twilio": {
"type": "object",
"additionalProperties": false,
"properties": {
"accountSid": {
"type": "string"
},
"authToken": {
"type": "string"
}
}
},
"plivo": {
"type": "object",
"additionalProperties": false,
"properties": {
"authId": {
"type": "string"
},
"authToken": {
"type": "string"
}
}
},
"fromNumber": {
"type": "string",
"pattern": "^\\+[1-9]\\d{1,14}$"
},
"toNumber": {
"type": "string",
"pattern": "^\\+[1-9]\\d{1,14}$"
},
"inboundPolicy": {
"type": "string",
"enum": [
"disabled",
"allowlist",
"pairing",
"open"
]
},
"allowFrom": {
"type": "array",
"items": {
"type": "string",
"pattern": "^\\+[1-9]\\d{1,14}$"
}
},
"inboundGreeting": {
"type": "string"
},
"outbound": {
"type": "object",
"additionalProperties": false,
"properties": {
"defaultMode": {
"type": "string",
"enum": [
"notify",
"conversation"
]
},
"notifyHangupDelaySec": {
"type": "integer",
"minimum": 0
}
}
},
"maxDurationSeconds": {
"type": "integer",
"minimum": 1
},
"silenceTimeoutMs": {
"type": "integer",
"minimum": 1
},
"transcriptTimeoutMs": {
"type": "integer",
"minimum": 1
},
"ringTimeoutMs": {
"type": "integer",
"minimum": 1
},
"maxConcurrentCalls": {
"type": "integer",
"minimum": 1
},
"serve": {
"type": "object",
"additionalProperties": false,
"properties": {
"port": {
"type": "integer",
"minimum": 1
},
"bind": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"tailscale": {
"type": "object",
"additionalProperties": false,
"properties": {
"mode": {
"type": "string",
"enum": [
"off",
"serve",
"funnel"
]
},
"path": {
"type": "string"
}
}
},
"tunnel": {
"type": "object",
"additionalProperties": false,
"properties": {
"provider": {
"type": "string",
"enum": [
"none",
"ngrok",
"tailscale-serve",
"tailscale-funnel"
]
},
"ngrokAuthToken": {
"type": "string"
},
"ngrokDomain": {
"type": "string"
},
"allowNgrokFreeTier": {
"type": "boolean"
}
}
},
"streaming": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"sttProvider": {
"type": "string",
"enum": [
"openai-realtime"
]
},
"openaiApiKey": {
"type": "string"
},
"sttModel": {
"type": "string"
},
"silenceDurationMs": {
"type": "integer",
"minimum": 1
},
"vadThreshold": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"streamPath": {
"type": "string"
}
}
},
"publicUrl": {
"type": "string"
},
"skipSignatureVerification": {
"type": "boolean"
},
"stt": {
"type": "object",
"additionalProperties": false,
"properties": {
"provider": {
"type": "string",
"enum": [
"openai"
]
},
"model": {
"type": "string"
}
}
},
"tts": {
"type": "object",
"additionalProperties": false,
"properties": {
"provider": {
"type": "string",
"enum": [
"openai"
]
},
"model": {
"type": "string"
},
"voice": {
"type": "string"
},
"instructions": {
"type": "string"
}
}
},
"store": {
"type": "string"
},
"responseModel": {
"type": "string"
},
"responseSystemPrompt": {
"type": "string"
},
"responseTimeoutMs": {
"type": "integer",
"minimum": 1
}
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"id": "whatsapp",
"channels": [
"whatsapp"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"id": "zalo",
"channels": [
"zalo"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
+17 -1
View File
@@ -6,7 +6,23 @@
"clawdbot": { "clawdbot": {
"extensions": [ "extensions": [
"./index.ts" "./index.ts"
] ],
"channel": {
"id": "zalo",
"label": "Zalo",
"selectionLabel": "Zalo (Bot API)",
"docsPath": "/channels/zalo",
"docsLabel": "zalo",
"blurb": "Vietnam-focused messaging platform with Bot API.",
"aliases": ["zl"],
"order": 80,
"quickstartAllowFrom": true
},
"install": {
"npmSpec": "@clawdbot/zalo",
"localPath": "extensions/zalo",
"defaultChoice": "npm"
}
}, },
"dependencies": { "dependencies": {
"clawdbot": "workspace:*", "clawdbot": "workspace:*",
+11
View File
@@ -0,0 +1,11 @@
{
"id": "zalouser",
"channels": [
"zalouser"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
+3 -1
View File
@@ -1,5 +1,6 @@
{ {
"ensureLogins": [ "ensureLogins": [
"odrobnik",
"alphonse-arianee", "alphonse-arianee",
"ronak-guliani", "ronak-guliani",
"cpojer", "cpojer",
@@ -9,7 +10,8 @@
"longmaba", "longmaba",
"manmal", "manmal",
"thesash", "thesash",
"rhjoh" "rhjoh",
"ysqander"
], ],
"seedCommit": "d6863f87", "seedCommit": "d6863f87",
"placeholderAvatar": "assets/avatar-placeholder.svg", "placeholderAvatar": "assets/avatar-placeholder.svg",
@@ -3,7 +3,8 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { ensureAuthProfileStore } from "./auth-profiles.js"; import { ensureAuthProfileStore } from "./auth-profiles.js";
import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js"; import { AUTH_STORE_VERSION, CODEX_CLI_PROFILE_ID } from "./auth-profiles/constants.js";
import { withTempHome } from "../../test/helpers/temp-home.js";
describe("ensureAuthProfileStore", () => { describe("ensureAuthProfileStore", () => {
it("migrates legacy auth.json and deletes it (PR #368)", () => { it("migrates legacy auth.json and deletes it (PR #368)", () => {
@@ -122,4 +123,80 @@ describe("ensureAuthProfileStore", () => {
fs.rmSync(root, { recursive: true, force: true }); fs.rmSync(root, { recursive: true, force: true });
} }
}); });
it("drops codex-cli from merged store when a custom openai-codex profile matches", async () => {
await withTempHome(async (tempHome) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-auth-dedup-merge-"));
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
try {
const mainDir = path.join(root, "main-agent");
const agentDir = path.join(root, "agent-x");
fs.mkdirSync(mainDir, { recursive: true });
fs.mkdirSync(agentDir, { recursive: true });
process.env.CLAWDBOT_AGENT_DIR = mainDir;
process.env.PI_CODING_AGENT_DIR = mainDir;
process.env.HOME = tempHome;
fs.writeFileSync(
path.join(mainDir, "auth-profiles.json"),
`${JSON.stringify(
{
version: AUTH_STORE_VERSION,
profiles: {
[CODEX_CLI_PROFILE_ID]: {
type: "oauth",
provider: "openai-codex",
access: "shared-access-token",
refresh: "shared-refresh-token",
expires: Date.now() + 3600000,
},
},
},
null,
2,
)}\n`,
"utf8",
);
fs.writeFileSync(
path.join(agentDir, "auth-profiles.json"),
`${JSON.stringify(
{
version: AUTH_STORE_VERSION,
profiles: {
"openai-codex:my-custom-profile": {
type: "oauth",
provider: "openai-codex",
access: "shared-access-token",
refresh: "shared-refresh-token",
expires: Date.now() + 3600000,
},
},
},
null,
2,
)}\n`,
"utf8",
);
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeUndefined();
expect(store.profiles["openai-codex:my-custom-profile"]).toBeDefined();
} finally {
if (previousAgentDir === undefined) {
delete process.env.CLAWDBOT_AGENT_DIR;
} else {
process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
}
if (previousPiAgentDir === undefined) {
delete process.env.PI_CODING_AGENT_DIR;
} else {
process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
}
fs.rmSync(root, { recursive: true, force: true });
}
});
});
}); });
@@ -0,0 +1,166 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { withTempHome } from "../../test/helpers/temp-home.js";
import { CODEX_CLI_PROFILE_ID, ensureAuthProfileStore } from "./auth-profiles.js";
describe("external CLI credential sync", () => {
it("skips codex-cli sync when credentials already exist in another openai-codex profile", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-codex-dedup-skip-"));
try {
await withTempHome(
async (tempHome) => {
const codexDir = path.join(tempHome, ".codex");
fs.mkdirSync(codexDir, { recursive: true });
const codexAuthPath = path.join(codexDir, "auth.json");
fs.writeFileSync(
codexAuthPath,
JSON.stringify({
tokens: {
access_token: "shared-access-token",
refresh_token: "shared-refresh-token",
},
}),
);
fs.utimesSync(codexAuthPath, new Date(), new Date());
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
"openai-codex:my-custom-profile": {
type: "oauth",
provider: "openai-codex",
access: "shared-access-token",
refresh: "shared-refresh-token",
expires: Date.now() + 3600000,
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeUndefined();
expect(store.profiles["openai-codex:my-custom-profile"]).toBeDefined();
},
{ prefix: "clawdbot-home-" },
);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
it("creates codex-cli profile when credentials differ from existing openai-codex profiles", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-codex-dedup-create-"));
try {
await withTempHome(
async (tempHome) => {
const codexDir = path.join(tempHome, ".codex");
fs.mkdirSync(codexDir, { recursive: true });
const codexAuthPath = path.join(codexDir, "auth.json");
fs.writeFileSync(
codexAuthPath,
JSON.stringify({
tokens: {
access_token: "unique-access-token",
refresh_token: "unique-refresh-token",
},
}),
);
fs.utimesSync(codexAuthPath, new Date(), new Date());
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
"openai-codex:my-custom-profile": {
type: "oauth",
provider: "openai-codex",
access: "different-access-token",
refresh: "different-refresh-token",
expires: Date.now() + 3600000,
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeDefined();
expect((store.profiles[CODEX_CLI_PROFILE_ID] as { access: string }).access).toBe(
"unique-access-token",
);
expect(store.profiles["openai-codex:my-custom-profile"]).toBeDefined();
},
{ prefix: "clawdbot-home-" },
);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
it("removes codex-cli profile when it duplicates another openai-codex profile", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-codex-dedup-remove-"));
try {
await withTempHome(
async (tempHome) => {
const codexDir = path.join(tempHome, ".codex");
fs.mkdirSync(codexDir, { recursive: true });
const codexAuthPath = path.join(codexDir, "auth.json");
fs.writeFileSync(
codexAuthPath,
JSON.stringify({
tokens: {
access_token: "shared-access-token",
refresh_token: "shared-refresh-token",
},
}),
);
fs.utimesSync(codexAuthPath, new Date(), new Date());
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
[CODEX_CLI_PROFILE_ID]: {
type: "oauth",
provider: "openai-codex",
access: "shared-access-token",
refresh: "shared-refresh-token",
expires: Date.now() + 3600000,
},
"openai-codex:my-custom-profile": {
type: "oauth",
provider: "openai-codex",
access: "shared-access-token",
refresh: "shared-refresh-token",
expires: Date.now() + 3600000,
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeUndefined();
const saved = JSON.parse(fs.readFileSync(authPath, "utf8")) as {
profiles?: Record<string, unknown>;
};
expect(saved.profiles?.[CODEX_CLI_PROFILE_ID]).toBeUndefined();
expect(saved.profiles?.["openai-codex:my-custom-profile"]).toBeDefined();
},
{ prefix: "clawdbot-home-" },
);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
});
+60 -16
View File
@@ -58,6 +58,26 @@ function isExternalProfileFresh(cred: AuthProfileCredential | undefined, now: nu
return cred.expires > now + EXTERNAL_CLI_NEAR_EXPIRY_MS; return cred.expires > now + EXTERNAL_CLI_NEAR_EXPIRY_MS;
} }
/**
* Find any existing openai-codex profile (other than codex-cli) that has the same
* access and refresh tokens. This prevents creating a duplicate codex-cli profile
* when the user has already set up a custom profile with the same credentials.
*/
export function findDuplicateCodexProfile(
store: AuthProfileStore,
creds: OAuthCredential,
): string | undefined {
for (const [profileId, profile] of Object.entries(store.profiles)) {
if (profileId === CODEX_CLI_PROFILE_ID) continue;
if (profile.type !== "oauth") continue;
if (profile.provider !== "openai-codex") continue;
if (profile.access === creds.access && profile.refresh === creds.refresh) {
return profileId;
}
}
return undefined;
}
/** /**
* Sync OAuth credentials from external CLI tools (Claude Code CLI, Codex CLI) into the store. * Sync OAuth credentials from external CLI tools (Claude Code CLI, Codex CLI) into the store.
* This allows clawdbot to use the same credentials as these tools without requiring * This allows clawdbot to use the same credentials as these tools without requiring
@@ -143,31 +163,55 @@ export function syncExternalCliCredentials(
// Sync from Codex CLI // Sync from Codex CLI
const existingCodex = store.profiles[CODEX_CLI_PROFILE_ID]; const existingCodex = store.profiles[CODEX_CLI_PROFILE_ID];
const existingCodexOAuth = existingCodex?.type === "oauth" ? existingCodex : undefined;
const duplicateExistingId = existingCodexOAuth
? findDuplicateCodexProfile(store, existingCodexOAuth)
: undefined;
if (duplicateExistingId) {
delete store.profiles[CODEX_CLI_PROFILE_ID];
mutated = true;
log.info("removed codex-cli profile: credentials already exist in another profile", {
existingProfileId: duplicateExistingId,
removedProfileId: CODEX_CLI_PROFILE_ID,
});
}
const shouldSyncCodex = const shouldSyncCodex =
!existingCodex || !existingCodex ||
existingCodex.provider !== "openai-codex" || existingCodex.provider !== "openai-codex" ||
!isExternalProfileFresh(existingCodex, now); !isExternalProfileFresh(existingCodex, now);
const codexCreds = shouldSyncCodex const codexCreds = shouldSyncCodex || duplicateExistingId
? readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }) ? readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS })
: null; : null;
if (codexCreds) { if (codexCreds) {
const existing = store.profiles[CODEX_CLI_PROFILE_ID]; const duplicateProfileId = findDuplicateCodexProfile(store, codexCreds);
const existingOAuth = existing?.type === "oauth" ? existing : undefined; if (duplicateProfileId) {
if (store.profiles[CODEX_CLI_PROFILE_ID]) {
delete store.profiles[CODEX_CLI_PROFILE_ID];
mutated = true;
log.info("removed codex-cli profile: credentials already exist in another profile", {
existingProfileId: duplicateProfileId,
removedProfileId: CODEX_CLI_PROFILE_ID,
});
}
} else {
const existing = store.profiles[CODEX_CLI_PROFILE_ID];
const existingOAuth = existing?.type === "oauth" ? existing : undefined;
// Codex creds don't carry expiry; use file mtime heuristic for freshness. // Codex creds don't carry expiry; use file mtime heuristic for freshness.
const shouldUpdate = const shouldUpdate =
!existingOAuth || !existingOAuth ||
existingOAuth.provider !== "openai-codex" || existingOAuth.provider !== "openai-codex" ||
existingOAuth.expires <= now || existingOAuth.expires <= now ||
codexCreds.expires > existingOAuth.expires; codexCreds.expires > existingOAuth.expires;
if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, codexCreds)) { if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, codexCreds)) {
store.profiles[CODEX_CLI_PROFILE_ID] = codexCreds; store.profiles[CODEX_CLI_PROFILE_ID] = codexCreds;
mutated = true; mutated = true;
log.info("synced openai-codex credentials from codex cli", { log.info("synced openai-codex credentials from codex cli", {
profileId: CODEX_CLI_PROFILE_ID, profileId: CODEX_CLI_PROFILE_ID,
expires: new Date(codexCreds.expires).toISOString(), expires: new Date(codexCreds.expires).toISOString(),
}); });
}
} }
} }
+19 -3
View File
@@ -3,8 +3,13 @@ import type { OAuthCredentials } from "@mariozechner/pi-ai";
import lockfile from "proper-lockfile"; import lockfile from "proper-lockfile";
import { resolveOAuthPath } from "../../config/paths.js"; import { resolveOAuthPath } from "../../config/paths.js";
import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js"; import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js";
import { AUTH_STORE_LOCK_OPTIONS, AUTH_STORE_VERSION, log } from "./constants.js"; import {
import { syncExternalCliCredentials } from "./external-cli-sync.js"; AUTH_STORE_LOCK_OPTIONS,
AUTH_STORE_VERSION,
CODEX_CLI_PROFILE_ID,
log,
} from "./constants.js";
import { findDuplicateCodexProfile, syncExternalCliCredentials } from "./external-cli-sync.js";
import { ensureAuthStoreFile, resolveAuthStorePath, resolveLegacyAuthStorePath } from "./paths.js"; import { ensureAuthStoreFile, resolveAuthStorePath, resolveLegacyAuthStorePath } from "./paths.js";
import type { AuthProfileCredential, AuthProfileStore, ProfileUsageStats } from "./types.js"; import type { AuthProfileCredential, AuthProfileStore, ProfileUsageStats } from "./types.js";
@@ -330,7 +335,18 @@ export function ensureAuthProfileStore(
} }
const mainStore = loadAuthProfileStoreForAgent(undefined, options); const mainStore = loadAuthProfileStoreForAgent(undefined, options);
return mergeAuthProfileStores(mainStore, store); const merged = mergeAuthProfileStores(mainStore, store);
// Keep per-agent view clean even if the main store has codex-cli.
const codexProfile = merged.profiles[CODEX_CLI_PROFILE_ID];
if (codexProfile?.type === "oauth") {
const duplicateId = findDuplicateCodexProfile(merged, codexProfile);
if (duplicateId) {
delete merged.profiles[CODEX_CLI_PROFILE_ID];
}
}
return merged;
} }
export function saveAuthProfileStore(store: AuthProfileStore, agentDir?: string): void { export function saveAuthProfileStore(store: AuthProfileStore, agentDir?: string): void {
+4 -2
View File
@@ -1,2 +1,4 @@
export const AGENT_LANE_NESTED = "nested" as const; import { CommandLane } from "../process/lanes.js";
export const AGENT_LANE_SUBAGENT = "subagent" as const;
export const AGENT_LANE_NESTED = CommandLane.Nested;
export const AGENT_LANE_SUBAGENT = CommandLane.Subagent;
@@ -19,6 +19,15 @@ describe("isAuthErrorMessage", () => {
expect(isAuthErrorMessage(sample)).toBe(true); expect(isAuthErrorMessage(sample)).toBe(true);
} }
}); });
it("matches OAuth refresh failures", () => {
const samples = [
"OAuth token refresh failed for anthropic: Failed to refresh OAuth token for anthropic. Please try again or re-authenticate.",
"Please re-authenticate to continue.",
];
for (const sample of samples) {
expect(isAuthErrorMessage(sample)).toBe(true);
}
});
it("ignores unrelated errors", () => { it("ignores unrelated errors", () => {
expect(isAuthErrorMessage("rate limit exceeded")).toBe(false); expect(isAuthErrorMessage("rate limit exceeded")).toBe(false);
expect(isAuthErrorMessage("billing issue detected")).toBe(false); expect(isAuthErrorMessage("billing issue detected")).toBe(false);
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { isLikelyContextOverflowError } from "./pi-embedded-helpers.js";
import { DEFAULT_AGENTS_FILENAME } from "./workspace.js";
const _makeFile = (overrides: Partial<WorkspaceBootstrapFile>): WorkspaceBootstrapFile => ({
name: DEFAULT_AGENTS_FILENAME,
path: "/tmp/AGENTS.md",
content: "",
missing: false,
...overrides,
});
describe("isLikelyContextOverflowError", () => {
it("matches context overflow hints", () => {
const samples = [
"Model context window is 128k tokens, you requested 256k tokens",
"Context window exceeded: requested 12000 tokens",
"Prompt too large for this model",
];
for (const sample of samples) {
expect(isLikelyContextOverflowError(sample)).toBe(true);
}
});
it("excludes context window too small errors", () => {
const samples = [
"Model context window too small (minimum is 128k tokens)",
"Context window too small: minimum is 1000 tokens",
];
for (const sample of samples) {
expect(isLikelyContextOverflowError(sample)).toBe(false);
}
});
});
+1
View File
@@ -19,6 +19,7 @@ export {
isCloudCodeAssistFormatError, isCloudCodeAssistFormatError,
isCompactionFailureError, isCompactionFailureError,
isContextOverflowError, isContextOverflowError,
isLikelyContextOverflowError,
isFailoverAssistantError, isFailoverAssistantError,
isFailoverErrorMessage, isFailoverErrorMessage,
isImageDimensionErrorMessage, isImageDimensionErrorMessage,
+13
View File
@@ -18,6 +18,17 @@ export function isContextOverflowError(errorMessage?: string): boolean {
); );
} }
const CONTEXT_WINDOW_TOO_SMALL_RE = /context window.*(too small|minimum is)/i;
const CONTEXT_OVERFLOW_HINT_RE =
/context.*overflow|context window.*(too (?:large|long)|exceed|over|limit|max(?:imum)?|requested|sent|tokens)|(?:prompt|request|input).*(too (?:large|long)|exceed|over|limit|max(?:imum)?)/i;
export function isLikelyContextOverflowError(errorMessage?: string): boolean {
if (!errorMessage) return false;
if (CONTEXT_WINDOW_TOO_SMALL_RE.test(errorMessage)) return false;
if (isContextOverflowError(errorMessage)) return true;
return CONTEXT_OVERFLOW_HINT_RE.test(errorMessage);
}
export function isCompactionFailureError(errorMessage?: string): boolean { export function isCompactionFailureError(errorMessage?: string): boolean {
if (!errorMessage) return false; if (!errorMessage) return false;
if (!isContextOverflowError(errorMessage)) return false; if (!isContextOverflowError(errorMessage)) return false;
@@ -328,6 +339,8 @@ const ERROR_PATTERNS = {
"incorrect api key", "incorrect api key",
"invalid token", "invalid token",
"authentication", "authentication",
"re-authenticate",
"oauth token refresh failed",
"unauthorized", "unauthorized",
"forbidden", "forbidden",
"access denied", "access denied",
@@ -150,6 +150,7 @@ describe("getDmHistoryLimitFromSessionKey", () => {
"signal", "signal",
"imessage", "imessage",
"msteams", "msteams",
"nextcloud-talk",
] as const; ] as const;
for (const provider of providers) { for (const provider of providers) {
@@ -168,6 +169,7 @@ describe("getDmHistoryLimitFromSessionKey", () => {
"signal", "signal",
"imessage", "imessage",
"msteams", "msteams",
"nextcloud-talk",
] as const; ] as const;
for (const provider of providers) { for (const provider of providers) {
+12 -18
View File
@@ -62,22 +62,16 @@ export function getDmHistoryLimitFromSessionKey(
return providerConfig.dmHistoryLimit; return providerConfig.dmHistoryLimit;
}; };
switch (provider) { const resolveProviderConfig = (
case "telegram": cfg: ClawdbotConfig | undefined,
return getLimit(config.channels?.telegram); providerId: string,
case "whatsapp": ): { dmHistoryLimit?: number; dms?: Record<string, { historyLimit?: number }> } | undefined => {
return getLimit(config.channels?.whatsapp); const channels = cfg?.channels;
case "discord": if (!channels || typeof channels !== "object") return undefined;
return getLimit(config.channels?.discord); const entry = (channels as Record<string, unknown>)[providerId];
case "slack": if (!entry || typeof entry !== "object" || Array.isArray(entry)) return undefined;
return getLimit(config.channels?.slack); return entry as { dmHistoryLimit?: number; dms?: Record<string, { historyLimit?: number }> };
case "signal": };
return getLimit(config.channels?.signal);
case "imessage": return getLimit(resolveProviderConfig(config, provider));
return getLimit(config.channels?.imessage);
case "msteams":
return getLimit(config.channels?.msteams);
default:
return undefined;
}
} }
+4 -2
View File
@@ -1,11 +1,13 @@
import { CommandLane } from "../../process/lanes.js";
export function resolveSessionLane(key: string) { export function resolveSessionLane(key: string) {
const cleaned = key.trim() || "main"; const cleaned = key.trim() || CommandLane.Main;
return cleaned.startsWith("session:") ? cleaned : `session:${cleaned}`; return cleaned.startsWith("session:") ? cleaned : `session:${cleaned}`;
} }
export function resolveGlobalLane(lane?: string) { export function resolveGlobalLane(lane?: string) {
const cleaned = lane?.trim(); const cleaned = lane?.trim();
return cleaned ? cleaned : "main"; return cleaned ? cleaned : CommandLane.Main;
} }
export function resolveEmbeddedSessionLane(key: string) { export function resolveEmbeddedSessionLane(key: string) {
@@ -24,6 +24,11 @@ export function handleAutoCompactionStart(ctx: EmbeddedPiSubscribeContext) {
ctx.state.compactionInFlight = true; ctx.state.compactionInFlight = true;
ctx.ensureCompactionPromise(); ctx.ensureCompactionPromise();
ctx.log.debug(`embedded run compaction start: runId=${ctx.params.runId}`); ctx.log.debug(`embedded run compaction start: runId=${ctx.params.runId}`);
emitAgentEvent({
runId: ctx.params.runId,
stream: "compaction",
data: { phase: "start" },
});
void ctx.params.onAgentEvent?.({ void ctx.params.onAgentEvent?.({
stream: "compaction", stream: "compaction",
data: { phase: "start" }, data: { phase: "start" },
@@ -43,6 +48,11 @@ export function handleAutoCompactionEnd(
} else { } else {
ctx.maybeResolveCompactionWait(); ctx.maybeResolveCompactionWait();
} }
emitAgentEvent({
runId: ctx.params.runId,
stream: "compaction",
data: { phase: "end", willRetry },
});
void ctx.params.onAgentEvent?.({ void ctx.params.onAgentEvent?.({
stream: "compaction", stream: "compaction",
data: { phase: "end", willRetry }, data: { phase: "end", willRetry },
@@ -109,34 +109,34 @@ export function handleMessageUpdate(
.trim(); .trim();
if (next && next !== ctx.state.lastStreamedAssistant) { if (next && next !== ctx.state.lastStreamedAssistant) {
const previousText = ctx.state.lastStreamedAssistant ?? ""; const previousText = ctx.state.lastStreamedAssistant ?? "";
ctx.state.lastStreamedAssistant = next;
const { text: cleanedText, mediaUrls } = parseReplyDirectives(next); const { text: cleanedText, mediaUrls } = parseReplyDirectives(next);
const { text: previousCleanedText } = parseReplyDirectives(previousText); const { text: previousCleanedText } = parseReplyDirectives(previousText);
const deltaText = cleanedText.startsWith(previousCleanedText) if (cleanedText.startsWith(previousCleanedText)) {
? cleanedText.slice(previousCleanedText.length) const deltaText = cleanedText.slice(previousCleanedText.length);
: cleanedText; ctx.state.lastStreamedAssistant = next;
emitAgentEvent({ emitAgentEvent({
runId: ctx.params.runId, runId: ctx.params.runId,
stream: "assistant", stream: "assistant",
data: { data: {
text: cleanedText, text: cleanedText,
delta: deltaText, delta: deltaText,
mediaUrls: mediaUrls?.length ? mediaUrls : undefined, mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
}, },
});
void ctx.params.onAgentEvent?.({
stream: "assistant",
data: {
text: cleanedText,
delta: deltaText,
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
},
});
if (ctx.params.onPartialReply && ctx.state.shouldEmitPartialReplies) {
void ctx.params.onPartialReply({
text: cleanedText,
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
}); });
void ctx.params.onAgentEvent?.({
stream: "assistant",
data: {
text: cleanedText,
delta: deltaText,
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
},
});
if (ctx.params.onPartialReply && ctx.state.shouldEmitPartialReplies) {
void ctx.params.onPartialReply({
text: cleanedText,
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
});
}
} }
} }
@@ -184,4 +184,72 @@ describe("subscribeEmbeddedPiSession", () => {
expect(payloads[1]?.text).toBe("Hello world"); expect(payloads[1]?.text).toBe("Hello world");
expect(payloads[1]?.delta).toBe(" world"); expect(payloads[1]?.delta).toBe(" world");
}); });
it("skips agent events when cleaned text rewinds mid-stream", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onAgentEvent = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
runId: "run",
onAgentEvent,
});
handler?.({ type: "message_start", message: { role: "assistant" } });
handler?.({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: { type: "text_delta", delta: "MEDIA:" },
});
handler?.({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: { type: "text_delta", delta: " https://example.com/a.png\nCaption" },
});
const payloads = onAgentEvent.mock.calls
.map((call) => call[0]?.data as Record<string, unknown> | undefined)
.filter((value): value is Record<string, unknown> => Boolean(value));
expect(payloads).toHaveLength(1);
expect(payloads[0]?.text).toBe("MEDIA:");
});
it("emits agent events when media arrives without text", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onAgentEvent = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
runId: "run",
onAgentEvent,
});
handler?.({ type: "message_start", message: { role: "assistant" } });
handler?.({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: { type: "text_delta", delta: "MEDIA: https://example.com/a.png" },
});
const payloads = onAgentEvent.mock.calls
.map((call) => call[0]?.data as Record<string, unknown> | undefined)
.filter((value): value is Record<string, unknown> => Boolean(value));
expect(payloads).toHaveLength(1);
expect(payloads[0]?.text).toBe("");
expect(payloads[0]?.mediaUrls).toEqual(["https://example.com/a.png"]);
});
}); });
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { onAgentEvent } from "../infra/agent-events.js";
import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js";
type StubSession = { type StubSession = {
@@ -54,6 +55,44 @@ describe("subscribeEmbeddedPiSession", () => {
await waitPromise; await waitPromise;
expect(resolved).toBe(true); expect(resolved).toBe(true);
}); });
it("emits compaction events on the agent event bus", async () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const events: Array<{ phase: string; willRetry?: boolean }> = [];
const stop = onAgentEvent((evt) => {
if (evt.runId !== "run-compaction") return;
if (evt.stream !== "compaction") return;
const phase = typeof evt.data?.phase === "string" ? evt.data.phase : "";
events.push({
phase,
willRetry: typeof evt.data?.willRetry === "boolean" ? evt.data.willRetry : undefined,
});
});
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
runId: "run-compaction",
});
handler?.({ type: "auto_compaction_start" });
handler?.({ type: "auto_compaction_end", willRetry: true });
handler?.({ type: "auto_compaction_end", willRetry: false });
stop();
expect(events).toEqual([
{ phase: "start" },
{ phase: "end", willRetry: true },
{ phase: "end", willRetry: false },
]);
});
it("emits tool summaries at tool start when verbose is on", async () => { it("emits tool summaries at tool start when verbose is on", async () => {
let handler: ((evt: unknown) => void) | undefined; let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = { const session: StubSession = {
+2 -1
View File
@@ -48,6 +48,7 @@ export const DEFAULT_SANDBOX_BROWSER_AUTOSTART_TIMEOUT_MS = 12_000;
export const SANDBOX_AGENT_WORKSPACE_MOUNT = "/agent"; export const SANDBOX_AGENT_WORKSPACE_MOUNT = "/agent";
export const SANDBOX_STATE_DIR = path.join(STATE_DIR_CLAWDBOT, "sandbox"); const resolvedSandboxStateDir = STATE_DIR_CLAWDBOT ?? path.join(os.homedir(), ".clawdbot");
export const SANDBOX_STATE_DIR = path.join(resolvedSandboxStateDir, "sandbox");
export const SANDBOX_REGISTRY_PATH = path.join(SANDBOX_STATE_DIR, "containers.json"); export const SANDBOX_REGISTRY_PATH = path.join(SANDBOX_STATE_DIR, "containers.json");
export const SANDBOX_BROWSER_REGISTRY_PATH = path.join(SANDBOX_STATE_DIR, "browsers.json"); export const SANDBOX_BROWSER_REGISTRY_PATH = path.join(SANDBOX_STATE_DIR, "browsers.json");
@@ -8,31 +8,31 @@ import { describe, expect, it, afterEach } from "vitest";
import { loadClawdbotPlugins } from "../plugins/loader.js"; import { loadClawdbotPlugins } from "../plugins/loader.js";
import { resetGlobalHookRunner } from "../plugins/hook-runner-global.js"; import { resetGlobalHookRunner } from "../plugins/hook-runner-global.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import { guardSessionManager } from "./session-tool-result-guard-wrapper.js"; import { guardSessionManager } from "./session-tool-result-guard-wrapper.js";
const EMPTY_CONFIG_SCHEMA = `configSchema: { const EMPTY_PLUGIN_SCHEMA = { type: "object", additionalProperties: false, properties: {} };
validate: () => ({ ok: true }),
jsonSchema: { type: "object", additionalProperties: true },
uiHints: {}
}`;
function writeTempPlugin(params: { dir: string; id: string; body: string }): string { function writeTempPlugin(params: { dir: string; id: string; body: string }): string {
const file = path.join(params.dir, `${params.id}.mjs`); const pluginDir = path.join(params.dir, params.id);
fs.mkdirSync(pluginDir, { recursive: true });
const file = path.join(pluginDir, `${params.id}.mjs`);
fs.writeFileSync(file, params.body, "utf-8"); fs.writeFileSync(file, params.body, "utf-8");
fs.writeFileSync(
path.join(pluginDir, "clawdbot.plugin.json"),
JSON.stringify(
{
id: params.id,
configSchema: EMPTY_PLUGIN_SCHEMA,
},
null,
2,
),
"utf-8",
);
return file; return file;
} }
const ORIGINAL_BUNDLED_PLUGINS_DIR = process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR;
afterEach(() => { afterEach(() => {
if (ORIGINAL_BUNDLED_PLUGINS_DIR === undefined) {
delete process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR;
} else {
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = ORIGINAL_BUNDLED_PLUGINS_DIR;
}
setActivePluginRegistry(createTestRegistry([]));
resetGlobalHookRunner(); resetGlobalHookRunner();
}); });
@@ -73,7 +73,7 @@ describe("tool_result_persist hook", () => {
const pluginA = writeTempPlugin({ const pluginA = writeTempPlugin({
dir: tmp, dir: tmp,
id: "persist-a", id: "persist-a",
body: `export default { id: "persist-a", ${EMPTY_CONFIG_SCHEMA}, register(api) { body: `export default { id: "persist-a", register(api) {
api.on("tool_result_persist", (event, ctx) => { api.on("tool_result_persist", (event, ctx) => {
const msg = event.message; const msg = event.message;
// Example: remove large diagnostic payloads before persistence. // Example: remove large diagnostic payloads before persistence.
@@ -86,7 +86,7 @@ describe("tool_result_persist hook", () => {
const pluginB = writeTempPlugin({ const pluginB = writeTempPlugin({
dir: tmp, dir: tmp,
id: "persist-b", id: "persist-b",
body: `export default { id: "persist-b", ${EMPTY_CONFIG_SCHEMA}, register(api) { body: `export default { id: "persist-b", register(api) {
api.on("tool_result_persist", (event) => { api.on("tool_result_persist", (event) => {
const prior = (event.message && event.message.persistOrder) ? event.message.persistOrder : []; const prior = (event.message && event.message.persistOrder) ? event.message.persistOrder : [];
return { message: { ...event.message, persistOrder: [...prior, "b"] } }; return { message: { ...event.message, persistOrder: [...prior, "b"] } };
+82
View File
@@ -0,0 +1,82 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { getShellConfig } from "./shell-utils.js";
const isWin = process.platform === "win32";
describe("getShellConfig", () => {
const originalShell = process.env.SHELL;
const originalPath = process.env.PATH;
const tempDirs: string[] = [];
const createTempBin = (files: string[]) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-shell-"));
tempDirs.push(dir);
for (const name of files) {
const filePath = path.join(dir, name);
fs.writeFileSync(filePath, "");
fs.chmodSync(filePath, 0o755);
}
return dir;
};
beforeEach(() => {
if (!isWin) {
process.env.SHELL = "/usr/bin/fish";
}
});
afterEach(() => {
if (originalShell == null) {
delete process.env.SHELL;
} else {
process.env.SHELL = originalShell;
}
if (originalPath == null) {
delete process.env.PATH;
} else {
process.env.PATH = originalPath;
}
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
if (isWin) {
it("uses PowerShell on Windows", () => {
const { shell } = getShellConfig();
expect(shell.toLowerCase()).toContain("powershell");
});
return;
}
it("prefers bash when fish is default and bash is on PATH", () => {
const binDir = createTempBin(["bash"]);
process.env.PATH = binDir;
const { shell } = getShellConfig();
expect(shell).toBe(path.join(binDir, "bash"));
});
it("falls back to sh when fish is default and bash is missing", () => {
const binDir = createTempBin(["sh"]);
process.env.PATH = binDir;
const { shell } = getShellConfig();
expect(shell).toBe(path.join(binDir, "sh"));
});
it("falls back to env shell when fish is default and no sh is available", () => {
process.env.PATH = "";
const { shell } = getShellConfig();
expect(shell).toBe("/usr/bin/fish");
});
it("uses sh when SHELL is unset", () => {
delete process.env.SHELL;
process.env.PATH = "";
const { shell } = getShellConfig();
expect(shell).toBe("sh");
});
});
+26 -1
View File
@@ -30,10 +30,35 @@ export function getShellConfig(): { shell: string; args: string[] } {
}; };
} }
const shell = process.env.SHELL?.trim() || "sh"; const envShell = process.env.SHELL?.trim();
const shellName = envShell ? path.basename(envShell) : "";
// Fish rejects common bashisms used by tools, so prefer bash when detected.
if (shellName === "fish") {
const bash = resolveShellFromPath("bash");
if (bash) return { shell: bash, args: ["-c"] };
const sh = resolveShellFromPath("sh");
if (sh) return { shell: sh, args: ["-c"] };
}
const shell = envShell && envShell.length > 0 ? envShell : "sh";
return { shell, args: ["-c"] }; return { shell, args: ["-c"] };
} }
function resolveShellFromPath(name: string): string | undefined {
const envPath = process.env.PATH ?? "";
if (!envPath) return undefined;
const entries = envPath.split(path.delimiter).filter(Boolean);
for (const entry of entries) {
const candidate = path.join(entry, name);
try {
fs.accessSync(candidate, fs.constants.X_OK);
return candidate;
} catch {
// ignore missing or non-executable entries
}
}
return undefined;
}
export function sanitizeBinaryOutput(text: string): string { export function sanitizeBinaryOutput(text: string): string {
const scrubbed = text.replace(/[\p{Format}\p{Surrogate}]/gu, ""); const scrubbed = text.replace(/[\p{Format}\p{Surrogate}]/gu, "");
if (!scrubbed) return scrubbed; if (!scrubbed) return scrubbed;
@@ -9,6 +9,7 @@ import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
import { import {
isCompactionFailureError, isCompactionFailureError,
isContextOverflowError, isContextOverflowError,
isLikelyContextOverflowError,
sanitizeUserFacingText, sanitizeUserFacingText,
} from "../../agents/pi-embedded-helpers.js"; } from "../../agents/pi-embedded-helpers.js";
import { import {
@@ -419,9 +420,7 @@ export async function runAgentTurnWithFallback(params: {
break; break;
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
const isContextOverflow = const isContextOverflow = isLikelyContextOverflowError(message);
isContextOverflowError(message) ||
/context.*overflow|too large|context window/i.test(message);
const isCompactionFailure = isCompactionFailureError(message); const isCompactionFailure = isCompactionFailureError(message);
const isSessionCorruption = /function call turn comes immediately after/i.test(message); const isSessionCorruption = /function call turn comes immediately after/i.test(message);
const isRoleOrderingError = /incorrect role information|roles must alternate/i.test(message); const isRoleOrderingError = /incorrect role information|roles must alternate/i.test(message);
+3 -3
View File
@@ -1,6 +1,6 @@
import { import {
readConfigFileSnapshot, readConfigFileSnapshot,
validateConfigObject, validateConfigObjectWithPlugins,
writeConfigFile, writeConfigFile,
} from "../../config/config.js"; } from "../../config/config.js";
import { import {
@@ -120,7 +120,7 @@ export const handleConfigCommand: CommandHandler = async (params, allowTextComma
reply: { text: `⚙️ No config value found for ${configCommand.path}.` }, reply: { text: `⚙️ No config value found for ${configCommand.path}.` },
}; };
} }
const validated = validateConfigObject(parsedBase); const validated = validateConfigObjectWithPlugins(parsedBase);
if (!validated.ok) { if (!validated.ok) {
const issue = validated.issues[0]; const issue = validated.issues[0];
return { return {
@@ -146,7 +146,7 @@ export const handleConfigCommand: CommandHandler = async (params, allowTextComma
}; };
} }
setConfigValueAtPath(parsedBase, parsedPath.path, configCommand.value); setConfigValueAtPath(parsedBase, parsedPath.path, configCommand.value);
const validated = validateConfigObject(parsedBase); const validated = validateConfigObjectWithPlugins(parsedBase);
if (!validated.ok) { if (!validated.ok) {
const issue = validated.issues[0]; const issue = validated.issues[0];
return { return {
@@ -1,177 +0,0 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import type { SkillCommandSpec } from "../../agents/skills.js";
import type { AnyAgentTool } from "../../agents/pi-tools.types.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { MsgContext, TemplateContext } from "../templating.js";
import type { InlineDirectives } from "./directive-handling.js";
import { parseInlineDirectives } from "./directive-handling.js";
import type { TypingController } from "./typing.js";
import { handleInlineActions } from "./get-reply-inline-actions.js";
vi.mock("../../agents/clawdbot-tools.js", () => ({
createClawdbotTools: vi.fn(),
}));
import { createClawdbotTools } from "../../agents/clawdbot-tools.js";
const mockedCreateClawdbotTools = vi.mocked(createClawdbotTools);
const createTypingController = (): TypingController => ({
onReplyStart: vi.fn(),
startTypingLoop: vi.fn(),
startTypingOnText: vi.fn(),
refreshTypingTtl: vi.fn(),
isActive: vi.fn(() => false),
markRunComplete: vi.fn(),
markDispatchIdle: vi.fn(),
cleanup: vi.fn(),
});
const baseCommand = {
surface: "slack",
channel: "slack",
ownerList: [],
isAuthorizedSender: true,
rawBodyNormalized: "/dispatch",
commandBodyNormalized: "/dispatch",
senderId: "user-1",
};
const baseDirectives = parseInlineDirectives("/dispatch") as InlineDirectives;
function createParams(overrides: Partial<Parameters<typeof handleInlineActions>[0]> = {}) {
const cfg = (overrides.cfg ??
({
tools: {
allow: ["tool_allowed"],
},
} as ClawdbotConfig)) as ClawdbotConfig;
return {
ctx: {
Surface: "slack",
Provider: "slack",
AccountId: "default",
} satisfies MsgContext as MsgContext,
sessionCtx: {
Body: "",
BodyForAgent: "",
BodyStripped: "",
} satisfies TemplateContext as TemplateContext,
cfg,
agentId: "main",
agentDir: "/tmp",
sessionEntry: undefined,
previousSessionEntry: undefined,
sessionStore: undefined,
sessionKey: "main",
storePath: undefined,
sessionScope: "per-sender",
workspaceDir: "/tmp",
isGroup: false,
opts: undefined,
typing: createTypingController(),
allowTextCommands: true,
inlineStatusRequested: false,
command: baseCommand,
skillCommands: [],
directives: baseDirectives,
cleanedBody: "/dispatch",
elevatedEnabled: false,
elevatedAllowed: false,
elevatedFailures: [],
defaultActivation: () => "always",
resolvedThinkLevel: "off",
resolvedVerboseLevel: "off",
resolvedReasoningLevel: "off",
resolvedElevatedLevel: "off",
resolveDefaultThinkingLevel: async () => undefined,
provider: "openai",
model: "gpt-4o-mini",
contextTokens: 0,
directiveAck: undefined,
abortedLastRun: false,
skillFilter: undefined,
...overrides,
};
}
function createTool(name: string, execute: AnyAgentTool["execute"]): AnyAgentTool {
return {
name,
label: name,
description: name,
parameters: {},
execute,
} as AnyAgentTool;
}
describe("handleInlineActions tool-dispatch", () => {
beforeEach(() => {
mockedCreateClawdbotTools.mockReset();
});
it("returns media payloads from tool results", async () => {
const tool = createTool("tool_allowed", async () => ({
content: [{ type: "text", text: "Done\nMEDIA:/tmp/photo.jpg" }],
}));
mockedCreateClawdbotTools.mockReturnValue([tool]);
const skillCommands: SkillCommandSpec[] = [
{
name: "dispatch",
skillName: "dispatch",
description: "Dispatch",
dispatch: { kind: "tool", toolName: "tool_allowed", argMode: "raw" },
},
];
const result = await handleInlineActions(
createParams({
command: { ...baseCommand, commandBodyNormalized: "/dispatch hi" },
skillCommands,
cleanedBody: "/dispatch hi",
}),
);
expect(result.kind).toBe("reply");
const reply = (result as { reply?: unknown }).reply as { text?: string; mediaUrl?: string };
expect(reply.text).toBe("Done");
expect(reply.mediaUrl).toBe("file:///tmp/photo.jpg");
});
it("blocks tool dispatch when policy disallows the tool", async () => {
const allowed = createTool("tool_allowed", async () => ({ content: "ok" }));
const blocked = createTool("tool_blocked", async () => ({ content: "nope" }));
mockedCreateClawdbotTools.mockReturnValue([allowed, blocked]);
const cfg = {
tools: {
allow: ["tool_allowed"],
},
} as ClawdbotConfig;
const skillCommands: SkillCommandSpec[] = [
{
name: "dispatch",
skillName: "dispatch",
description: "Dispatch",
dispatch: { kind: "tool", toolName: "tool_blocked", argMode: "raw" },
},
];
const result = await handleInlineActions(
createParams({
cfg,
skillCommands,
command: { ...baseCommand, commandBodyNormalized: "/dispatch arg" },
cleanedBody: "/dispatch arg",
}),
);
expect(result.kind).toBe("reply");
const reply = (result as { reply?: { text?: string; isError?: boolean } }).reply;
expect(reply?.text).toContain("Tool blocked by policy");
expect(reply?.isError).toBe(true);
});
});
+17 -263
View File
@@ -1,47 +1,21 @@
import { pathToFileURL } from "node:url";
import { getChannelDock } from "../../channels/dock.js"; import { getChannelDock } from "../../channels/dock.js";
import type { SkillCommandSpec } from "../../agents/skills.js"; import type { SkillCommandSpec } from "../../agents/skills.js";
import type { AnyAgentTool } from "../../agents/pi-tools.types.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js"; import type { SessionEntry } from "../../config/sessions.js";
import type { MsgContext, TemplateContext } from "../templating.js"; import type { MsgContext, TemplateContext } from "../templating.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js"; import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js";
import {
resolveSubagentToolPolicy,
resolveEffectiveToolPolicy,
filterToolsByPolicy,
} from "../../agents/pi-tools.policy.js";
import {
buildPluginToolGroups,
collectExplicitAllowlist,
expandPolicyWithPluginGroups,
normalizeToolName,
resolveToolProfilePolicy,
} from "../../agents/tool-policy.js";
import {
resolveSandboxRuntimeStatus,
formatSandboxToolPolicyBlockedMessage,
} from "../../agents/sandbox/runtime-status.js";
import { isSubagentSessionKey } from "../../routing/session-key.js";
import { resolveUserPath } from "../../utils.js";
import { getAbortMemory } from "./abort.js"; import { getAbortMemory } from "./abort.js";
import { buildStatusReply, handleCommands } from "./commands.js"; import { buildStatusReply, handleCommands } from "./commands.js";
import type { InlineDirectives } from "./directive-handling.js"; import type { InlineDirectives } from "./directive-handling.js";
import { isDirectiveOnly } from "./directive-handling.js"; import { isDirectiveOnly } from "./directive-handling.js";
import type { createModelSelectionState } from "./model-selection.js"; import type { createModelSelectionState } from "./model-selection.js";
import { extractInlineSimpleCommand } from "./reply-inline.js"; import { extractInlineSimpleCommand } from "./reply-inline.js";
import { parseReplyDirectives } from "./reply-directives.js";
import type { TypingController } from "./typing.js"; import type { TypingController } from "./typing.js";
import { listSkillCommandsForWorkspace, resolveSkillCommandInvocation } from "../skill-commands.js"; import { listSkillCommandsForWorkspace, resolveSkillCommandInvocation } from "../skill-commands.js";
import { logVerbose } from "../../globals.js"; import { logVerbose } from "../../globals.js";
import { createClawdbotTools } from "../../agents/clawdbot-tools.js"; import { createClawdbotTools } from "../../agents/clawdbot-tools.js";
import { import { resolveGatewayMessageChannel } from "../../utils/message-channel.js";
resolveGatewayMessageChannel,
type GatewayMessageChannel,
} from "../../utils/message-channel.js";
import { getPluginToolMeta } from "../../plugins/tools.js";
export type InlineActionResult = export type InlineActionResult =
| { kind: "reply"; reply: ReplyPayload | ReplyPayload[] | undefined } | { kind: "reply"; reply: ReplyPayload | ReplyPayload[] | undefined }
@@ -51,20 +25,9 @@ export type InlineActionResult =
abortedLastRun: boolean; abortedLastRun: boolean;
}; };
function normalizeMediaUrlCandidate(raw: string): string | null { function extractTextFromToolResult(result: any): string | null {
const trimmed = raw.trim(); if (!result || typeof result !== "object") return null;
if (!trimmed) return null; const content = (result as { content?: unknown }).content;
if (/^https?:\/\//i.test(trimmed)) return trimmed;
if (trimmed.startsWith("file://")) return trimmed;
const resolved = trimmed.startsWith("~") ? resolveUserPath(trimmed) : trimmed;
if (resolved.startsWith("/")) {
return pathToFileURL(resolved).toString();
}
if (resolved.startsWith("./") || resolved.startsWith("../")) return resolved;
return trimmed;
}
function extractTextFromToolResultContent(content: unknown): string | null {
if (typeof content === "string") { if (typeof content === "string") {
const trimmed = content.trim(); const trimmed = content.trim();
return trimmed ? trimmed : null; return trimmed ? trimmed : null;
@@ -79,202 +42,9 @@ function extractTextFromToolResultContent(content: unknown): string | null {
parts.push(rec.text); parts.push(rec.text);
} }
} }
const out = parts.join("\n").trim(); const out = parts.join("");
return out ? out : null; const trimmed = out.trim();
} return trimmed ? trimmed : null;
function extractMediaUrlsFromDetails(details: unknown): string[] {
if (!details || typeof details !== "object") return [];
const record = details as Record<string, unknown>;
const candidates: string[] = [];
const mediaUrls = record.mediaUrls;
if (Array.isArray(mediaUrls)) {
for (const entry of mediaUrls) {
if (typeof entry === "string") candidates.push(entry);
}
}
const mediaUrl = record.mediaUrl;
if (typeof mediaUrl === "string") candidates.push(mediaUrl);
const media = record.media;
if (typeof media === "string") candidates.push(media);
const path = record.path;
if (typeof path === "string") candidates.push(path);
return candidates
.map((entry) => normalizeMediaUrlCandidate(entry))
.filter((entry): entry is string => Boolean(entry));
}
function extractReplyPayloadFromToolResult(result: unknown): ReplyPayload | null {
if (!result || typeof result !== "object") return null;
const maybePayload = result as ReplyPayload & { content?: unknown; details?: unknown };
if (
typeof maybePayload.text === "string" ||
typeof maybePayload.mediaUrl === "string" ||
Array.isArray(maybePayload.mediaUrls)
) {
return {
text: maybePayload.text?.trim() ? maybePayload.text.trim() : undefined,
mediaUrl: maybePayload.mediaUrl,
mediaUrls: maybePayload.mediaUrls,
replyToId: maybePayload.replyToId,
replyToTag: maybePayload.replyToTag,
replyToCurrent: maybePayload.replyToCurrent,
audioAsVoice: maybePayload.audioAsVoice,
isError: maybePayload.isError,
};
}
const content = maybePayload.content;
const text = extractTextFromToolResultContent(content);
const parsed = text
? parseReplyDirectives(text)
: {
text: "",
mediaUrls: undefined,
mediaUrl: undefined,
replyToId: undefined,
replyToCurrent: false,
replyToTag: false,
audioAsVoice: undefined,
isSilent: false,
};
if (parsed.isSilent) return null;
const mediaFromText = parsed.mediaUrls ?? (parsed.mediaUrl ? [parsed.mediaUrl] : []);
const mediaFromDetails = extractMediaUrlsFromDetails(maybePayload.details);
const mediaUrls = Array.from(new Set([...mediaFromText, ...mediaFromDetails]))
.map((entry) => normalizeMediaUrlCandidate(entry))
.filter((entry): entry is string => Boolean(entry));
const cleanedText = parsed.text?.trim() ? parsed.text.trim() : undefined;
if (!cleanedText && mediaUrls.length === 0) return null;
return {
text: cleanedText,
mediaUrls: mediaUrls.length ? mediaUrls : undefined,
mediaUrl: mediaUrls[0],
replyToId: parsed.replyToId,
replyToTag: parsed.replyToTag,
replyToCurrent: parsed.replyToCurrent,
audioAsVoice: parsed.audioAsVoice,
isError: (result as { isError?: unknown }).isError === true,
};
}
function resolveToolDispatchTools(params: {
cfg: ClawdbotConfig;
sessionKey: string;
provider: string;
model: string;
agentDir?: string;
agentChannel?: GatewayMessageChannel;
agentAccountId?: string;
workspaceDir: string;
}): {
allTools: AnyAgentTool[];
allowedTools: AnyAgentTool[];
sandboxed: boolean;
} {
const sandboxRuntime = resolveSandboxRuntimeStatus({
cfg: params.cfg,
sessionKey: params.sessionKey,
});
const {
profile,
providerProfile,
globalPolicy,
globalProviderPolicy,
agentPolicy,
agentProviderPolicy,
} = resolveEffectiveToolPolicy({
config: params.cfg,
sessionKey: params.sessionKey,
modelProvider: params.provider,
modelId: params.model,
});
const profilePolicy = resolveToolProfilePolicy(profile);
const providerProfilePolicy = resolveToolProfilePolicy(providerProfile);
const sandboxPolicy = sandboxRuntime.sandboxed ? sandboxRuntime.toolPolicy : undefined;
const subagentPolicy =
isSubagentSessionKey(params.sessionKey) && params.sessionKey
? resolveSubagentToolPolicy(params.cfg)
: undefined;
const pluginToolAllowlist = collectExplicitAllowlist([
profilePolicy,
providerProfilePolicy,
globalPolicy,
globalProviderPolicy,
agentPolicy,
agentProviderPolicy,
sandboxPolicy,
subagentPolicy,
]);
const allTools = createClawdbotTools({
agentSessionKey: params.sessionKey,
agentChannel: params.agentChannel,
agentAccountId: params.agentAccountId,
agentDir: params.agentDir,
workspaceDir: params.workspaceDir,
config: params.cfg,
sandboxed: sandboxRuntime.sandboxed,
pluginToolAllowlist,
}) as AnyAgentTool[];
const pluginGroups = buildPluginToolGroups({
tools: allTools,
toolMeta: (tool) => getPluginToolMeta(tool),
});
const profilePolicyExpanded = expandPolicyWithPluginGroups(profilePolicy, pluginGroups);
const providerProfilePolicyExpanded = expandPolicyWithPluginGroups(
providerProfilePolicy,
pluginGroups,
);
const globalPolicyExpanded = expandPolicyWithPluginGroups(globalPolicy, pluginGroups);
const globalProviderPolicyExpanded = expandPolicyWithPluginGroups(
globalProviderPolicy,
pluginGroups,
);
const agentPolicyExpanded = expandPolicyWithPluginGroups(agentPolicy, pluginGroups);
const agentProviderPolicyExpanded = expandPolicyWithPluginGroups(
agentProviderPolicy,
pluginGroups,
);
const sandboxPolicyExpanded = expandPolicyWithPluginGroups(sandboxPolicy, pluginGroups);
const subagentPolicyExpanded = expandPolicyWithPluginGroups(subagentPolicy, pluginGroups);
const toolsFiltered = profilePolicyExpanded
? filterToolsByPolicy(allTools, profilePolicyExpanded)
: allTools;
const providerProfileFiltered = providerProfilePolicyExpanded
? filterToolsByPolicy(toolsFiltered, providerProfilePolicyExpanded)
: toolsFiltered;
const globalFiltered = globalPolicyExpanded
? filterToolsByPolicy(providerProfileFiltered, globalPolicyExpanded)
: providerProfileFiltered;
const globalProviderFiltered = globalProviderPolicyExpanded
? filterToolsByPolicy(globalFiltered, globalProviderPolicyExpanded)
: globalFiltered;
const agentFiltered = agentPolicyExpanded
? filterToolsByPolicy(globalProviderFiltered, agentPolicyExpanded)
: globalProviderFiltered;
const agentProviderFiltered = agentProviderPolicyExpanded
? filterToolsByPolicy(agentFiltered, agentProviderPolicyExpanded)
: agentFiltered;
const sandboxed = sandboxPolicyExpanded
? filterToolsByPolicy(agentProviderFiltered, sandboxPolicyExpanded)
: agentProviderFiltered;
const subagentFiltered = subagentPolicyExpanded
? filterToolsByPolicy(sandboxed, subagentPolicyExpanded)
: sandboxed;
return {
allTools,
allowedTools: subagentFiltered,
sandboxed: sandboxRuntime.sandboxed,
};
} }
export async function handleInlineActions(params: { export async function handleInlineActions(params: {
@@ -393,34 +163,18 @@ export async function handleInlineActions(params: {
resolveGatewayMessageChannel(ctx.Surface) ?? resolveGatewayMessageChannel(ctx.Surface) ??
resolveGatewayMessageChannel(ctx.Provider) ?? resolveGatewayMessageChannel(ctx.Provider) ??
undefined; undefined;
const { allTools, allowedTools } = resolveToolDispatchTools({
cfg, const tools = createClawdbotTools({
sessionKey, agentSessionKey: sessionKey,
provider,
model,
agentDir,
agentChannel: channel, agentChannel: channel,
agentAccountId: (ctx as { AccountId?: string }).AccountId, agentAccountId: (ctx as { AccountId?: string }).AccountId,
agentDir,
workspaceDir, workspaceDir,
config: cfg,
}); });
const requestedName = normalizeToolName(dispatch.toolName);
const findTool = (tools: AnyAgentTool[]) => const tool = tools.find((candidate) => candidate.name === dispatch.toolName);
tools.find((candidate) => normalizeToolName(candidate.name) === requestedName);
const tool = findTool(allowedTools);
if (!tool) { if (!tool) {
const allTool = findTool(allTools);
if (allTool) {
const sandboxReason = formatSandboxToolPolicyBlockedMessage({
cfg,
sessionKey,
toolName: requestedName,
});
const message = sandboxReason
? `❌ Tool blocked by policy: ${dispatch.toolName}\n${sandboxReason}`
: `❌ Tool blocked by policy: ${dispatch.toolName}`;
typing.cleanup();
return { kind: "reply", reply: { text: message, isError: true } };
}
typing.cleanup(); typing.cleanup();
return { kind: "reply", reply: { text: `❌ Tool not available: ${dispatch.toolName}` } }; return { kind: "reply", reply: { text: `❌ Tool not available: ${dispatch.toolName}` } };
} }
@@ -432,13 +186,13 @@ export async function handleInlineActions(params: {
commandName: skillInvocation.command.name, commandName: skillInvocation.command.name,
skillName: skillInvocation.command.skillName, skillName: skillInvocation.command.skillName,
} as any); } as any);
const reply = extractReplyPayloadFromToolResult(result) ?? { text: "✅ Done." }; const text = extractTextFromToolResult(result) ?? "✅ Done.";
typing.cleanup(); typing.cleanup();
return { kind: "reply", reply }; return { kind: "reply", reply: { text } };
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
typing.cleanup(); typing.cleanup();
return { kind: "reply", reply: { text: `❌ ${message}`, isError: true } }; return { kind: "reply", reply: { text: `❌ ${message}` } };
} }
} }
+129 -77
View File
@@ -1,3 +1,8 @@
import path from "node:path";
import { discoverClawdbotPlugins } from "../../plugins/discovery.js";
import type { PluginOrigin } from "../../plugins/types.js";
import type { ClawdbotPackageManifest } from "../../plugins/manifest.js";
import type { ChannelMeta } from "./types.js"; import type { ChannelMeta } from "./types.js";
export type ChannelPluginCatalogEntry = { export type ChannelPluginCatalogEntry = {
@@ -10,86 +15,133 @@ export type ChannelPluginCatalogEntry = {
}; };
}; };
const CATALOG: ChannelPluginCatalogEntry[] = [ type CatalogOptions = {
{ workspaceDir?: string;
id: "msteams", };
meta: {
id: "msteams",
label: "Microsoft Teams",
selectionLabel: "Microsoft Teams (Bot Framework)",
docsPath: "/channels/msteams",
docsLabel: "msteams",
blurb: "Bot Framework; enterprise support.",
aliases: ["teams"],
order: 60,
},
install: {
npmSpec: "@clawdbot/msteams",
localPath: "extensions/msteams",
defaultChoice: "npm",
},
},
{
id: "matrix",
meta: {
id: "matrix",
label: "Matrix",
selectionLabel: "Matrix (plugin)",
docsPath: "/channels/matrix",
docsLabel: "matrix",
blurb: "open protocol; install the plugin to enable.",
order: 70,
quickstartAllowFrom: true,
},
install: {
npmSpec: "@clawdbot/matrix",
localPath: "extensions/matrix",
defaultChoice: "npm",
},
},
{
id: "bluebubbles",
meta: {
id: "bluebubbles",
label: "BlueBubbles",
selectionLabel: "BlueBubbles (macOS app)",
docsPath: "/channels/bluebubbles",
docsLabel: "bluebubbles",
blurb: "iMessage via the BlueBubbles mac app + REST API.",
order: 75,
},
install: {
npmSpec: "@clawdbot/bluebubbles",
localPath: "extensions/bluebubbles",
defaultChoice: "npm",
},
},
{
id: "zalo",
meta: {
id: "zalo",
label: "Zalo",
selectionLabel: "Zalo (Bot API)",
docsPath: "/channels/zalo",
docsLabel: "zalo",
blurb: "Vietnam-focused messaging platform with Bot API.",
aliases: ["zl"],
order: 80,
quickstartAllowFrom: true,
},
install: {
npmSpec: "@clawdbot/zalo",
localPath: "extensions/zalo",
},
},
];
export function listChannelPluginCatalogEntries(): ChannelPluginCatalogEntry[] { const ORIGIN_PRIORITY: Record<PluginOrigin, number> = {
return [...CATALOG]; config: 0,
workspace: 1,
global: 2,
bundled: 3,
};
function toChannelMeta(params: {
channel: NonNullable<ClawdbotPackageManifest["channel"]>;
id: string;
}): ChannelMeta | null {
const label = params.channel.label?.trim();
if (!label) return null;
const selectionLabel = params.channel.selectionLabel?.trim() || label;
const docsPath = params.channel.docsPath?.trim() || `/channels/${params.id}`;
const blurb = params.channel.blurb?.trim() || "";
return {
id: params.id,
label,
selectionLabel,
docsPath,
docsLabel: params.channel.docsLabel?.trim() || undefined,
blurb,
...(params.channel.aliases ? { aliases: params.channel.aliases } : {}),
...(params.channel.order !== undefined ? { order: params.channel.order } : {}),
...(params.channel.selectionDocsPrefix
? { selectionDocsPrefix: params.channel.selectionDocsPrefix }
: {}),
...(params.channel.selectionDocsOmitLabel !== undefined
? { selectionDocsOmitLabel: params.channel.selectionDocsOmitLabel }
: {}),
...(params.channel.selectionExtras ? { selectionExtras: params.channel.selectionExtras } : {}),
...(params.channel.showConfigured !== undefined
? { showConfigured: params.channel.showConfigured }
: {}),
...(params.channel.quickstartAllowFrom !== undefined
? { quickstartAllowFrom: params.channel.quickstartAllowFrom }
: {}),
...(params.channel.forceAccountBinding !== undefined
? { forceAccountBinding: params.channel.forceAccountBinding }
: {}),
...(params.channel.preferSessionLookupForAnnounceTarget !== undefined
? {
preferSessionLookupForAnnounceTarget: params.channel.preferSessionLookupForAnnounceTarget,
}
: {}),
};
} }
export function getChannelPluginCatalogEntry(id: string): ChannelPluginCatalogEntry | undefined { function resolveInstallInfo(params: {
manifest: ClawdbotPackageManifest;
packageName?: string;
packageDir?: string;
workspaceDir?: string;
}): ChannelPluginCatalogEntry["install"] | null {
const npmSpec = params.manifest.install?.npmSpec?.trim() ?? params.packageName?.trim();
if (!npmSpec) return null;
let localPath = params.manifest.install?.localPath?.trim() || undefined;
if (!localPath && params.workspaceDir && params.packageDir) {
localPath = path.relative(params.workspaceDir, params.packageDir) || undefined;
}
const defaultChoice = params.manifest.install?.defaultChoice ?? (localPath ? "local" : "npm");
return {
npmSpec,
...(localPath ? { localPath } : {}),
...(defaultChoice ? { defaultChoice } : {}),
};
}
function buildCatalogEntry(candidate: {
packageName?: string;
packageDir?: string;
workspaceDir?: string;
packageClawdbot?: ClawdbotPackageManifest;
}): ChannelPluginCatalogEntry | null {
const manifest = candidate.packageClawdbot;
if (!manifest?.channel) return null;
const id = manifest.channel.id?.trim();
if (!id) return null;
const meta = toChannelMeta({ channel: manifest.channel, id });
if (!meta) return null;
const install = resolveInstallInfo({
manifest,
packageName: candidate.packageName,
packageDir: candidate.packageDir,
workspaceDir: candidate.workspaceDir,
});
if (!install) return null;
return { id, meta, install };
}
export function listChannelPluginCatalogEntries(
options: CatalogOptions = {},
): ChannelPluginCatalogEntry[] {
const discovery = discoverClawdbotPlugins({ workspaceDir: options.workspaceDir });
const resolved = new Map<string, { entry: ChannelPluginCatalogEntry; priority: number }>();
for (const candidate of discovery.candidates) {
const entry = buildCatalogEntry(candidate);
if (!entry) continue;
const priority = ORIGIN_PRIORITY[candidate.origin] ?? 99;
const existing = resolved.get(entry.id);
if (!existing || priority < existing.priority) {
resolved.set(entry.id, { entry, priority });
}
}
return Array.from(resolved.values())
.map(({ entry }) => entry)
.sort((a, b) => {
const orderA = a.meta.order ?? 999;
const orderB = b.meta.order ?? 999;
if (orderA !== orderB) return orderA - orderB;
return a.meta.label.localeCompare(b.meta.label);
});
}
export function getChannelPluginCatalogEntry(
id: string,
options: CatalogOptions = {},
): ChannelPluginCatalogEntry | undefined {
const trimmed = id.trim(); const trimmed = id.trim();
if (!trimmed) return undefined; if (!trimmed) return undefined;
return CATALOG.find((entry) => entry.id === trimmed); return listChannelPluginCatalogEntries(options).find((entry) => entry.id === trimmed);
} }
+228
View File
@@ -0,0 +1,228 @@
import type { Command } from "commander";
import { callGateway } from "../gateway/call.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { defaultRuntime } from "../runtime.js";
import { withProgress } from "./progress.js";
type DevicesRpcOpts = {
url?: string;
token?: string;
password?: string;
timeout?: string;
json?: boolean;
device?: string;
role?: string;
scope?: string[];
};
type DeviceTokenSummary = {
role: string;
scopes?: string[];
revokedAtMs?: number;
};
type PendingDevice = {
requestId: string;
deviceId: string;
displayName?: string;
role?: string;
remoteIp?: string;
isRepair?: boolean;
ts?: number;
};
type PairedDevice = {
deviceId: string;
displayName?: string;
roles?: string[];
scopes?: string[];
remoteIp?: string;
tokens?: DeviceTokenSummary[];
createdAtMs?: number;
approvedAtMs?: number;
};
type DevicePairingList = {
pending?: PendingDevice[];
paired?: PairedDevice[];
};
function formatAge(msAgo: number) {
const s = Math.max(0, Math.floor(msAgo / 1000));
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h`;
const d = Math.floor(h / 24);
return `${d}d`;
}
const devicesCallOpts = (cmd: Command, defaults?: { timeoutMs?: number }) =>
cmd
.option("--url <url>", "Gateway WebSocket URL (defaults to gateway.remote.url when configured)")
.option("--token <token>", "Gateway token (if required)")
.option("--password <password>", "Gateway password (password auth)")
.option("--timeout <ms>", "Timeout in ms", String(defaults?.timeoutMs ?? 10_000))
.option("--json", "Output JSON", false);
const callGatewayCli = async (method: string, opts: DevicesRpcOpts, params?: unknown) =>
withProgress(
{
label: `Devices ${method}`,
indeterminate: true,
enabled: opts.json !== true,
},
async () =>
await callGateway({
url: opts.url,
token: opts.token,
password: opts.password,
method,
params,
timeoutMs: Number(opts.timeout ?? 10_000),
clientName: GATEWAY_CLIENT_NAMES.CLI,
mode: GATEWAY_CLIENT_MODES.CLI,
}),
);
function parseDevicePairingList(value: unknown): DevicePairingList {
const obj = typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
return {
pending: Array.isArray(obj.pending) ? (obj.pending as PendingDevice[]) : [],
paired: Array.isArray(obj.paired) ? (obj.paired as PairedDevice[]) : [],
};
}
function formatTokenSummary(tokens: DeviceTokenSummary[] | undefined) {
if (!tokens || tokens.length === 0) return "tokens: none";
const parts = tokens
.map((t) => `${t.role}${t.revokedAtMs ? " (revoked)" : ""}`)
.sort((a, b) => a.localeCompare(b));
return `tokens: ${parts.join(", ")}`;
}
export function registerDevicesCli(program: Command) {
const devices = program.command("devices").description("Device pairing and auth tokens");
devicesCallOpts(
devices
.command("list")
.description("List pending and paired devices")
.action(async (opts: DevicesRpcOpts) => {
const result = await callGatewayCli("device.pair.list", opts, {});
const list = parseDevicePairingList(result);
if (opts.json) {
defaultRuntime.log(JSON.stringify(list, null, 2));
return;
}
if (list.pending?.length) {
defaultRuntime.log("Pending:");
for (const req of list.pending) {
const name = req.displayName || req.deviceId;
const repair = req.isRepair ? " (repair)" : "";
const ip = req.remoteIp ? ` · ${req.remoteIp}` : "";
const age =
typeof req.ts === "number" ? ` · ${formatAge(Date.now() - req.ts)} ago` : "";
const role = req.role ? ` · role: ${req.role}` : "";
defaultRuntime.log(`- ${req.requestId}: ${name}${repair}${role}${ip}${age}`);
}
}
if (list.paired?.length) {
defaultRuntime.log("Paired:");
for (const device of list.paired) {
const name = device.displayName || device.deviceId;
const roles = device.roles?.length ? `roles: ${device.roles.join(", ")}` : "roles: -";
const scopes = device.scopes?.length
? `scopes: ${device.scopes.join(", ")}`
: "scopes: -";
const ip = device.remoteIp ? ` · ${device.remoteIp}` : "";
const tokens = formatTokenSummary(device.tokens);
defaultRuntime.log(`- ${name} · ${roles} · ${scopes} · ${tokens}${ip}`);
}
}
if (!list.pending?.length && !list.paired?.length) {
defaultRuntime.log("No device pairing entries.");
}
}),
);
devicesCallOpts(
devices
.command("approve")
.description("Approve a pending device pairing request")
.argument("<requestId>", "Pending request id")
.action(async (requestId: string, opts: DevicesRpcOpts) => {
const result = await callGatewayCli("device.pair.approve", opts, { requestId });
if (opts.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
const deviceId = (result as { device?: { deviceId?: string } })?.device?.deviceId;
defaultRuntime.log(`device approved: ${deviceId ?? "ok"}`);
}),
);
devicesCallOpts(
devices
.command("reject")
.description("Reject a pending device pairing request")
.argument("<requestId>", "Pending request id")
.action(async (requestId: string, opts: DevicesRpcOpts) => {
const result = await callGatewayCli("device.pair.reject", opts, { requestId });
if (opts.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
const deviceId = (result as { deviceId?: string })?.deviceId;
defaultRuntime.log(`device rejected: ${deviceId ?? "ok"}`);
}),
);
devicesCallOpts(
devices
.command("rotate")
.description("Rotate a device token for a role")
.requiredOption("--device <id>", "Device id")
.requiredOption("--role <role>", "Role name")
.option("--scope <scope...>", "Scopes to attach to the token (repeatable)")
.action(async (opts: DevicesRpcOpts) => {
const deviceId = String(opts.device ?? "").trim();
const role = String(opts.role ?? "").trim();
if (!deviceId || !role) {
defaultRuntime.error("--device and --role required");
defaultRuntime.exit(1);
return;
}
const result = await callGatewayCli("device.token.rotate", opts, {
deviceId,
role,
scopes: Array.isArray(opts.scope) ? opts.scope : undefined,
});
defaultRuntime.log(JSON.stringify(result, null, 2));
}),
);
devicesCallOpts(
devices
.command("revoke")
.description("Revoke a device token for a role")
.requiredOption("--device <id>", "Device id")
.requiredOption("--role <role>", "Role name")
.action(async (opts: DevicesRpcOpts) => {
const deviceId = String(opts.device ?? "").trim();
const role = String(opts.role ?? "").trim();
if (!deviceId || !role) {
defaultRuntime.error("--device and --role required");
defaultRuntime.exit(1);
return;
}
const result = await callGatewayCli("device.token.revoke", opts, {
deviceId,
role,
});
defaultRuntime.log(JSON.stringify(result, null, 2));
}),
);
}
+8 -1
View File
@@ -64,7 +64,8 @@ export function renderBeaconLines(beacon: GatewayBonjourBeacon, rich: boolean):
const host = pickBeaconHost(beacon); const host = pickBeaconHost(beacon);
const gatewayPort = pickGatewayPort(beacon); const gatewayPort = pickGatewayPort(beacon);
const wsUrl = host ? `ws://${host}:${gatewayPort}` : null; const scheme = beacon.gatewayTls ? "wss" : "ws";
const wsUrl = host ? `${scheme}://${host}:${gatewayPort}` : null;
const lines = [`- ${title} ${domain}`]; const lines = [`- ${title} ${domain}`];
@@ -81,6 +82,12 @@ export function renderBeaconLines(beacon: GatewayBonjourBeacon, rich: boolean):
if (wsUrl) { if (wsUrl) {
lines.push(` ${colorize(rich, theme.muted, "ws")}: ${colorize(rich, theme.command, wsUrl)}`); lines.push(` ${colorize(rich, theme.muted, "ws")}: ${colorize(rich, theme.command, wsUrl)}`);
} }
if (beacon.gatewayTls) {
const fingerprint = beacon.gatewayTlsFingerprintSha256
? `sha256 ${beacon.gatewayTlsFingerprintSha256}`
: "enabled";
lines.push(` ${colorize(rich, theme.muted, "tls")}: ${fingerprint}`);
}
if (typeof beacon.sshPort === "number" && beacon.sshPort > 0 && host) { if (typeof beacon.sshPort === "number" && beacon.sshPort > 0 && host) {
const ssh = `ssh -N -L 18789:127.0.0.1:18789 <user>@${host} -p ${beacon.sshPort}`; const ssh = `ssh -N -L 18789:127.0.0.1:18789 <user>@${host} -p ${beacon.sshPort}`;
lines.push(` ${colorize(rich, theme.muted, "ssh")}: ${colorize(rich, theme.command, ssh)}`); lines.push(` ${colorize(rich, theme.muted, "ssh")}: ${colorize(rich, theme.command, ssh)}`);
+2 -27
View File
@@ -1,8 +1,6 @@
import { readConfigFileSnapshot } from "../../config/config.js"; import { readConfigFileSnapshot } from "../../config/config.js";
import { colorize, isRich, theme } from "../../terminal/theme.js";
import { loadAndMaybeMigrateDoctorConfig } from "../../commands/doctor-config-flow.js"; import { loadAndMaybeMigrateDoctorConfig } from "../../commands/doctor-config-flow.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { colorize, isRich, theme } from "../../terminal/theme.js";
import { loadClawdbotPlugins } from "../../plugins/loader.js";
import type { RuntimeEnv } from "../../runtime.js"; import type { RuntimeEnv } from "../../runtime.js";
import { formatCliCommand } from "../command-format.js"; import { formatCliCommand } from "../command-format.js";
@@ -30,26 +28,7 @@ export async function ensureConfigReady(params: {
? snapshot.legacyIssues.map((issue) => `- ${issue.path}: ${issue.message}`) ? snapshot.legacyIssues.map((issue) => `- ${issue.path}: ${issue.message}`)
: []; : [];
const pluginIssues: string[] = []; const invalid = snapshot.exists && !snapshot.valid;
if (snapshot.valid) {
const workspaceDir = resolveAgentWorkspaceDir(
snapshot.config,
resolveDefaultAgentId(snapshot.config),
);
const registry = loadClawdbotPlugins({
config: snapshot.config,
workspaceDir: workspaceDir ?? undefined,
cache: false,
mode: "validate",
});
for (const diag of registry.diagnostics) {
if (diag.level !== "error") continue;
const id = diag.pluginId ? ` ${diag.pluginId}` : "";
pluginIssues.push(`- plugin${id}: ${diag.message}`);
}
}
const invalid = snapshot.exists && (!snapshot.valid || pluginIssues.length > 0);
if (!invalid) return; if (!invalid) return;
const rich = isRich(); const rich = isRich();
@@ -68,10 +47,6 @@ export async function ensureConfigReady(params: {
params.runtime.error(muted("Legacy config keys detected:")); params.runtime.error(muted("Legacy config keys detected:"));
params.runtime.error(legacyIssues.map((issue) => ` ${error(issue)}`).join("\n")); params.runtime.error(legacyIssues.map((issue) => ` ${error(issue)}`).join("\n"));
} }
if (pluginIssues.length > 0) {
params.runtime.error(muted("Plugin config errors:"));
params.runtime.error(pluginIssues.map((issue) => ` ${error(issue)}`).join("\n"));
}
params.runtime.error(""); params.runtime.error("");
params.runtime.error( params.runtime.error(
`${muted("Run:")} ${commandText(formatCliCommand("clawdbot doctor --fix"))}`, `${muted("Run:")} ${commandText(formatCliCommand("clawdbot doctor --fix"))}`,
+8
View File
@@ -94,6 +94,14 @@ const entries: SubCliEntry[] = [
mod.registerNodesCli(program); mod.registerNodesCli(program);
}, },
}, },
{
name: "devices",
description: "Device pairing + token management",
register: async (program) => {
const mod = await import("../devices-cli.js");
mod.registerDevicesCli(program);
},
},
{ {
name: "node", name: "node",
description: "Node control", description: "Node control",
+38
View File
@@ -18,6 +18,7 @@ import { loadModelCatalog } from "../agents/model-catalog.js";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import * as configModule from "../config/config.js"; import * as configModule from "../config/config.js";
import { emitAgentEvent, onAgentEvent } from "../infra/agent-events.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { setActivePluginRegistry } from "../plugins/runtime.js"; import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createPluginRuntime } from "../plugins/runtime/index.js"; import { createPluginRuntime } from "../plugins/runtime/index.js";
@@ -139,6 +140,43 @@ describe("agentCommand", () => {
}); });
}); });
it("does not duplicate agent events from embedded runs", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store);
const assistantEvents: Array<{ runId: string; text?: string }> = [];
const stop = onAgentEvent((evt) => {
if (evt.stream !== "assistant") return;
assistantEvents.push({
runId: evt.runId,
text: typeof evt.data?.text === "string" ? (evt.data.text as string) : undefined,
});
});
vi.mocked(runEmbeddedPiAgent).mockImplementationOnce(async (params) => {
const runId = (params as { runId?: string } | undefined)?.runId ?? "run";
const data = { text: "hello", delta: "hello" };
(
params as {
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
}
).onAgentEvent?.({ stream: "assistant", data });
emitAgentEvent({ runId, stream: "assistant", data });
return {
payloads: [{ text: "hello" }],
meta: { agentMeta: { provider: "p", model: "m" } },
} as never;
});
await agentCommand({ message: "hi", to: "+1555" }, runtime);
stop();
const matching = assistantEvents.filter((evt) => evt.text === "hello");
expect(matching).toHaveLength(1);
});
});
it("uses provider/model from agents.defaults.model.primary", async () => { it("uses provider/model from agents.defaults.model.primary", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const store = path.join(home, "sessions.json"); const store = path.join(home, "sessions.json");
+1 -5
View File
@@ -434,6 +434,7 @@ export async function agentCommand(
streamParams: opts.streamParams, streamParams: opts.streamParams,
agentDir, agentDir,
onAgentEvent: (evt) => { onAgentEvent: (evt) => {
// Track lifecycle end for fallback emission below.
if ( if (
evt.stream === "lifecycle" && evt.stream === "lifecycle" &&
typeof evt.data?.phase === "string" && typeof evt.data?.phase === "string" &&
@@ -441,11 +442,6 @@ export async function agentCommand(
) { ) {
lifecycleEnded = true; lifecycleEnded = true;
} }
emitAgentEvent({
runId,
stream: evt.stream,
data: evt.data,
});
}, },
}); });
}, },
+5
View File
@@ -128,6 +128,11 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
if (snapshot.exists && !snapshot.valid && snapshot.legacyIssues.length === 0) { if (snapshot.exists && !snapshot.valid && snapshot.legacyIssues.length === 0) {
note("Config invalid; doctor will run with best-effort config.", "Config"); note("Config invalid; doctor will run with best-effort config.", "Config");
} }
const warnings = snapshot.warnings ?? [];
if (warnings.length > 0) {
const lines = warnings.map((issue) => `- ${issue.path}: ${issue.message}`).join("\n");
note(lines, "Config warnings");
}
if (snapshot.legacyIssues.length > 0) { if (snapshot.legacyIssues.length > 0) {
note( note(
@@ -6,6 +6,12 @@ import path from "node:path";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { WebSocket } from "ws"; import { WebSocket } from "ws";
import {
loadOrCreateDeviceIdentity,
publicKeyRawBase64UrlFromPem,
signDevicePayload,
} from "../infra/device-identity.js";
import { buildDeviceAuthPayload } from "../gateway/device-auth.js";
import { PROTOCOL_VERSION } from "../gateway/protocol/index.js"; import { PROTOCOL_VERSION } from "../gateway/protocol/index.js";
import { rawDataToString } from "../infra/ws.js"; import { rawDataToString } from "../infra/ws.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
@@ -58,6 +64,23 @@ async function onceMessage<T = unknown>(
async function connectReq(params: { url: string; token?: string }) { async function connectReq(params: { url: string; token?: string }) {
const ws = new WebSocket(params.url); const ws = new WebSocket(params.url);
await new Promise<void>((resolve) => ws.once("open", resolve)); await new Promise<void>((resolve) => ws.once("open", resolve));
const identity = loadOrCreateDeviceIdentity();
const signedAtMs = Date.now();
const payload = buildDeviceAuthPayload({
deviceId: identity.deviceId,
clientId: GATEWAY_CLIENT_NAMES.TEST,
clientMode: GATEWAY_CLIENT_MODES.TEST,
role: "operator",
scopes: [],
signedAtMs,
token: params.token ?? null,
});
const device = {
id: identity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
signature: signDevicePayload(identity.privateKeyPem, payload),
signedAt: signedAtMs,
};
ws.send( ws.send(
JSON.stringify({ JSON.stringify({
type: "req", type: "req",
@@ -75,6 +98,7 @@ async function connectReq(params: { url: string; token?: string }) {
}, },
caps: [], caps: [],
auth: params.token ? { token: params.token } : undefined, auth: params.token ? { token: params.token } : undefined,
device,
}, },
}), }),
); );
@@ -6,6 +6,12 @@ import path from "node:path";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { WebSocket } from "ws"; import { WebSocket } from "ws";
import {
loadOrCreateDeviceIdentity,
publicKeyRawBase64UrlFromPem,
signDevicePayload,
} from "../infra/device-identity.js";
import { buildDeviceAuthPayload } from "../gateway/device-auth.js";
import { PROTOCOL_VERSION } from "../gateway/protocol/index.js"; import { PROTOCOL_VERSION } from "../gateway/protocol/index.js";
import { getFreePort as getFreeTestPort } from "../gateway/test-helpers.js"; import { getFreePort as getFreeTestPort } from "../gateway/test-helpers.js";
import { rawDataToString } from "../infra/ws.js"; import { rawDataToString } from "../infra/ws.js";
@@ -64,6 +70,23 @@ async function onceMessage<T = unknown>(
async function connectReq(params: { url: string; token?: string }) { async function connectReq(params: { url: string; token?: string }) {
const ws = new WebSocket(params.url); const ws = new WebSocket(params.url);
await new Promise<void>((resolve) => ws.once("open", resolve)); await new Promise<void>((resolve) => ws.once("open", resolve));
const identity = loadOrCreateDeviceIdentity();
const signedAtMs = Date.now();
const payload = buildDeviceAuthPayload({
deviceId: identity.deviceId,
clientId: GATEWAY_CLIENT_NAMES.TEST,
clientMode: GATEWAY_CLIENT_MODES.TEST,
role: "operator",
scopes: [],
signedAtMs,
token: params.token ?? null,
});
const device = {
id: identity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
signature: signDevicePayload(identity.privateKeyPem, payload),
signedAt: signedAtMs,
};
ws.send( ws.send(
JSON.stringify({ JSON.stringify({
type: "req", type: "req",
@@ -81,6 +104,7 @@ async function connectReq(params: { url: string; token?: string }) {
}, },
caps: [], caps: [],
auth: params.token ? { token: params.token } : undefined, auth: params.token ? { token: params.token } : undefined,
device,
}, },
}), }),
); );
+1 -9
View File
@@ -1,11 +1,9 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path";
import JSON5 from "json5"; import JSON5 from "json5";
import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../agents/workspace.js"; import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../agents/workspace.js";
import { type ClawdbotConfig, CONFIG_PATH_CLAWDBOT } from "../config/config.js"; import { type ClawdbotConfig, CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../config/config.js";
import { applyModelDefaults } from "../config/defaults.js";
import { resolveSessionTranscriptsDir } from "../config/sessions.js"; import { resolveSessionTranscriptsDir } from "../config/sessions.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
@@ -26,12 +24,6 @@ async function readConfigFileRaw(): Promise<{
} }
} }
async function writeConfigFile(cfg: ClawdbotConfig) {
await fs.mkdir(path.dirname(CONFIG_PATH_CLAWDBOT), { recursive: true });
const json = JSON.stringify(applyModelDefaults(cfg), null, 2).trimEnd().concat("\n");
await fs.writeFile(CONFIG_PATH_CLAWDBOT, json, "utf-8");
}
export async function setupCommand( export async function setupCommand(
opts?: { workspace?: string }, opts?: { workspace?: string },
runtime: RuntimeEnv = defaultRuntime, runtime: RuntimeEnv = defaultRuntime,
+20
View File
@@ -0,0 +1,20 @@
import type { ClawdbotConfig } from "./types.js";
export const DEFAULT_AGENT_MAX_CONCURRENT = 4;
export const DEFAULT_SUBAGENT_MAX_CONCURRENT = 8;
export function resolveAgentMaxConcurrent(cfg?: ClawdbotConfig): number {
const raw = cfg?.agents?.defaults?.maxConcurrent;
if (typeof raw === "number" && Number.isFinite(raw)) {
return Math.max(1, Math.floor(raw));
}
return DEFAULT_AGENT_MAX_CONCURRENT;
}
export function resolveSubagentMaxConcurrent(cfg?: ClawdbotConfig): number {
const raw = cfg?.agents?.defaults?.subagents?.maxConcurrent;
if (typeof raw === "number" && Number.isFinite(raw)) {
return Math.max(1, Math.floor(raw));
}
return DEFAULT_SUBAGENT_MAX_CONCURRENT;
}
@@ -0,0 +1,62 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
DEFAULT_AGENT_MAX_CONCURRENT,
DEFAULT_SUBAGENT_MAX_CONCURRENT,
resolveAgentMaxConcurrent,
resolveSubagentMaxConcurrent,
} from "./agent-limits.js";
import { withTempHome } from "./test-helpers.js";
describe("agent concurrency defaults", () => {
it("resolves defaults when unset", () => {
expect(resolveAgentMaxConcurrent({})).toBe(DEFAULT_AGENT_MAX_CONCURRENT);
expect(resolveSubagentMaxConcurrent({})).toBe(DEFAULT_SUBAGENT_MAX_CONCURRENT);
});
it("resolves configured values", () => {
const cfg = {
agents: {
defaults: {
maxConcurrent: 6,
subagents: { maxConcurrent: 9 },
},
},
};
expect(resolveAgentMaxConcurrent(cfg)).toBe(6);
expect(resolveSubagentMaxConcurrent(cfg)).toBe(9);
});
it("clamps invalid values to at least 1", () => {
const cfg = {
agents: {
defaults: {
maxConcurrent: 0,
subagents: { maxConcurrent: -3 },
},
},
};
expect(resolveAgentMaxConcurrent(cfg)).toBe(1);
expect(resolveSubagentMaxConcurrent(cfg)).toBe(1);
});
it("injects defaults on load", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
JSON.stringify({}, null, 2),
"utf-8",
);
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.agents?.defaults?.maxConcurrent).toBe(DEFAULT_AGENT_MAX_CONCURRENT);
expect(cfg.agents?.defaults?.subagents?.maxConcurrent).toBe(DEFAULT_SUBAGENT_MAX_CONCURRENT);
});
});
});
+5 -2
View File
@@ -12,7 +12,7 @@ describe("config backup rotation", () => {
const configPath = resolveConfigPath(); const configPath = resolveConfigPath();
const buildConfig = (version: number): ClawdbotConfig => const buildConfig = (version: number): ClawdbotConfig =>
({ ({
identity: { name: `v${version}` }, agents: { list: [{ id: `v${version}` }] },
}) as ClawdbotConfig; }) as ClawdbotConfig;
for (let version = 0; version <= 6; version += 1) { for (let version = 0; version <= 6; version += 1) {
@@ -21,7 +21,10 @@ describe("config backup rotation", () => {
const readName = async (suffix = "") => { const readName = async (suffix = "") => {
const raw = await fs.readFile(`${configPath}${suffix}`, "utf-8"); const raw = await fs.readFile(`${configPath}${suffix}`, "utf-8");
return (JSON.parse(raw) as { identity?: { name?: string } }).identity?.name ?? null; return (
(JSON.parse(raw) as { agents?: { list?: Array<{ id?: string }> } }).agents?.list?.[0]
?.id ?? null
);
}; };
await expect(readName()).resolves.toBe("v6"); await expect(readName()).resolves.toBe("v6");
+7 -2
View File
@@ -1,6 +1,7 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_AGENT_MAX_CONCURRENT, DEFAULT_SUBAGENT_MAX_CONCURRENT } from "./agent-limits.js";
import { withTempHome } from "./test-helpers.js"; import { withTempHome } from "./test-helpers.js";
describe("config identity defaults", () => { describe("config identity defaults", () => {
@@ -284,7 +285,7 @@ describe("config identity defaults", () => {
}); });
}); });
it("does not synthesize agent/session when absent", async () => { it("does not synthesize agent list/session when absent", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot"); const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true }); await fs.mkdir(configDir, { recursive: true });
@@ -306,7 +307,11 @@ describe("config identity defaults", () => {
expect(cfg.messages?.responsePrefix).toBeUndefined(); expect(cfg.messages?.responsePrefix).toBeUndefined();
expect(cfg.messages?.groupChat?.mentionPatterns).toBeUndefined(); expect(cfg.messages?.groupChat?.mentionPatterns).toBeUndefined();
expect(cfg.agents).toBeUndefined(); expect(cfg.agents?.list).toBeUndefined();
expect(cfg.agents?.defaults?.maxConcurrent).toBe(DEFAULT_AGENT_MAX_CONCURRENT);
expect(cfg.agents?.defaults?.subagents?.maxConcurrent).toBe(
DEFAULT_SUBAGENT_MAX_CONCURRENT,
);
expect(cfg.session).toBeUndefined(); expect(cfg.session).toBeUndefined();
}); });
}); });
Loaded 100 of 178 files, more files were not shown because too many files have changed in this diff. Show more