Compare commits

..
Author SHA1 Message Date
Shakker d57c9ce9f1 Merge branch 'main' into feat/plan-mode 2026-01-26 18:00:53 +00:00
Peter Steinberger b9098f3401 fix: remove unsupported gateway auth off option 2026-01-26 17:44:23 +00:00
Peter Steinberger e6bdffe568 feat: add control ui device auth bypass 2026-01-26 17:40:28 +00:00
Peter Steinberger a486940781 fix: honor tools.exec.safeBins config 2026-01-26 17:22:40 +00:00
Shadow 2a4ccb624a Docs: update clawtributors 2026-01-26 11:02:59 -06:00
Shadow e0b8661eee Docs: credit LINE channel guide contributor 2026-01-26 11:02:59 -06:00
Peter SteinbergerandGlucksberg bfe9bb8a23 docs(changelog): note slack redirect fix
Co-authored-by: Glucksberg <markuscontasul@gmail.com>
2026-01-26 17:01:22 +00:00
Peter SteinbergerandGlucksberg 287ab84060 fix(slack): handle file redirects
Co-authored-by: Glucksberg <markuscontasul@gmail.com>
2026-01-26 17:01:22 +00:00
Peter Steinberger b06fc50e25 docs: clarify onboarding security warning 2026-01-26 16:58:55 +00:00
Ayaan Zaidi 94ead83ba4 fix: telegram sendPayload and plugin auth (#1917) (thanks @JoshuaLelon) 2026-01-26 22:28:14 +05:30
Joshua Mitchell db2395744b fix(telegram): extract and send buttons from channelData
Plugin commands can return buttons in channelData.telegram.buttons,
but deliverReplies() was ignoring them. Now we:

1. Extract buttons from reply.channelData?.telegram?.buttons
2. Build inline keyboard using buildInlineKeyboard()
3. Pass reply_markup to sendMessage()

Buttons are attached to the first text chunk when text is chunked.
2026-01-26 22:28:14 +05:30
Joshua Mitchell b8e6f0b135 fix(telegram): register bot.command handlers for plugin commands
Plugin commands were added to setMyCommands menu but didn't have
bot.command() handlers registered. This meant /flow-start and other
plugin commands would fall through to the general message handler
instead of being dispatched to the plugin command executor.

Now we register bot.command() handlers for each plugin command,
with full authorization checks and proper result delivery.
2026-01-26 22:28:14 +05:30
Joshua Mitchell 0e3340d1fc feat(plugins): sync plugin commands to Telegram menu and export gateway types
- Add plugin command specs to Telegram setMyCommands for autocomplete
- Export GatewayRequestHandler types in plugin-sdk for plugin authors
- Enables plugins to register gateway methods and appear in command menus
2026-01-26 22:28:14 +05:30
Joshua MitchellandClaude Sonnet 4.5 ce60c6db1b feat(telegram): implement sendPayload for channelData support
Add sendPayload handler to Telegram outbound adapter to support
channel-specific data via the channelData pattern. This enables
features like inline keyboard buttons without custom ReplyPayload fields.

Implementation:
- Extract telegram.buttons from payload.channelData
- Pass buttons to sendMessageTelegram (already supports this)
- Follows existing sendText/sendMedia patterns
- Completes optional ChannelOutboundAdapter.sendPayload interface

This enables plugins to send Telegram-specific features (buttons, etc.)
using the standard channelData envelope pattern instead of custom fields.

Related: delivery system in src/infra/outbound/deliver.ts:324 already
checks for sendPayload handler and routes accordingly.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-26 22:28:14 +05:30
Peter Steinberger c01cc61f9a docs: note fly private deployment fixups (#2289) (thanks @dguido) 2026-01-26 16:58:09 +00:00
Peter Steinberger 5b6a211583 docs: tighten fly private deployment steps 2026-01-26 16:58:09 +00:00
Dan GuidoandClaude Opus 4.5 b9643ad60e docs(fly): add private/hardened deployment guide
- Add fly.private.toml template for deployments with no public IP
- Add "Private Deployment (Hardened)" section to Fly docs
- Document how to convert existing deployment to private-only
- Add security notes recommending env vars over config file for secrets

This addresses security concerns about Clawdbot gateways being
discoverable on internet scanners (Shodan, Censys). Private deployments
are accessible only via fly proxy, WireGuard, or SSH.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 16:52:55 +00:00
Vignesh Natarajan b6f9c31d05 test: expand /plan TUI coverage 2026-01-24 17:19:10 -08:00
Vignesh Natarajan ed70d596ec feat: /plan multiselect + end-of-plan question extension 2026-01-24 17:12:17 -08:00
Vignesh Natarajan 07f176b562 feat: interactive /plan questionnaire in CLI 2026-01-24 16:54:30 -08:00
Vignesh Natarajan ecf4b1a527 feat: /plans interactive picker in CLI 2026-01-24 16:40:24 -08:00
Vignesh Natarajan e8287571e2 feat: add /plans command to manage planning artifacts 2026-01-24 16:35:32 -08:00
Vignesh Natarajan 1179c3a88f feat: add /plan planning-mode instructions 2026-01-24 16:31:47 -08:00
41 changed files with 2339 additions and 195 deletions

No files matched your search

+8 -2
View File
@@ -6,8 +6,10 @@ Docs: https://docs.clawd.bot
Status: unreleased.
### Changes
- Docs: add Fly private/hardened deployment guide. (#2121) Thanks @dguido.
- Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281)
- Docs: tighten Fly private deployment steps. (#2289) Thanks @dguido.
- Gateway: warn on hook tokens via query params; document header auth preference. (#2200) Thanks @YuriNachos.
- Gateway: add dangerous Control UI device auth bypass flag + audit warnings. (#2248)
- Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz.
- Discord: add configurable privileged gateway intents for presences/members. (#2266) Thanks @kentaro.
- Docs: add Vercel AI Gateway to providers sidebar. (#1901) Thanks @jerilynzheng.
@@ -18,9 +20,10 @@ Status: unreleased.
- Docs: add DigitalOcean deployment guide. (#1870) Thanks @0xJonHoldsCrypto.
- Docs: add Raspberry Pi install guide. (#1871) Thanks @0xJonHoldsCrypto.
- Docs: add GCP Compute Engine deployment guide. (#1848) Thanks @hougangdev.
- Docs: add LINE channel guide.
- Docs: add LINE channel guide. Thanks @thewilloftheshadow.
- Docs: credit both contributors for Control UI refresh. (#1852) Thanks @EnzeD.
- Onboarding: add Venice API key to non-interactive flow. (#1893) Thanks @jonisjongithub.
- Onboarding: strengthen security warning copy for beta + access control expectations.
- Tlon: format thread reply IDs as @ud. (#1837) Thanks @wca4a.
- Gateway: prefer newest session metadata when combining stores. (#1823) Thanks @emanuelst.
- Web UI: keep sub-agent announce replies visible in WebChat. (#1977) Thanks @andrescardonas7.
@@ -29,6 +32,7 @@ Status: unreleased.
- Browser: fall back to URL matching for extension relay target resolution. (#1999) Thanks @jonit-dev.
- Update: ignore dist/control-ui for dirty checks and restore after ui builds. (#1976) Thanks @Glucksberg.
- Telegram: allow caption param for media sends. (#1888) Thanks @mguellsegarra.
- Telegram: support plugin sendPayload channelData (media/buttons) and validate plugin commands. (#1917) Thanks @JoshuaLelon.
- Telegram: avoid block replies when streaming is disabled. (#1885) Thanks @ivancasco.
- Auth: show copyable Google auth URL after ASCII prompt. (#1787) Thanks @robbyczgw-cla.
- Routing: precompile session key regexes. (#1697) Thanks @Ray0907.
@@ -48,10 +52,12 @@ Status: unreleased.
- Web UI: improve WebChat image paste previews and allow image-only sends. (#1925) Thanks @smartprogrammer93.
- Security: wrap external hook content by default with a per-hook opt-out. (#1827) Thanks @mertcicekci0.
- Gateway: default auth now fail-closed (token/password required; Tailscale Serve identity remains allowed).
- Onboarding: remove unsupported gateway auth "off" choice from onboarding/configure flows and CLI flags.
## 2026.1.24-3
### Fixes
- Slack: fix image downloads failing due to missing Authorization header on cross-origin redirects. (#1936) Thanks @sanderhelgesen.
- Gateway: harden reverse proxy handling for local-client detection and unauthenticated proxied connects. (#1795) Thanks @orlyjamie.
- Security audit: flag loopback Control UI with auth disabled as critical. (#1795) Thanks @orlyjamie.
- CLI: resume claude-cli sessions and stream CLI replies to TUI clients. (#1921) Thanks @rmorse.
+28 -27
View File
@@ -479,32 +479,33 @@ Thanks to all clawtributors:
<p align="left">
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/plum-dawg"><img src="https://avatars.githubusercontent.com/u/5909950?v=4&s=48" width="48" height="48" alt="plum-dawg" title="plum-dawg"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/Glucksberg"><img src="https://avatars.githubusercontent.com/u/80581902?v=4&s=48" width="48" height="48" alt="Glucksberg" title="Glucksberg"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a>
<a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/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/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/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a>
<a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/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/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/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="patelhiren" title="patelhiren"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/jonisjongithub"><img src="https://avatars.githubusercontent.com/u/86072337?v=4&s=48" width="48" height="48" alt="jonisjongithub" title="jonisjongithub"/></a> <a href="https://github.com/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/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/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/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/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/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="patelhiren" title="patelhiren"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/jonisjongithub"><img src="https://avatars.githubusercontent.com/u/86072337?v=4&s=48" width="48" height="48" alt="jonisjongithub" title="jonisjongithub"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a>
<a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/mousberg"><img src="https://avatars.githubusercontent.com/u/57605064?v=4&s=48" width="48" height="48" alt="mousberg" title="mousberg"/></a>
<a href="https://github.com/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/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/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/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/rohannagpal"><img src="https://avatars.githubusercontent.com/u/4009239?v=4&s=48" width="48" height="48" alt="rohannagpal" title="rohannagpal"/></a>
<a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/f-trycua"><img src="https://avatars.githubusercontent.com/u/195596869?v=4&s=48" width="48" height="48" alt="f-trycua" title="f-trycua"/></a> <a href="https://github.com/benostein"><img src="https://avatars.githubusercontent.com/u/31802821?v=4&s=48" width="48" height="48" alt="benostein" title="benostein"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a>
<a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/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/denysvitali"><img src="https://avatars.githubusercontent.com/u/4939519?v=4&s=48" width="48" height="48" alt="denysvitali" title="denysvitali"/></a> <a href="https://github.com/orlyjamie"><img src="https://avatars.githubusercontent.com/u/6668807?v=4&s=48" width="48" height="48" alt="orlyjamie" title="orlyjamie"/></a>
<a href="https://github.com/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/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/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/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/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/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a>
<a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></a> <a href="https://github.com/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/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/kyleok"><img src="https://avatars.githubusercontent.com/u/58307870?v=4&s=48" width="48" height="48" alt="kyleok" title="kyleok"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a>
<a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/uos-status"><img src="https://avatars.githubusercontent.com/u/255712580?v=4&s=48" width="48" height="48" alt="uos-status" title="uos-status"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/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/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/JonUleis"><img src="https://avatars.githubusercontent.com/u/7644941?v=4&s=48" width="48" height="48" alt="JonUleis" title="JonUleis"/></a> <a href="https://github.com/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/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/pookNast"><img src="https://avatars.githubusercontent.com/u/14242552?v=4&s=48" width="48" height="48" alt="pookNast" title="pookNast"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/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/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a>
<a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a>
<a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a>
<a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a>
<a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a>
<a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/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/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a>
<a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/rmorse"><img src="https://avatars.githubusercontent.com/u/853547?v=4&s=48" width="48" height="48" alt="rmorse" title="rmorse"/></a>
<a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/andreabadesso"><img src="https://avatars.githubusercontent.com/u/3586068?v=4&s=48" width="48" height="48" alt="andreabadesso" title="andreabadesso"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/dguido"><img src="https://avatars.githubusercontent.com/u/294844?v=4&s=48" width="48" height="48" alt="dguido" title="dguido"/></a> <a href="https://github.com/EnzeD"><img src="https://avatars.githubusercontent.com/u/9866900?v=4&s=48" width="48" height="48" alt="EnzeD" title="EnzeD"/></a>
<a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/itsjaydesu"><img src="https://avatars.githubusercontent.com/u/220390?v=4&s=48" width="48" height="48" alt="itsjaydesu" title="itsjaydesu"/></a> <a href="https://github.com/ivancasco"><img src="https://avatars.githubusercontent.com/u/2452858?v=4&s=48" width="48" height="48" alt="ivancasco" title="ivancasco"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a>
<a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/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/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></a> <a href="https://github.com/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/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></a>
<a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/abhaymundhara"><img src="https://avatars.githubusercontent.com/u/62872231?v=4&s=48" width="48" height="48" alt="abhaymundhara" title="abhaymundhara"/></a> <a href="https://github.com/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/arthyn"><img src="https://avatars.githubusercontent.com/u/5466421?v=4&s=48" width="48" height="48" alt="arthyn" title="arthyn"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/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/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/dasilva333"><img src="https://avatars.githubusercontent.com/u/947827?v=4&s=48" width="48" height="48" alt="dasilva333" title="dasilva333"/></a> <a href="https://github.com/search?q=Developer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Developer" title="Developer"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/foeken"><img src="https://avatars.githubusercontent.com/u/13864?v=4&s=48" width="48" height="48" alt="foeken" title="foeken"/></a> <a href="https://github.com/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/grrowl"><img src="https://avatars.githubusercontent.com/u/907140?v=4&s=48" width="48" height="48" alt="grrowl" title="grrowl"/></a>
<a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=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/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/louzhixian"><img src="https://avatars.githubusercontent.com/u/7994361?v=4&s=48" width="48" height="48" alt="louzhixian" title="louzhixian"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a> <a href="https://github.com/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/Noctivoro"><img src="https://avatars.githubusercontent.com/u/183974570?v=4&s=48" width="48" height="48" alt="Noctivoro" title="Noctivoro"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/senoldogann"><img src="https://avatars.githubusercontent.com/u/45736551?v=4&s=48" width="48" height="48" alt="senoldogann" title="senoldogann"/></a>
<a href="https://github.com/Seredeep"><img src="https://avatars.githubusercontent.com/u/22802816?v=4&s=48" width="48" height="48" alt="Seredeep" title="Seredeep"/></a> <a href="https://github.com/sergical"><img src="https://avatars.githubusercontent.com/u/3760543?v=4&s=48" width="48" height="48" alt="sergical" title="sergical"/></a> <a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></a> <a href="https://github.com/shiyuanhai"><img src="https://avatars.githubusercontent.com/u/1187370?v=4&s=48" width="48" height="48" alt="shiyuanhai" title="shiyuanhai"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a>
<a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/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/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/search?q=ymat19"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ymat19" title="ymat19"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/0xJonHoldsCrypto"><img src="https://avatars.githubusercontent.com/u/81202085?v=4&s=48" width="48" height="48" alt="0xJonHoldsCrypto" title="0xJonHoldsCrypto"/></a> <a href="https://github.com/aaronn"><img src="https://avatars.githubusercontent.com/u/1653630?v=4&s=48" width="48" height="48" alt="aaronn" title="aaronn"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a>
<a href="https://github.com/atalovesyou"><img src="https://avatars.githubusercontent.com/u/3534502?v=4&s=48" width="48" height="48" alt="atalovesyou" title="atalovesyou"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/hougangdev"><img src="https://avatars.githubusercontent.com/u/105773686?v=4&s=48" width="48" height="48" alt="hougangdev" title="hougangdev"/></a> <a href="https://github.com/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>
<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/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/joeynyc"><img src="https://avatars.githubusercontent.com/u/17919866?v=4&s=48" width="48" height="48" alt="joeynyc" title="joeynyc"/></a> <a href="https://github.com/orlyjamie"><img src="https://avatars.githubusercontent.com/u/6668807?v=4&s=48" width="48" height="48" alt="orlyjamie" title="orlyjamie"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/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/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a>
<a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/rohannagpal"><img src="https://avatars.githubusercontent.com/u/4009239?v=4&s=48" width="48" height="48" alt="rohannagpal" title="rohannagpal"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/f-trycua"><img src="https://avatars.githubusercontent.com/u/195596869?v=4&s=48" width="48" height="48" alt="f-trycua" title="f-trycua"/></a> <a href="https://github.com/benostein"><img src="https://avatars.githubusercontent.com/u/31802821?v=4&s=48" width="48" height="48" alt="benostein" title="benostein"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a>
<a href="https://github.com/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/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/denysvitali"><img src="https://avatars.githubusercontent.com/u/4939519?v=4&s=48" width="48" height="48" alt="denysvitali" title="denysvitali"/></a> <a href="https://github.com/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/shakkernerd"><img src="https://avatars.githubusercontent.com/u/165377636?v=4&s=48" width="48" height="48" alt="shakkernerd" title="shakkernerd"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/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/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/rhuanssauro"><img src="https://avatars.githubusercontent.com/u/164682191?v=4&s=48" width="48" height="48" alt="rhuanssauro" title="rhuanssauro"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/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/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a>
<a href="https://github.com/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></a> <a href="https://github.com/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/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a>
<a href="https://github.com/kyleok"><img src="https://avatars.githubusercontent.com/u/58307870?v=4&s=48" width="48" height="48" alt="kyleok" title="kyleok"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/uos-status"><img src="https://avatars.githubusercontent.com/u/255712580?v=4&s=48" width="48" height="48" alt="uos-status" title="uos-status"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/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/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a>
<a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/JonUleis"><img src="https://avatars.githubusercontent.com/u/7644941?v=4&s=48" width="48" height="48" alt="JonUleis" title="JonUleis"/></a> <a href="https://github.com/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/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/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/pookNast"><img src="https://avatars.githubusercontent.com/u/14242552?v=4&s=48" width="48" height="48" alt="pookNast" title="pookNast"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a>
<a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a>
<a href="https://github.com/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a>
<a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a>
<a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a>
<a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a>
<a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/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/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a>
<a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/rmorse"><img src="https://avatars.githubusercontent.com/u/853547?v=4&s=48" width="48" height="48" alt="rmorse" title="rmorse"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/andreabadesso"><img src="https://avatars.githubusercontent.com/u/3586068?v=4&s=48" width="48" height="48" alt="andreabadesso" title="andreabadesso"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a>
<a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/dguido"><img src="https://avatars.githubusercontent.com/u/294844?v=4&s=48" width="48" height="48" alt="dguido" title="dguido"/></a> <a href="https://github.com/EnzeD"><img src="https://avatars.githubusercontent.com/u/9866900?v=4&s=48" width="48" height="48" alt="EnzeD" title="EnzeD"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/itsjaydesu"><img src="https://avatars.githubusercontent.com/u/220390?v=4&s=48" width="48" height="48" alt="itsjaydesu" title="itsjaydesu"/></a> <a href="https://github.com/ivancasco"><img src="https://avatars.githubusercontent.com/u/2452858?v=4&s=48" width="48" height="48" alt="ivancasco" title="ivancasco"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a>
<a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/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/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></a> <a href="https://github.com/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/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/abhaymundhara"><img src="https://avatars.githubusercontent.com/u/62872231?v=4&s=48" width="48" height="48" alt="abhaymundhara" title="abhaymundhara"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/Alex-Alaniz"><img src="https://avatars.githubusercontent.com/u/88956822?v=4&s=48" width="48" height="48" alt="Alex-Alaniz" title="Alex-Alaniz"/></a> <a href="https://github.com/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/arthyn"><img src="https://avatars.githubusercontent.com/u/5466421?v=4&s=48" width="48" height="48" alt="arthyn" title="arthyn"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/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/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/dasilva333"><img src="https://avatars.githubusercontent.com/u/947827?v=4&s=48" width="48" height="48" alt="dasilva333" title="dasilva333"/></a> <a href="https://github.com/search?q=Developer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Developer" title="Developer"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></a>
<a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/foeken"><img src="https://avatars.githubusercontent.com/u/13864?v=4&s=48" width="48" height="48" alt="foeken" title="foeken"/></a> <a href="https://github.com/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/grrowl"><img src="https://avatars.githubusercontent.com/u/907140?v=4&s=48" width="48" height="48" alt="grrowl" title="grrowl"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=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/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/louzhixian"><img src="https://avatars.githubusercontent.com/u/7994361?v=4&s=48" width="48" height="48" alt="louzhixian" title="louzhixian"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a> <a href="https://github.com/mertcicekci0"><img src="https://avatars.githubusercontent.com/u/179321902?v=4&s=48" width="48" height="48" alt="mertcicekci0" title="mertcicekci0"/></a>
<a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/Noctivoro"><img src="https://avatars.githubusercontent.com/u/183974570?v=4&s=48" width="48" height="48" alt="Noctivoro" title="Noctivoro"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a>
<a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/senoldogann"><img src="https://avatars.githubusercontent.com/u/45736551?v=4&s=48" width="48" height="48" alt="senoldogann" title="senoldogann"/></a> <a href="https://github.com/Seredeep"><img src="https://avatars.githubusercontent.com/u/22802816?v=4&s=48" width="48" height="48" alt="Seredeep" title="Seredeep"/></a> <a href="https://github.com/sergical"><img src="https://avatars.githubusercontent.com/u/3760543?v=4&s=48" width="48" height="48" alt="sergical" title="sergical"/></a> <a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></a> <a href="https://github.com/shiyuanhai"><img src="https://avatars.githubusercontent.com/u/1187370?v=4&s=48" width="48" height="48" alt="shiyuanhai" title="shiyuanhai"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a>
<a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/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/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a>
<a href="https://github.com/search?q=ymat19"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ymat19" title="ymat19"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/0xJonHoldsCrypto"><img src="https://avatars.githubusercontent.com/u/81202085?v=4&s=48" width="48" height="48" alt="0xJonHoldsCrypto" title="0xJonHoldsCrypto"/></a> <a href="https://github.com/aaronn"><img src="https://avatars.githubusercontent.com/u/1653630?v=4&s=48" width="48" height="48" alt="aaronn" title="aaronn"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/atalovesyou"><img src="https://avatars.githubusercontent.com/u/3534502?v=4&s=48" width="48" height="48" alt="atalovesyou" title="atalovesyou"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a>
<a href="https://github.com/hougangdev"><img src="https://avatars.githubusercontent.com/u/105773686?v=4&s=48" width="48" height="48" alt="hougangdev" title="hougangdev"/></a> <a href="https://github.com/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>
+1 -1
View File
@@ -314,7 +314,7 @@ Options:
- `--opencode-zen-api-key <key>`
- `--gateway-port <port>`
- `--gateway-bind <loopback|lan|tailnet|auto|custom>`
- `--gateway-auth <off|token|password>`
- `--gateway-auth <token|password>`
- `--gateway-token <token>`
- `--gateway-password <password>`
- `--remote-url <url>`
+4 -2
View File
@@ -2847,9 +2847,11 @@ Control UI base path:
- `gateway.controlUi.basePath` sets the URL prefix where the Control UI is served.
- Examples: `"/ui"`, `"/clawdbot"`, `"/apps/clawdbot"`.
- Default: root (`/`) (unchanged).
- `gateway.controlUi.allowInsecureAuth` allows token-only auth for the Control UI and skips
device identity + pairing (even on HTTPS). Default: `false`. Prefer HTTPS
- `gateway.controlUi.allowInsecureAuth` allows token-only auth for the Control UI when
device identity is omitted (typically over HTTP). Default: `false`. Prefer HTTPS
(Tailscale Serve) or `127.0.0.1`.
- `gateway.controlUi.dangerouslyDisableDeviceAuth` disables device identity checks for the
Control UI (token/password only). Default: `false`. Break-glass only.
Related docs:
- [Control UI](/web/control-ui)
+2 -1
View File
@@ -198,7 +198,8 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
- **Local** connects include loopback and the gateway host’s own tailnet address
(so same‑host tailnet binds can still auto‑approve).
- All WS clients must include `device` identity during `connect` (operator + node).
Control UI can omit it **only** when `gateway.controlUi.allowInsecureAuth` is enabled.
Control UI can omit it **only** when `gateway.controlUi.allowInsecureAuth` is enabled
(or `gateway.controlUi.dangerouslyDisableDeviceAuth` for break-glass use).
- Non-local connections must sign the server-provided `connect.challenge` nonce.
## TLS + pinning
+5 -1
View File
@@ -58,9 +58,13 @@ When the audit prints findings, treat this as a priority order:
The Control UI needs a **secure context** (HTTPS or localhost) to generate device
identity. If you enable `gateway.controlUi.allowInsecureAuth`, the UI falls back
to **token-only auth** and skips device pairing (even on HTTPS). This is a security
to **token-only auth** and skips device pairing when device identity is omitted. This is a security
downgrade—prefer HTTPS (Tailscale Serve) or open the UI on `127.0.0.1`.
For break-glass scenarios only, `gateway.controlUi.dangerouslyDisableDeviceAuth`
disables device identity checks entirely. This is a severe security downgrade;
keep it off unless you are actively debugging and can revert quickly.
`clawdbot security audit` warns when this setting is enabled.
## Reverse Proxy Configuration
+1 -1
View File
@@ -214,7 +214,7 @@ the Gateway likely refused to bind.
- Fix: run `clawdbot doctor` to update it (or `clawdbot gateway install --force` for a full rewrite).
**If `Last gateway error:` mentions “refusing to bind … without auth”**
- You set `gateway.bind` to a non-loopback mode (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) but left auth off.
- You set `gateway.bind` to a non-loopback mode (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) but didn’t configure auth.
- Fix: set `gateway.auth.mode` + `gateway.auth.token` (or export `CLAWDBOT_GATEWAY_TOKEN`) and restart the service.
**If `clawdbot gateway status` says `bind=tailnet` but no tailnet interface was found**
+78
View File
@@ -0,0 +1,78 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import type { ExecApprovalsResolved } from "../infra/exec-approvals.js";
import { createClawdbotCodingTools } from "./pi-tools.js";
vi.mock("../infra/exec-approvals.js", async (importOriginal) => {
const mod = await importOriginal<typeof import("../infra/exec-approvals.js")>();
const approvals: ExecApprovalsResolved = {
path: "/tmp/exec-approvals.json",
socketPath: "/tmp/exec-approvals.sock",
token: "token",
defaults: {
security: "allowlist",
ask: "off",
askFallback: "deny",
autoAllowSkills: false,
},
agent: {
security: "allowlist",
ask: "off",
askFallback: "deny",
autoAllowSkills: false,
},
allowlist: [],
file: {
version: 1,
socket: { path: "/tmp/exec-approvals.sock", token: "token" },
defaults: {
security: "allowlist",
ask: "off",
askFallback: "deny",
autoAllowSkills: false,
},
agents: {},
},
};
return { ...mod, resolveExecApprovals: () => approvals };
});
describe("createClawdbotCodingTools safeBins", () => {
it("threads tools.exec.safeBins into exec allowlist checks", async () => {
if (process.platform === "win32") return;
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-safe-bins-"));
const cfg: ClawdbotConfig = {
tools: {
exec: {
host: "gateway",
security: "allowlist",
ask: "off",
safeBins: ["echo"],
},
},
};
const tools = createClawdbotCodingTools({
config: cfg,
sessionKey: "agent:main:main",
workspaceDir: tmpDir,
agentDir: path.join(tmpDir, "agent"),
});
const execTool = tools.find((tool) => tool.name === "exec");
expect(execTool).toBeDefined();
const marker = `safe-bins-${Date.now()}`;
const result = await execTool!.execute("call1", {
command: `echo ${marker}`,
workdir: tmpDir,
});
const text = result.content.find((content) => content.type === "text")?.text ?? "";
expect(result.details.status).toBe("completed");
expect(text).toContain(marker);
});
});
+2
View File
@@ -86,6 +86,7 @@ function resolveExecConfig(cfg: ClawdbotConfig | undefined) {
ask: globalExec?.ask,
node: globalExec?.node,
pathPrepend: globalExec?.pathPrepend,
safeBins: globalExec?.safeBins,
backgroundMs: globalExec?.backgroundMs,
timeoutSec: globalExec?.timeoutSec,
approvalRunningNoticeMs: globalExec?.approvalRunningNoticeMs,
@@ -235,6 +236,7 @@ export function createClawdbotCodingTools(options?: {
ask: options?.exec?.ask ?? execConfig.ask,
node: options?.exec?.node ?? execConfig.node,
pathPrepend: options?.exec?.pathPrepend ?? execConfig.pathPrepend,
safeBins: options?.exec?.safeBins ?? execConfig.safeBins,
agentId,
cwd: options?.workspaceDir,
allowBackground,
+10
View File
@@ -310,6 +310,16 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("/status shows Reasoning");
});
it("includes /plan mode instructions", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
});
expect(prompt).toContain("## Plan Mode (/plan)");
expect(prompt).toContain("If the user message starts with /plan");
expect(prompt).toContain("plans/<timestamp>-<slug>/");
});
it("builds runtime line with agent and channel details", () => {
const line = buildRuntimeLine(
{
+21
View File
@@ -558,6 +558,27 @@ export function buildAgentSystemPrompt(params: {
);
}
// Plan mode is conversational but stateful: compile a messy goal into a structured artifact.
if (!isMinimal) {
lines.push(
"## Plan Mode (/plan)",
"If the user message starts with /plan, enter *planning mode*.",
"- Treat everything after /plan as the goal.",
"- Ask targeted follow-up questions (one at a time) to remove ambiguity.",
"- Persist state in the workspace so planning can resume without losing context.",
"",
"Artifacts:",
"- Create a directory plans/<timestamp>-<slug>/ in the workspace.",
"- Maintain plans/<id>/answers.json (incremental) and plans/<id>/plan.md (human-readable).",
"- Optionally create plans/<id>/plan.json (structured) when helpful.",
"",
"Rules:",
"- Be token-efficient: do not restate the entire plan on every turn; ask the next question.",
"- When enough information is collected, produce a crisp plan with milestones + next actions.",
"",
);
}
lines.push(
"## Runtime",
buildRuntimeLine(runtimeInfo, runtimeChannel, runtimeCapabilities, params.defaultThinkLevel),
+14
View File
@@ -157,6 +157,20 @@ function buildChatCommands(): ChatCommandDefinition[] {
description: "Show current status.",
textAlias: "/status",
}),
defineChatCommand({
key: "plan",
nativeName: "plan",
description: "Interactive project planning (/plan) with saved artifacts.",
textAlias: "/plan",
acceptsArgs: true,
}),
defineChatCommand({
key: "plans",
nativeName: "plans",
description: "List/show/rename saved /plan artifacts.",
textAlias: "/plans",
acceptsArgs: true,
}),
defineChatCommand({
key: "allowlist",
description: "List/add/remove allowlist entries.",
+4
View File
@@ -27,6 +27,8 @@ import {
handleUsageCommand,
} from "./commands-session.js";
import { handlePluginCommand } from "./commands-plugin.js";
import { handlePlansCommand } from "./commands-plans.js";
import { handlePlanCommand } from "./commands-plan.js";
import type {
CommandHandler,
CommandHandlerResult,
@@ -43,6 +45,8 @@ const HANDLERS: CommandHandler[] = [
handleRestartCommand,
handleTtsCommands,
handleHelpCommand,
handlePlanCommand,
handlePlansCommand,
handleCommandsListCommand,
handleStatusCommand,
handleAllowlistCommand,
+396
View File
@@ -0,0 +1,396 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import type { MsgContext } from "../templating.js";
import { buildCommandContext, handleCommands } from "./commands.js";
import { parseInlineDirectives } from "./directive-handling.js";
const CANCEL = Symbol.for("clack:cancel");
const hoisted = vi.hoisted(() => {
const state = {
selectQueue: [] as any[],
textQueue: [] as any[],
confirmQueue: [] as any[],
multiselectQueue: [] as any[],
// Embedded runner control
baseQuestions: [
{
id: "budget",
section: "Constraints",
prompt: "Budget?",
kind: "select",
required: true,
options: ["$", "$$"],
},
{
id: "deadline",
section: "Timeline",
prompt: "Deadline?",
kind: "text",
},
] as any[],
extraQuestions: [
{
id: "transport",
section: "Constraints",
prompt: "Preferred transport?",
kind: "multiselect",
required: true,
options: ["Car", "Plane"],
},
] as any[],
};
function resetQueues() {
state.selectQueue = [];
state.textQueue = [];
state.confirmQueue = [];
state.multiselectQueue = [];
}
const clack = {
select: vi.fn(async ({ options, message }: any) => {
const queued = state.selectQueue.shift();
if (queued !== undefined) return queued;
// Default behavior: choose first section once, then review.
if (String(message).includes("Choose a section")) {
// If we already chose a section once, go to review.
const already = (clack.select as any)._chosenOnce === true;
(clack.select as any)._chosenOnce = true;
if (already) return "__review";
return options[0].value;
}
return options[0].value;
}),
text: vi.fn(async ({ initialValue }: any) => {
const queued = state.textQueue.shift();
if (queued !== undefined) return queued;
return initialValue ?? "";
}),
confirm: vi.fn(async () => {
const queued = state.confirmQueue.shift();
if (queued !== undefined) return queued;
return true;
}),
multiselect: vi.fn(async ({ options }: any) => {
const queued = state.multiselectQueue.shift();
if (queued !== undefined) return queued;
return options.map((o: any) => o.value);
}),
isCancel: vi.fn((v: any) => v === CANCEL),
};
const embedded = {
runEmbeddedPiAgent: vi.fn(async ({ prompt }: any) => {
if (String(prompt).includes("Generate a compact questionnaire")) {
return {
payloads: [
{
text: JSON.stringify({
goal: "demo",
questions: state.baseQuestions,
}),
},
],
};
}
if (String(prompt).includes("propose any missing high-signal questions")) {
return {
payloads: [
{
text: JSON.stringify({
goal: "demo",
questions: state.extraQuestions,
}),
},
],
};
}
return {
payloads: [{ text: JSON.stringify({ ok: true }) }],
};
}),
};
return { state, resetQueues, clack, embedded };
});
// Mock clack prompts to simulate TUI interaction.
vi.mock("@clack/prompts", async () => {
return {
confirm: hoisted.clack.confirm,
isCancel: hoisted.clack.isCancel,
select: hoisted.clack.select,
multiselect: hoisted.clack.multiselect,
text: hoisted.clack.text,
};
});
// llm-task extension dynamically imports embedded runner in src-first/dist-fallback form.
vi.mock("../../../src/agents/pi-embedded-runner.js", () => ({
runEmbeddedPiAgent: hoisted.embedded.runEmbeddedPiAgent,
}));
vi.mock("../../../agents/pi-embedded-runner.js", () => ({
runEmbeddedPiAgent: hoisted.embedded.runEmbeddedPiAgent,
}));
let testWorkspaceDir = os.tmpdir();
beforeAll(async () => {
testWorkspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-plan-"));
await fs.writeFile(path.join(testWorkspaceDir, "AGENTS.md"), "# Agents\n", "utf-8");
});
afterAll(async () => {
await fs.rm(testWorkspaceDir, { recursive: true, force: true });
});
beforeEach(() => {
hoisted.resetQueues();
(hoisted.clack.select as any)._chosenOnce = false;
// defaults
hoisted.state.baseQuestions = [
{
id: "budget",
section: "Constraints",
prompt: "Budget?",
kind: "select",
required: true,
options: ["$", "$$"],
},
{
id: "deadline",
section: "Timeline",
prompt: "Deadline?",
kind: "text",
},
];
hoisted.state.extraQuestions = [
{
id: "transport",
section: "Constraints",
prompt: "Preferred transport?",
kind: "multiselect",
required: true,
options: ["Car", "Plane"],
},
];
});
function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial<MsgContext>) {
const ctx = {
Body: commandBody,
CommandBody: commandBody,
CommandSource: "cli",
CommandAuthorized: true,
Provider: "cli",
Surface: "cli",
...ctxOverrides,
} as MsgContext;
const command = buildCommandContext({
ctx,
cfg,
isGroup: false,
triggerBodyNormalized: commandBody.trim().toLowerCase(),
commandAuthorized: true,
});
return {
ctx,
cfg,
command,
directives: parseInlineDirectives(commandBody),
elevated: { enabled: true, allowed: true, failures: [] },
sessionKey: "agent:main:main",
workspaceDir: testWorkspaceDir,
defaultGroupActivation: () => "mention",
resolvedVerboseLevel: "off" as const,
resolvedReasoningLevel: "off" as const,
resolveDefaultThinkingLevel: async () => undefined,
provider: "cli",
model: "test-model",
contextTokens: 0,
isGroup: false,
};
}
async function getLatestPlanDir() {
const plansDir = path.join(testWorkspaceDir, "plans");
const entries = await fs.readdir(plansDir, { withFileTypes: true });
const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
expect(dirs.length).toBeGreaterThan(0);
// Sort for determinism (timestamp prefix in name)
dirs.sort();
return path.join(plansDir, dirs[dirs.length - 1]);
}
describe("/plan TUI", () => {
it("creates a plan directory and writes plan.md + answers.json + questions.json", async () => {
(process.stdin as any).isTTY = true;
(process.stdout as any).isTTY = true;
const cfg = {
commands: { text: true },
agents: { defaults: { model: { primary: "openai/mock-1" } } },
} as ClawdbotConfig;
const result = await handleCommands(buildParams("/plan plan a trip", cfg));
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Plan saved");
const createdDir = await getLatestPlanDir();
const planMd = await fs.readFile(path.join(createdDir, "plan.md"), "utf-8");
const answers = JSON.parse(await fs.readFile(path.join(createdDir, "answers.json"), "utf-8"));
const questions = JSON.parse(
await fs.readFile(path.join(createdDir, "questions.json"), "utf-8"),
);
expect(planMd).toContain("# Plan");
expect(Object.keys(answers).length).toBeGreaterThan(0);
expect(questions.questions.length).toBeGreaterThan(0);
});
it("supports one-time extension at the end and uses multiselect for extra questions", async () => {
(process.stdin as any).isTTY = true;
(process.stdout as any).isTTY = true;
// Ensure we accept the extra questions and multiselect returns both.
hoisted.state.confirmQueue.push(true);
hoisted.state.multiselectQueue.push(["Car", "Plane"]);
const cfg = {
commands: { text: true },
agents: { defaults: { model: { primary: "openai/mock-1" } } },
} as ClawdbotConfig;
const result = await handleCommands(buildParams("/plan plan a trip", cfg));
expect(result.shouldContinue).toBe(false);
const createdDir = await getLatestPlanDir();
const answers = JSON.parse(await fs.readFile(path.join(createdDir, "answers.json"), "utf-8"));
const questions = JSON.parse(
await fs.readFile(path.join(createdDir, "questions.json"), "utf-8"),
);
expect(questions.questions.some((q: any) => q.id === "transport")).toBe(true);
expect(Array.isArray(answers.transport)).toBe(true);
expect(answers.transport).toEqual(["Car", "Plane"]);
expect(hoisted.clack.multiselect).toHaveBeenCalled();
});
it("does not ask extension confirm when extension returns no extra questions", async () => {
(process.stdin as any).isTTY = true;
(process.stdout as any).isTTY = true;
hoisted.state.extraQuestions = [];
const cfg = {
commands: { text: true },
agents: { defaults: { model: { primary: "openai/mock-1" } } },
} as ClawdbotConfig;
const result = await handleCommands(buildParams("/plan plan a trip", cfg));
expect(result.shouldContinue).toBe(false);
// confirm might still be used for other prompts; but in this template it shouldn't.
// We assert it was not called with the extension message.
const confirmCalls = (hoisted.clack.confirm as any).mock.calls as any[];
const extensionCall = confirmCalls.find((c) =>
String(c?.[0]?.message ?? "").includes("tighter the plan"),
);
expect(extensionCall).toBeUndefined();
});
it("dedupes extension questions by id", async () => {
(process.stdin as any).isTTY = true;
(process.stdout as any).isTTY = true;
// Extension repeats an existing id.
hoisted.state.extraQuestions = [
{
id: "budget",
section: "Constraints",
prompt: "Budget again?",
kind: "select",
required: true,
options: ["$"],
},
];
const cfg = {
commands: { text: true },
agents: { defaults: { model: { primary: "openai/mock-1" } } },
} as ClawdbotConfig;
const result = await handleCommands(buildParams("/plan plan a trip", cfg));
expect(result.shouldContinue).toBe(false);
const createdDir = await getLatestPlanDir();
const questions = JSON.parse(
await fs.readFile(path.join(createdDir, "questions.json"), "utf-8"),
);
const ids = questions.questions.map((q: any) => q.id);
const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(ids.length);
});
it("cancels cleanly if plan name prompt is cancelled", async () => {
(process.stdin as any).isTTY = true;
(process.stdout as any).isTTY = true;
hoisted.state.textQueue.push(CANCEL);
const cfg = {
commands: { text: true },
agents: { defaults: { model: { primary: "openai/mock-1" } } },
} as ClawdbotConfig;
const result = await handleCommands(buildParams("/plan plan a trip", cfg));
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Cancelled");
});
it("errors on required multiselect when user selects nothing", async () => {
(process.stdin as any).isTTY = true;
(process.stdout as any).isTTY = true;
// Force extension with a required multiselect and accept it.
hoisted.state.confirmQueue.push(true);
hoisted.state.multiselectQueue.push([]);
const cfg = {
commands: { text: true },
agents: { defaults: { model: { primary: "openai/mock-1" } } },
} as ClawdbotConfig;
const result = await handleCommands(buildParams("/plan plan a trip", cfg));
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Missing required answer");
});
it("does not run TUI handler when not in a TTY", async () => {
(process.stdin as any).isTTY = false;
(process.stdout as any).isTTY = false;
const cfg = {
commands: { text: true },
agents: { defaults: { model: { primary: "openai/mock-1" } } },
} as ClawdbotConfig;
const result = await handleCommands(buildParams("/plan plan a trip", cfg));
expect(result.shouldContinue).toBe(true);
});
});
+491
View File
@@ -0,0 +1,491 @@
import fs from "node:fs/promises";
import path from "node:path";
import { confirm, isCancel, select, text, multiselect } from "@clack/prompts";
import type { ClawdbotPluginApi } from "../../plugins/types.js";
import type { CommandHandler } from "./commands-types.js";
// We import the llm-task tool directly (Clawdbot-native) so standalone TUI runs can
// still use the agent's configured models via the embedded runner.
import { createLlmTaskTool } from "../../../extensions/llm-task/src/llm-task-tool.js";
type QuestionKind = "text" | "select" | "multiselect" | "confirm";
export type QuestionSpec = {
id: string;
section: string;
prompt: string;
kind: QuestionKind;
required?: boolean;
options?: string[];
placeholder?: string;
};
type QuestionSet = {
title?: string;
goal: string;
questions: QuestionSpec[];
};
const QUESTIONS_SCHEMA = {
type: "object",
properties: {
title: { type: "string" },
goal: { type: "string" },
questions: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "string" },
section: { type: "string" },
prompt: { type: "string" },
kind: { type: "string", enum: ["text", "select", "multiselect", "confirm"] },
required: { type: "boolean" },
options: { type: "array", items: { type: "string" } },
placeholder: { type: "string" },
},
required: ["id", "section", "prompt", "kind"],
additionalProperties: false,
},
},
},
required: ["goal", "questions"],
additionalProperties: false,
};
function slugify(input: string): string {
return input
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60);
}
function nowStamp() {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(
d.getMinutes(),
)}`;
}
async function safeReadJson(filePath: string): Promise<any | null> {
try {
const text = await fs.readFile(filePath, "utf-8");
return JSON.parse(text);
} catch (err: any) {
if (err?.code === "ENOENT") return null;
return null;
}
}
async function writeJson(filePath: string, value: any) {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, JSON.stringify(value, null, 2), "utf-8");
}
async function writeTextFile(filePath: string, textContent: string) {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, textContent, "utf-8");
}
function buildLlmQuestionPrompt(goal: string) {
return (
`You are helping a user plan a project. Generate a compact questionnaire grouped into sections.\n` +
`Return JSON matching the provided schema.\n\n` +
`Requirements:\n` +
`- Ask only high-signal questions. Prefer multiple choice when the user can pick from common options.\n` +
`- Use sections like Goals, Constraints, Inputs, Outputs, Timeline, Risks (as appropriate).\n` +
`- Keep it to ~8-15 questions unless the goal clearly needs more.\n` +
`- Use stable ids (snake_case).\n` +
`- Use multiselect when multiple options may apply.\n\n` +
`GOAL:\n${goal}`
);
}
function buildLlmExtendPrompt(goal: string) {
return (
`You are helping a user plan a project.\n` +
`Given the current answers and existing question ids, propose any missing high-signal questions.\n` +
`Return JSON matching the provided schema.\n\n` +
`Rules:\n` +
`- If nothing important is missing, return {"goal": <same>, "questions": []}.\n` +
`- Do not repeat existing question ids.\n` +
`- Keep it short: up to 5 additional questions.\n\n` +
`GOAL:\n${goal}`
);
}
function buildPlanMarkdown(goal: string, answers: Record<string, any>, questions: QuestionSpec[]) {
const byId = new Map(questions.map((q) => [q.id, q] as const));
const sections = new Map<string, Array<{ q: QuestionSpec; a: any }>>();
for (const [id, a] of Object.entries(answers)) {
const q = byId.get(id);
if (!q) continue;
const list = sections.get(q.section) ?? [];
list.push({ q, a });
sections.set(q.section, list);
}
const lines: string[] = [];
lines.push(`# Plan\n\n## Goal\n${goal}\n`);
for (const [section, items] of sections) {
lines.push(`## ${section}`);
for (const { q, a } of items) {
const rendered = Array.isArray(a)
? a.join(", ")
: typeof a === "boolean"
? a
? "yes"
: "no"
: String(a);
lines.push(`- **${q.prompt}**: ${rendered}`);
}
lines.push("");
}
return lines.join("\n").trim() + "\n";
}
function createFakePluginApi(cfg: any, workspaceDir: string): ClawdbotPluginApi {
// Minimal stub for llm-task tool; it only relies on `config` and `pluginConfig`.
return {
id: "local",
name: "local",
source: "internal",
config: cfg,
pluginConfig: {},
runtime: {} as any,
logger: {
info() {},
warn() {},
error() {},
debug() {},
},
registerTool() {},
registerHook() {},
registerHttpHandler() {},
registerChannel() {},
registerGatewayMethod() {},
registerCli() {},
registerService() {},
registerProvider() {},
registerCommand() {},
resolvePath: (input: string) => path.resolve(workspaceDir, input),
on() {},
} as any;
}
export const handlePlanCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) return null;
if (!params.command.commandBodyNormalized.startsWith("/plan")) return null;
// Only implement interactive plan builder in the local CLI.
const isInteractiveCli =
params.ctx.CommandSource === "cli" &&
Boolean(process.stdin.isTTY) &&
Boolean(process.stdout.isTTY);
if (!isInteractiveCli) return null;
const raw =
params.ctx.BodyForCommands ??
params.ctx.CommandBody ??
params.ctx.RawBody ??
params.ctx.BodyStripped ??
params.ctx.Body ??
"/plan";
const goal = raw.replace(/^\s*\/plan\s*/i, "").trim();
if (!goal) {
return {
shouldContinue: false,
reply: { text: "Usage: /plan <goal>" },
};
}
const defaultName = `${nowStamp()}-${slugify(goal.split(/\s+/).slice(0, 6).join(" ")) || "plan"}`;
const chosenName = await text({
message: "Plan name (folder under workspace/plans/)",
initialValue: defaultName,
validate: (v) => (!v?.trim() ? "Name required" : undefined),
});
if (isCancel(chosenName)) {
return { shouldContinue: false, reply: { text: "Cancelled." } };
}
const planName = slugify(String(chosenName).trim()) || defaultName;
const plansDir = path.join(params.workspaceDir, "plans");
const planDir = path.join(plansDir, planName);
await fs.mkdir(planDir, { recursive: true });
const metaPath = path.join(planDir, "meta.json");
const answersPath = path.join(planDir, "answers.json");
const planMdPath = path.join(planDir, "plan.md");
const existingAnswers = (await safeReadJson(answersPath)) ?? {};
await writeJson(metaPath, {
name: planName,
goal,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
// Ask the LLM to generate the question set.
const api = createFakePluginApi(params.cfg, params.workspaceDir);
const tool = createLlmTaskTool(api);
const llmResult = await tool.execute("llm-task", {
prompt: buildLlmQuestionPrompt(goal),
input: { goal, existingAnswers },
schema: QUESTIONS_SCHEMA,
});
const questionSet = (llmResult as any).details?.json as QuestionSet;
const questions: QuestionSpec[] = Array.isArray(questionSet?.questions)
? questionSet.questions
: [];
if (questions.length === 0) {
return {
shouldContinue: false,
reply: { text: "No questions generated (unexpected)." },
};
}
const questionsPath = path.join(planDir, "questions.json");
await writeJson(questionsPath, { goal, questions });
// Group by section.
const sectionOrder: string[] = [];
const bySection = new Map<string, QuestionSpec[]>();
for (const q of questions) {
const section = q.section?.trim() || "General";
if (!bySection.has(section)) sectionOrder.push(section);
const list = bySection.get(section) ?? [];
list.push(q);
bySection.set(section, list);
}
const answers: Record<string, any> = { ...existingAnswers };
// Iterate sections with a review loop.
while (true) {
const section = await select({
message: "Choose a section",
options: [
...sectionOrder.map((s) => ({ label: s, value: s })),
{ label: "Review + finalize", value: "__review" },
],
});
if (isCancel(section)) {
return { shouldContinue: false, reply: { text: "Cancelled." } };
}
if (section === "__review") break;
const qs = bySection.get(String(section)) ?? [];
for (const q of qs) {
const existing = answers[q.id];
const required = Boolean(q.required);
if (q.kind === "confirm") {
const res = await confirm({ message: q.prompt, initialValue: Boolean(existing ?? false) });
if (isCancel(res)) return { shouldContinue: false, reply: { text: "Cancelled." } };
answers[q.id] = Boolean(res);
} else if (q.kind === "select") {
const opts = (q.options ?? []).map((o) => ({ label: o, value: o }));
if (opts.length === 0) {
const res = await text({
message: q.prompt,
initialValue: existing ? String(existing) : "",
});
if (isCancel(res)) return { shouldContinue: false, reply: { text: "Cancelled." } };
const v = String(res).trim();
if (required && !v)
return { shouldContinue: false, reply: { text: `Missing required answer: ${q.id}` } };
answers[q.id] = v;
} else {
const res = await select({ message: q.prompt, options: opts });
if (isCancel(res)) return { shouldContinue: false, reply: { text: "Cancelled." } };
answers[q.id] = res;
}
} else if (q.kind === "multiselect") {
const opts = (q.options ?? []).map((o) => ({ label: o, value: o }));
if (opts.length === 0) {
const res = await text({
message: q.prompt,
initialValue: Array.isArray(existing) ? existing.join(", ") : "",
placeholder: q.placeholder,
});
if (isCancel(res)) return { shouldContinue: false, reply: { text: "Cancelled." } };
const arr = String(res)
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (required && arr.length === 0) {
return { shouldContinue: false, reply: { text: `Missing required answer: ${q.id}` } };
}
answers[q.id] = arr;
} else {
const res = await multiselect({
message: q.prompt,
options: opts,
required: required ? true : false,
} as any);
if (isCancel(res)) return { shouldContinue: false, reply: { text: "Cancelled." } };
const arr = Array.isArray(res) ? res : [];
if (required && arr.length === 0) {
return { shouldContinue: false, reply: { text: `Missing required answer: ${q.id}` } };
}
answers[q.id] = arr;
}
} else {
const res = await text({
message: q.prompt,
initialValue: existing ? String(existing) : "",
placeholder: q.placeholder,
});
if (isCancel(res)) return { shouldContinue: false, reply: { text: "Cancelled." } };
const v = String(res).trim();
if (required && !v) {
return { shouldContinue: false, reply: { text: `Missing required answer: ${q.id}` } };
}
answers[q.id] = v;
}
await writeJson(answersPath, answers);
}
}
// One-time dynamic extension at the end.
const existingIds = new Set(questions.map((q) => q.id));
const extend = await tool.execute("llm-task", {
prompt: buildLlmExtendPrompt(goal),
input: {
goal,
answers,
existingQuestionIds: Array.from(existingIds),
},
schema: QUESTIONS_SCHEMA,
});
const extendSet = (extend as any).details?.json as QuestionSet;
const extraQuestions: QuestionSpec[] = Array.isArray(extendSet?.questions)
? extendSet.questions
: [];
const filteredExtras = extraQuestions.filter((q) => q && q.id && !existingIds.has(q.id));
if (filteredExtras.length > 0) {
const ok = await confirm({
message: `I have ${filteredExtras.length} more question(s) to tighten the plan. Add them?`,
});
if (!isCancel(ok) && ok === true) {
for (const q of filteredExtras) {
const section = q.section?.trim() || "General";
if (!bySection.has(section)) {
sectionOrder.push(section);
bySection.set(section, []);
}
bySection.get(section)!.push(q);
questions.push(q);
existingIds.add(q.id);
}
// Ask only the newly added questions (once).
for (const q of filteredExtras) {
const existing = answers[q.id];
const required = Boolean(q.required);
if (q.kind === "confirm") {
const res = await confirm({
message: q.prompt,
initialValue: Boolean(existing ?? false),
});
if (isCancel(res)) return { shouldContinue: false, reply: { text: "Cancelled." } };
answers[q.id] = Boolean(res);
} else if (q.kind === "select") {
const opts = (q.options ?? []).map((o) => ({ label: o, value: o }));
if (opts.length === 0) {
const res = await text({
message: q.prompt,
initialValue: existing ? String(existing) : "",
});
if (isCancel(res)) return { shouldContinue: false, reply: { text: "Cancelled." } };
const v = String(res).trim();
if (required && !v)
return { shouldContinue: false, reply: { text: `Missing required answer: ${q.id}` } };
answers[q.id] = v;
} else {
const res = await select({ message: q.prompt, options: opts });
if (isCancel(res)) return { shouldContinue: false, reply: { text: "Cancelled." } };
answers[q.id] = res;
}
} else if (q.kind === "multiselect") {
const opts = (q.options ?? []).map((o) => ({ label: o, value: o }));
if (opts.length === 0) {
const res = await text({
message: q.prompt,
initialValue: Array.isArray(existing) ? existing.join(", ") : "",
});
if (isCancel(res)) return { shouldContinue: false, reply: { text: "Cancelled." } };
const arr = String(res)
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (required && arr.length === 0)
return { shouldContinue: false, reply: { text: `Missing required answer: ${q.id}` } };
answers[q.id] = arr;
} else {
const res = await multiselect({
message: q.prompt,
options: opts,
required: required ? true : false,
} as any);
if (isCancel(res)) return { shouldContinue: false, reply: { text: "Cancelled." } };
const arr = Array.isArray(res) ? res : [];
if (required && arr.length === 0)
return { shouldContinue: false, reply: { text: `Missing required answer: ${q.id}` } };
answers[q.id] = arr;
}
} else {
const res = await text({
message: q.prompt,
initialValue: existing ? String(existing) : "",
});
if (isCancel(res)) return { shouldContinue: false, reply: { text: "Cancelled." } };
const v = String(res).trim();
if (required && !v)
return { shouldContinue: false, reply: { text: `Missing required answer: ${q.id}` } };
answers[q.id] = v;
}
await writeJson(answersPath, answers);
}
await writeJson(questionsPath, { goal, questions });
}
}
const md = buildPlanMarkdown(goal, answers, questions);
await writeTextFile(planMdPath, md);
await writeJson(metaPath, {
name: planName,
goal,
createdAt: (await safeReadJson(metaPath))?.createdAt ?? new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
return {
shouldContinue: false,
reply: {
text:
`✅ Plan saved: ${planName}\n` +
`- ${path.relative(params.workspaceDir, planMdPath)}\n` +
`- ${path.relative(params.workspaceDir, answersPath)}\n\n` +
`Use /plans show ${planName} to view it.`,
},
};
};
+256
View File
@@ -0,0 +1,256 @@
import fs from "node:fs/promises";
import path from "node:path";
import { confirm, isCancel, select, text } from "@clack/prompts";
import type { CommandHandler } from "./commands-types.js";
type PlansArgs = {
action: "list" | "show" | "rename" | "trash" | "help";
a?: string;
b?: string;
};
function parsePlansArgs(raw: string): PlansArgs {
const trimmed = raw.trim();
const parts = trimmed.split(/\s+/).filter(Boolean);
const cmd = parts[0]?.toLowerCase();
if (cmd !== "/plans") return { action: "help" };
const sub = parts[1]?.toLowerCase();
if (!sub || sub === "list") return { action: "list" };
if (sub === "help") return { action: "help" };
if (sub === "show") return { action: "show", a: parts[2] };
if (sub === "rename") return { action: "rename", a: parts[2], b: parts[3] };
if (sub === "trash" || sub === "delete" || sub === "rm") return { action: "trash", a: parts[2] };
// Default: treat unknown as help.
return { action: "help" };
}
async function listPlanDirs(plansDir: string): Promise<string[]> {
try {
const entries = await fs.readdir(plansDir, { withFileTypes: true });
return entries
.filter((e) => e.isDirectory())
.map((e) => e.name)
.filter((name) => !name.startsWith("."))
.sort();
} catch (err: any) {
if (err?.code === "ENOENT") return [];
throw err;
}
}
async function readFirstExisting(paths: string[]): Promise<string | null> {
for (const p of paths) {
try {
const text = await fs.readFile(p, "utf-8");
return text;
} catch (err: any) {
if (err?.code === "ENOENT") continue;
throw err;
}
}
return null;
}
export const handlePlansCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) return null;
// Match on normalized command body
if (!params.command.commandBodyNormalized.startsWith("/plans")) return null;
const raw =
params.ctx.BodyForCommands ??
params.ctx.CommandBody ??
params.ctx.RawBody ??
params.ctx.BodyStripped ??
params.ctx.Body ??
"/plans";
const args = parsePlansArgs(raw);
const plansDir = path.join(params.workspaceDir, "plans");
const isInteractiveCli =
params.ctx.CommandSource === "cli" &&
Boolean(process.stdin.isTTY) &&
Boolean(process.stdout.isTTY);
if (args.action === "help") {
return {
shouldContinue: false,
reply: {
text:
"Usage:\n" +
" /plans list\n" +
" /plans show <name>\n" +
" /plans rename <old> <new>\n" +
" /plans trash <name>\n\n" +
"TUI:\n" +
" /plans (with no args) opens an interactive picker in the CLI.\n\n" +
"Notes:\n" +
"- Plans are stored under workspace/plans/.\n" +
"- Use /plan <goal> to start a new plan; /plans helps manage saved plan folders.",
},
};
}
if (args.action === "list") {
const names = await listPlanDirs(plansDir);
if (names.length === 0) {
return {
shouldContinue: false,
reply: { text: "No plans found yet. Create one with: /plan <goal>" },
};
}
// Interactive picker only when running in the CLI TUI.
// For chat channels (Discord/Telegram/etc.), return plain text.
const wantsInteractive =
isInteractiveCli && (raw.trim() === "/plans" || raw.trim() === "/plans list");
if (wantsInteractive) {
const selected = await select({
message: "Select a plan",
options: names
.slice()
.reverse()
.map((n) => ({ label: n, value: n })),
});
if (isCancel(selected)) {
return { shouldContinue: false, reply: { text: "Cancelled." } };
}
const planName = String(selected);
const action = await select({
message: `Action for ${planName}`,
options: [
{ label: "Show", value: "show" },
{ label: "Rename", value: "rename" },
{ label: "Trash", value: "trash" },
],
});
if (isCancel(action)) {
return { shouldContinue: false, reply: { text: "Cancelled." } };
}
if (action === "show") {
const planDir = path.join(plansDir, planName);
const planMd = await readFirstExisting([
path.join(planDir, "plan.md"),
path.join(planDir, "README.md"),
]);
if (!planMd) {
return {
shouldContinue: false,
reply: { text: `No plan.md found for '${planName}'. Looked in: ${planDir}` },
};
}
const trimmed = planMd.trim();
const preview =
trimmed.length > 6000 ? `${trimmed.slice(0, 6000)}\n\n…(truncated)` : trimmed;
return { shouldContinue: false, reply: { text: `Plan: ${planName}\n\n${preview}` } };
}
if (action === "rename") {
const next = await text({
message: `Rename '${planName}' to:`,
initialValue: planName,
validate: (v) => (!v?.trim() ? "Name required" : undefined),
});
if (isCancel(next)) return { shouldContinue: false, reply: { text: "Cancelled." } };
const toName = String(next).trim();
if (toName && toName !== planName) {
await fs.rename(path.join(plansDir, planName), path.join(plansDir, toName));
}
return {
shouldContinue: false,
reply: { text: `Renamed plan '${planName}' → '${toName}'.` },
};
}
if (action === "trash") {
const ok = await confirm({ message: `Move '${planName}' to trash?` });
if (isCancel(ok) || ok === false) {
return { shouldContinue: false, reply: { text: "Cancelled." } };
}
const fromDir = path.join(plansDir, planName);
const trashDir = path.join(plansDir, ".trash");
await fs.mkdir(trashDir, { recursive: true });
const stamped = `${planName}__${new Date().toISOString().replace(/[:.]/g, "-")}`;
const toDir = path.join(trashDir, stamped);
await fs.rename(fromDir, toDir);
return { shouldContinue: false, reply: { text: `Moved plan '${planName}' to trash.` } };
}
}
const lines = ["Plans:", ...names.map((n) => `- ${n}`), "", "Tip: /plans show <name>"];
return { shouldContinue: false, reply: { text: lines.join("\n") } };
}
if (args.action === "show") {
const name = String(args.a ?? "").trim();
if (!name) {
return { shouldContinue: false, reply: { text: "Usage: /plans show <name>" } };
}
const planDir = path.join(plansDir, name);
const planMd = await readFirstExisting([
path.join(planDir, "plan.md"),
path.join(planDir, "README.md"),
]);
if (!planMd) {
return {
shouldContinue: false,
reply: { text: `No plan.md found for '${name}'. Looked in: ${planDir}` },
};
}
const trimmed = planMd.trim();
const preview = trimmed.length > 1800 ? `${trimmed.slice(0, 1800)}\n\n…(truncated)` : trimmed;
return {
shouldContinue: false,
reply: { text: `Plan: ${name}\n\n${preview}` },
};
}
if (args.action === "rename") {
const from = String(args.a ?? "").trim();
const to = String(args.b ?? "").trim();
if (!from || !to) {
return { shouldContinue: false, reply: { text: "Usage: /plans rename <old> <new>" } };
}
const fromDir = path.join(plansDir, from);
const toDir = path.join(plansDir, to);
await fs.mkdir(plansDir, { recursive: true });
await fs.rename(fromDir, toDir);
return {
shouldContinue: false,
reply: { text: `Renamed plan '${from}' → '${to}'.` },
};
}
if (args.action === "trash") {
const name = String(args.a ?? "").trim();
if (!name) {
return { shouldContinue: false, reply: { text: "Usage: /plans trash <name>" } };
}
const fromDir = path.join(plansDir, name);
const trashDir = path.join(plansDir, ".trash");
await fs.mkdir(trashDir, { recursive: true });
const stamped = `${name}__${new Date().toISOString().replace(/[:.]/g, "-")}`;
const toDir = path.join(trashDir, stamped);
await fs.rename(fromDir, toDir);
return {
shouldContinue: false,
reply: { text: `Moved plan '${name}' to trash.` },
};
}
return null;
};
+38
View File
@@ -132,6 +132,44 @@ describe("handleCommands gating", () => {
});
});
describe("/plans", () => {
it("lists plan directories", async () => {
const cfg = {
commands: { text: true },
whatsapp: { allowFrom: ["*"] },
} as ClawdbotConfig;
const plansDir = path.join(testWorkspaceDir, "plans");
await fs.mkdir(path.join(plansDir, "2026-01-24-trip"), { recursive: true });
await fs.writeFile(
path.join(plansDir, "2026-01-24-trip", "plan.md"),
"# Trip Plan\n\nDo stuff\n",
"utf-8",
);
const params = buildParams("/plans list", cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("2026-01-24-trip");
});
it("shows plan.md", async () => {
const cfg = {
commands: { text: true },
whatsapp: { allowFrom: ["*"] },
} as ClawdbotConfig;
const plansDir = path.join(testWorkspaceDir, "plans");
await fs.mkdir(path.join(plansDir, "demo"), { recursive: true });
await fs.writeFile(path.join(plansDir, "demo", "plan.md"), "Hello plan", "utf-8");
const params = buildParams("/plans show demo", cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Hello plan");
});
});
describe("handleCommands bash alias", () => {
it("routes !poll through the /bash handler", async () => {
resetBashChatCommandForTests();
@@ -0,0 +1,81 @@
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../../config/config.js";
import { telegramOutbound } from "./telegram.js";
describe("telegramOutbound.sendPayload", () => {
it("sends text payload with buttons", async () => {
const sendTelegram = vi.fn(async () => ({ messageId: "m1", chatId: "c1" }));
const result = await telegramOutbound.sendPayload?.({
cfg: {} as ClawdbotConfig,
to: "telegram:123",
text: "ignored",
payload: {
text: "Hello",
channelData: {
telegram: {
buttons: [[{ text: "Option", callback_data: "/option" }]],
},
},
},
deps: { sendTelegram },
});
expect(sendTelegram).toHaveBeenCalledTimes(1);
expect(sendTelegram).toHaveBeenCalledWith(
"telegram:123",
"Hello",
expect.objectContaining({
buttons: [[{ text: "Option", callback_data: "/option" }]],
textMode: "html",
}),
);
expect(result).toEqual({ channel: "telegram", messageId: "m1", chatId: "c1" });
});
it("sends media payloads and attaches buttons only to first", async () => {
const sendTelegram = vi
.fn()
.mockResolvedValueOnce({ messageId: "m1", chatId: "c1" })
.mockResolvedValueOnce({ messageId: "m2", chatId: "c1" });
const result = await telegramOutbound.sendPayload?.({
cfg: {} as ClawdbotConfig,
to: "telegram:123",
text: "ignored",
payload: {
text: "Caption",
mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"],
channelData: {
telegram: {
buttons: [[{ text: "Go", callback_data: "/go" }]],
},
},
},
deps: { sendTelegram },
});
expect(sendTelegram).toHaveBeenCalledTimes(2);
expect(sendTelegram).toHaveBeenNthCalledWith(
1,
"telegram:123",
"Caption",
expect.objectContaining({
mediaUrl: "https://example.com/a.png",
buttons: [[{ text: "Go", callback_data: "/go" }]],
}),
);
const secondOpts = sendTelegram.mock.calls[1]?.[2] as { buttons?: unknown } | undefined;
expect(sendTelegram).toHaveBeenNthCalledWith(
2,
"telegram:123",
"",
expect.objectContaining({
mediaUrl: "https://example.com/b.png",
}),
);
expect(secondOpts?.buttons).toBeUndefined();
expect(result).toEqual({ channel: "telegram", messageId: "m2", chatId: "c1" });
});
});
+43
View File
@@ -18,6 +18,7 @@ function parseThreadId(threadId?: string | number | null) {
const parsed = Number.parseInt(trimmed, 10);
return Number.isFinite(parsed) ? parsed : undefined;
}
export const telegramOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: markdownToTelegramHtmlChunks,
@@ -50,4 +51,46 @@ export const telegramOutbound: ChannelOutboundAdapter = {
});
return { channel: "telegram", ...result };
},
sendPayload: async ({ to, payload, accountId, deps, replyToId, threadId }) => {
const send = deps?.sendTelegram ?? sendMessageTelegram;
const replyToMessageId = parseReplyToMessageId(replyToId);
const messageThreadId = parseThreadId(threadId);
const telegramData = payload.channelData?.telegram as
| { buttons?: Array<Array<{ text: string; callback_data: string }>> }
| undefined;
const text = payload.text ?? "";
const mediaUrls = payload.mediaUrls?.length
? payload.mediaUrls
: payload.mediaUrl
? [payload.mediaUrl]
: [];
const baseOpts = {
verbose: false,
textMode: "html" as const,
messageThreadId,
replyToMessageId,
accountId: accountId ?? undefined,
};
if (mediaUrls.length === 0) {
const result = await send(to, text, {
...baseOpts,
buttons: telegramData?.buttons,
});
return { channel: "telegram", ...result };
}
// Telegram allows reply_markup on media; attach buttons only to first send.
let finalResult: Awaited<ReturnType<typeof send>> | undefined;
for (let i = 0; i < mediaUrls.length; i += 1) {
const mediaUrl = mediaUrls[i];
const isFirst = i === 0;
finalResult = await send(to, isFirst ? text : "", {
...baseOpts,
mediaUrl,
...(isFirst ? { buttons: telegramData?.buttons } : {}),
});
}
return { channel: "telegram", ...(finalResult ?? { messageId: "unknown", chatId: to }) };
},
};
+1 -1
View File
@@ -78,7 +78,7 @@ export function registerOnboardCommand(program: Command) {
.option("--opencode-zen-api-key <key>", "OpenCode Zen API key")
.option("--gateway-port <port>", "Gateway port")
.option("--gateway-bind <mode>", "Gateway bind: loopback|tailnet|lan|auto|custom")
.option("--gateway-auth <mode>", "Gateway auth: off|token|password")
.option("--gateway-auth <mode>", "Gateway auth: token|password")
.option("--gateway-token <token>", "Gateway token (token auth)")
.option("--gateway-password <password>", "Gateway password (password auth)")
.option("--remote-url <url>", "Remote Gateway WebSocket URL")
+6 -14
View File
@@ -3,26 +3,18 @@ import { describe, expect, it } from "vitest";
import { buildGatewayAuthConfig } from "./configure.js";
describe("buildGatewayAuthConfig", () => {
it("clears token/password when auth is off", () => {
const result = buildGatewayAuthConfig({
existing: { mode: "token", token: "abc", password: "secret" },
mode: "off",
});
expect(result).toBeUndefined();
});
it("preserves allowTailscale when auth is off", () => {
it("preserves allowTailscale when switching to token", () => {
const result = buildGatewayAuthConfig({
existing: {
mode: "token",
token: "abc",
mode: "password",
password: "secret",
allowTailscale: true,
},
mode: "off",
mode: "token",
token: "abc",
});
expect(result).toEqual({ allowTailscale: true });
expect(result).toEqual({ mode: "token", token: "abc", allowTailscale: true });
});
it("drops password when switching to token", () => {
+1 -4
View File
@@ -12,7 +12,7 @@ import {
promptModelAllowlist,
} from "./model-picker.js";
type GatewayAuthChoice = "off" | "token" | "password";
type GatewayAuthChoice = "token" | "password";
const ANTHROPIC_OAUTH_MODEL_KEYS = [
"anthropic/claude-opus-4-5",
@@ -30,9 +30,6 @@ export function buildGatewayAuthConfig(params: {
const base: GatewayAuthConfig = {};
if (typeof allowTailscale === "boolean") base.allowTailscale = allowTailscale;
if (params.mode === "off") {
return Object.keys(base).length > 0 ? base : undefined;
}
if (params.mode === "token") {
return { ...base, mode: "token", token: params.token };
}
+1 -11
View File
@@ -7,7 +7,7 @@ import { buildGatewayAuthConfig } from "./configure.gateway-auth.js";
import { confirm, select, text } from "./configure.shared.js";
import { guardCancel, randomToken } from "./onboard-helpers.js";
type GatewayAuthChoice = "off" | "token" | "password";
type GatewayAuthChoice = "token" | "password";
export async function promptGatewayConfig(
cfg: ClawdbotConfig,
@@ -91,11 +91,6 @@ export async function promptGatewayConfig(
await select({
message: "Gateway auth",
options: [
{
value: "off",
label: "Off (loopback only)",
hint: "Not recommended unless you fully trust local processes",
},
{ value: "token", label: "Token", hint: "Recommended default" },
{ value: "password", label: "Password" },
],
@@ -165,11 +160,6 @@ export async function promptGatewayConfig(
bind = "loopback";
}
if (authMode === "off" && bind !== "loopback") {
note("Non-loopback bind requires auth. Switching to token auth.", "Note");
authMode = "token";
}
if (tailscaleMode === "funnel" && authMode !== "password") {
note("Tailscale funnel requires password auth.", "Note");
authMode = "password";
@@ -210,7 +210,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
await fs.rm(stateDir, { recursive: true, force: true });
}, 60_000);
it("auto-enables token auth when binding LAN and persists the token", async () => {
it("auto-generates token auth when binding LAN and persists the token", async () => {
if (process.platform === "win32") {
// Windows runner occasionally drops the temp config write in this flow; skip to keep CI green.
return;
@@ -242,7 +242,6 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
installDaemon: false,
gatewayPort: port,
gatewayBind: "lan",
gatewayAuth: "off",
},
runtime,
);
@@ -28,16 +28,20 @@ export function applyNonInteractiveGatewayConfig(params: {
const port = hasGatewayPort ? (opts.gatewayPort as number) : params.defaultPort;
let bind = opts.gatewayBind ?? "loopback";
let authMode = opts.gatewayAuth ?? "token";
const authModeRaw = opts.gatewayAuth ?? "token";
if (authModeRaw !== "token" && authModeRaw !== "password") {
runtime.error("Invalid --gateway-auth (use token|password).");
runtime.exit(1);
return null;
}
let authMode = authModeRaw;
const tailscaleMode = opts.tailscale ?? "off";
const tailscaleResetOnExit = Boolean(opts.tailscaleResetOnExit);
// Tighten config to safe combos:
// - If Tailscale is on, force loopback bind (the tunnel handles external access).
// - If binding beyond loopback, disallow auth=off.
// - If using Tailscale Funnel, require password auth.
if (tailscaleMode !== "off" && bind !== "loopback") bind = "loopback";
if (authMode === "off" && bind !== "loopback") authMode = "token";
if (tailscaleMode === "funnel" && authMode !== "password") authMode = "password";
let nextConfig = params.nextConfig;
+1 -1
View File
@@ -32,7 +32,7 @@ export type AuthChoice =
| "copilot-proxy"
| "qwen-portal"
| "skip";
export type GatewayAuthChoice = "off" | "token" | "password";
export type GatewayAuthChoice = "token" | "password";
export type ResetScope = "config" | "config+creds+sessions" | "full";
export type GatewayBind = "loopback" | "lan" | "auto" | "custom" | "tailnet";
export type TailscaleMode = "off" | "serve" | "funnel";
+3
View File
@@ -199,6 +199,7 @@ const FIELD_LABELS: Record<string, string> = {
"tools.web.fetch.userAgent": "Web Fetch User-Agent",
"gateway.controlUi.basePath": "Control UI Base Path",
"gateway.controlUi.allowInsecureAuth": "Allow Insecure Control UI Auth",
"gateway.controlUi.dangerouslyDisableDeviceAuth": "Dangerously Disable Control UI Device Auth",
"gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint",
"gateway.reload.mode": "Config Reload Mode",
"gateway.reload.debounceMs": "Config Reload Debounce (ms)",
@@ -381,6 +382,8 @@ const FIELD_HELP: Record<string, string> = {
"Optional URL prefix where the Control UI is served (e.g. /clawdbot).",
"gateway.controlUi.allowInsecureAuth":
"Allow Control UI auth over insecure HTTP (token-only; not recommended).",
"gateway.controlUi.dangerouslyDisableDeviceAuth":
"DANGEROUS. Disable Control UI device identity checks (token/password only).",
"gateway.http.endpoints.chatCompletions.enabled":
"Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).",
"gateway.reload.mode": 'Hot reload strategy for config changes ("hybrid" recommended).',
+2
View File
@@ -66,6 +66,8 @@ export type GatewayControlUiConfig = {
basePath?: string;
/** Allow token-only auth over insecure HTTP (default: false). */
allowInsecureAuth?: boolean;
/** DANGEROUS: Disable device identity checks for the Control UI (default: false). */
dangerouslyDisableDeviceAuth?: boolean;
};
export type GatewayAuthMode = "token" | "password";
+1
View File
@@ -319,6 +319,7 @@ export const ClawdbotSchema = z
enabled: z.boolean().optional(),
basePath: z.string().optional(),
allowInsecureAuth: z.boolean().optional(),
dangerouslyDisableDeviceAuth: z.boolean().optional(),
})
.strict()
.optional(),
+47
View File
@@ -352,6 +352,53 @@ describe("gateway server auth/connect", () => {
}
});
test("allows control ui with stale device identity when device auth is disabled", async () => {
testState.gatewayControlUi = { dangerouslyDisableDeviceAuth: true };
const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
process.env.CLAWDBOT_GATEWAY_TOKEN = "secret";
const port = await getFreePort();
const server = await startGatewayServer(port);
const ws = await openWs(port);
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } =
await import("../infra/device-identity.js");
const identity = loadOrCreateDeviceIdentity();
const signedAtMs = Date.now() - 60 * 60 * 1000;
const payload = buildDeviceAuthPayload({
deviceId: identity.deviceId,
clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI,
clientMode: GATEWAY_CLIENT_MODES.WEBCHAT,
role: "operator",
scopes: [],
signedAtMs,
token: "secret",
});
const device = {
id: identity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
signature: signDevicePayload(identity.privateKeyPem, payload),
signedAt: signedAtMs,
};
const res = await connectReq(ws, {
token: "secret",
device,
client: {
id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
version: "1.0.0",
platform: "web",
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
},
});
expect(res.ok).toBe(true);
expect((res.payload as { auth?: unknown } | undefined)?.auth).toBeUndefined();
ws.close();
await server.close();
if (prevToken === undefined) {
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
} else {
process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
}
});
test("rejects proxied connections without auth when proxy headers are untrusted", async () => {
testState.gatewayAuth = { mode: "none" };
const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
@@ -335,7 +335,7 @@ export function attachGatewayWsMessageHandler(params: {
connectParams.role = role;
connectParams.scopes = scopes;
const device = connectParams.device;
const deviceRaw = connectParams.device;
let devicePublicKey: string | null = null;
const hasTokenAuth = Boolean(connectParams.auth?.token);
const hasPasswordAuth = Boolean(connectParams.auth?.password);
@@ -343,6 +343,10 @@ export function attachGatewayWsMessageHandler(params: {
const isControlUi = connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI;
const allowInsecureControlUi =
isControlUi && configSnapshot.gateway?.controlUi?.allowInsecureAuth === true;
const disableControlUiDeviceAuth =
isControlUi && configSnapshot.gateway?.controlUi?.dangerouslyDisableDeviceAuth === true;
const allowControlUiBypass = allowInsecureControlUi || disableControlUiDeviceAuth;
const device = disableControlUiDeviceAuth ? null : deviceRaw;
if (hasUntrustedProxyHeaders && resolvedAuth.mode === "none") {
setHandshakeState("failed");
setCloseCause("proxy-auth-required", {
@@ -370,9 +374,9 @@ export function attachGatewayWsMessageHandler(params: {
}
if (!device) {
const canSkipDevice = allowInsecureControlUi ? hasSharedAuth : hasTokenAuth;
const canSkipDevice = allowControlUiBypass ? hasSharedAuth : hasTokenAuth;
if (isControlUi && !allowInsecureControlUi) {
if (isControlUi && !allowControlUiBypass) {
const errorMessage = "control ui requires HTTPS or localhost (secure context)";
setHandshakeState("failed");
setCloseCause("control-ui-insecure-auth", {
@@ -615,7 +619,7 @@ export function attachGatewayWsMessageHandler(params: {
return;
}
const skipPairing = allowInsecureControlUi && hasSharedAuth;
const skipPairing = allowControlUiBypass && hasSharedAuth;
if (device && devicePublicKey && !skipPairing) {
const requirePairing = async (reason: string, _paired?: { deviceId: string }) => {
const pairing = await requestDevicePairing({
@@ -736,9 +740,7 @@ export function attachGatewayWsMessageHandler(params: {
const shouldTrackPresence = !isGatewayCliClient(connectParams.client);
const clientId = connectParams.client.id;
const instanceId = connectParams.client.instanceId;
const presenceKey = shouldTrackPresence
? (connectParams.device?.id ?? instanceId ?? connId)
: undefined;
const presenceKey = shouldTrackPresence ? (device?.id ?? instanceId ?? connId) : undefined;
logWs("in", "connect", {
connId,
@@ -766,10 +768,10 @@ export function attachGatewayWsMessageHandler(params: {
deviceFamily: connectParams.client.deviceFamily,
modelIdentifier: connectParams.client.modelIdentifier,
mode: connectParams.client.mode,
deviceId: connectParams.device?.id,
deviceId: device?.id,
roles: [role],
scopes,
instanceId: connectParams.device?.id ?? instanceId,
instanceId: device?.id ?? instanceId,
reason: "connect",
});
incrementPresenceVersion();
+5
View File
@@ -63,6 +63,11 @@ export type {
ClawdbotPluginService,
ClawdbotPluginServiceContext,
} from "../plugins/types.js";
export type {
GatewayRequestHandler,
GatewayRequestHandlerOptions,
RespondFn,
} from "../gateway/server-methods/types.js";
export type { PluginRuntime } from "../plugins/runtime/types.js";
export { normalizePluginHttpPath } from "../plugins/http-path.js";
export { registerPluginHttpRoute } from "../plugins/http-registry.js";
+24 -1
View File
@@ -293,7 +293,30 @@ describe("security audit", () => {
expect.arrayContaining([
expect.objectContaining({
checkId: "gateway.control_ui.insecure_auth",
severity: "warn",
severity: "critical",
}),
]),
);
});
it("warns when control UI device auth is disabled", async () => {
const cfg: ClawdbotConfig = {
gateway: {
controlUi: { dangerouslyDisableDeviceAuth: true },
},
};
const res = await runSecurityAudit({
config: cfg,
includeFilesystem: false,
includeChannelSecurity: false,
});
expect(res.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "gateway.control_ui.device_auth_disabled",
severity: "critical",
}),
]),
);
+12 -1
View File
@@ -274,7 +274,7 @@ function collectGatewayConfigFindings(cfg: ClawdbotConfig): SecurityAuditFinding
if (cfg.gateway?.controlUi?.allowInsecureAuth === true) {
findings.push({
checkId: "gateway.control_ui.insecure_auth",
severity: "warn",
severity: "critical",
title: "Control UI allows insecure HTTP auth",
detail:
"gateway.controlUi.allowInsecureAuth=true allows token-only auth over HTTP and skips device identity.",
@@ -282,6 +282,17 @@ function collectGatewayConfigFindings(cfg: ClawdbotConfig): SecurityAuditFinding
});
}
if (cfg.gateway?.controlUi?.dangerouslyDisableDeviceAuth === true) {
findings.push({
checkId: "gateway.control_ui.device_auth_disabled",
severity: "critical",
title: "DANGEROUS: Control UI device auth disabled",
detail:
"gateway.controlUi.dangerouslyDisableDeviceAuth=true disables device identity checks for the Control UI.",
remediation: "Disable it unless you are in a short-lived break-glass scenario.",
});
}
const token =
typeof auth.token === "string" && auth.token.trim().length > 0 ? auth.token.trim() : null;
if (auth.mode === "token" && token && token.length < 24) {
+278
View File
@@ -0,0 +1,278 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// Store original fetch
const originalFetch = globalThis.fetch;
let mockFetch: ReturnType<typeof vi.fn>;
describe("fetchWithSlackAuth", () => {
beforeEach(() => {
// Create a new mock for each test
mockFetch = vi.fn();
globalThis.fetch = mockFetch as typeof fetch;
});
afterEach(() => {
// Restore original fetch
globalThis.fetch = originalFetch;
vi.resetModules();
});
it("sends Authorization header on initial request with manual redirect", async () => {
// Import after mocking fetch
const { fetchWithSlackAuth } = await import("./media.js");
// Simulate direct 200 response (no redirect)
const mockResponse = new Response(Buffer.from("image data"), {
status: 200,
headers: { "content-type": "image/jpeg" },
});
mockFetch.mockResolvedValueOnce(mockResponse);
const result = await fetchWithSlackAuth("https://files.slack.com/test.jpg", "xoxb-test-token");
expect(result).toBe(mockResponse);
// Verify fetch was called with correct params
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch).toHaveBeenCalledWith("https://files.slack.com/test.jpg", {
headers: { Authorization: "Bearer xoxb-test-token" },
redirect: "manual",
});
});
it("follows redirects without Authorization header", async () => {
const { fetchWithSlackAuth } = await import("./media.js");
// First call: redirect response from Slack
const redirectResponse = new Response(null, {
status: 302,
headers: { location: "https://cdn.slack-edge.com/presigned-url?sig=abc123" },
});
// Second call: actual file content from CDN
const fileResponse = new Response(Buffer.from("actual image data"), {
status: 200,
headers: { "content-type": "image/jpeg" },
});
mockFetch.mockResolvedValueOnce(redirectResponse).mockResolvedValueOnce(fileResponse);
const result = await fetchWithSlackAuth("https://files.slack.com/test.jpg", "xoxb-test-token");
expect(result).toBe(fileResponse);
expect(mockFetch).toHaveBeenCalledTimes(2);
// First call should have Authorization header and manual redirect
expect(mockFetch).toHaveBeenNthCalledWith(1, "https://files.slack.com/test.jpg", {
headers: { Authorization: "Bearer xoxb-test-token" },
redirect: "manual",
});
// Second call should follow the redirect without Authorization
expect(mockFetch).toHaveBeenNthCalledWith(
2,
"https://cdn.slack-edge.com/presigned-url?sig=abc123",
{ redirect: "follow" },
);
});
it("handles relative redirect URLs", async () => {
const { fetchWithSlackAuth } = await import("./media.js");
// Redirect with relative URL
const redirectResponse = new Response(null, {
status: 302,
headers: { location: "/files/redirect-target" },
});
const fileResponse = new Response(Buffer.from("image data"), {
status: 200,
headers: { "content-type": "image/jpeg" },
});
mockFetch.mockResolvedValueOnce(redirectResponse).mockResolvedValueOnce(fileResponse);
await fetchWithSlackAuth("https://files.slack.com/original.jpg", "xoxb-test-token");
// Second call should resolve the relative URL against the original
expect(mockFetch).toHaveBeenNthCalledWith(2, "https://files.slack.com/files/redirect-target", {
redirect: "follow",
});
});
it("returns redirect response when no location header is provided", async () => {
const { fetchWithSlackAuth } = await import("./media.js");
// Redirect without location header
const redirectResponse = new Response(null, {
status: 302,
// No location header
});
mockFetch.mockResolvedValueOnce(redirectResponse);
const result = await fetchWithSlackAuth("https://files.slack.com/test.jpg", "xoxb-test-token");
// Should return the redirect response directly
expect(result).toBe(redirectResponse);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("returns 4xx/5xx responses directly without following", async () => {
const { fetchWithSlackAuth } = await import("./media.js");
const errorResponse = new Response("Not Found", {
status: 404,
});
mockFetch.mockResolvedValueOnce(errorResponse);
const result = await fetchWithSlackAuth("https://files.slack.com/test.jpg", "xoxb-test-token");
expect(result).toBe(errorResponse);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("handles 301 permanent redirects", async () => {
const { fetchWithSlackAuth } = await import("./media.js");
const redirectResponse = new Response(null, {
status: 301,
headers: { location: "https://cdn.slack.com/new-url" },
});
const fileResponse = new Response(Buffer.from("image data"), {
status: 200,
});
mockFetch.mockResolvedValueOnce(redirectResponse).mockResolvedValueOnce(fileResponse);
await fetchWithSlackAuth("https://files.slack.com/test.jpg", "xoxb-test-token");
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockFetch).toHaveBeenNthCalledWith(2, "https://cdn.slack.com/new-url", {
redirect: "follow",
});
});
});
describe("resolveSlackMedia", () => {
beforeEach(() => {
mockFetch = vi.fn();
globalThis.fetch = mockFetch as typeof fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
vi.resetModules();
});
it("prefers url_private_download over url_private", async () => {
// Mock the store module
vi.doMock("../../media/store.js", () => ({
saveMediaBuffer: vi.fn().mockResolvedValue({
path: "/tmp/test.jpg",
contentType: "image/jpeg",
}),
}));
const { resolveSlackMedia } = await import("./media.js");
const mockResponse = new Response(Buffer.from("image data"), {
status: 200,
headers: { "content-type": "image/jpeg" },
});
mockFetch.mockResolvedValueOnce(mockResponse);
await resolveSlackMedia({
files: [
{
url_private: "https://files.slack.com/private.jpg",
url_private_download: "https://files.slack.com/download.jpg",
name: "test.jpg",
},
],
token: "xoxb-test-token",
maxBytes: 1024 * 1024,
});
expect(mockFetch).toHaveBeenCalledWith(
"https://files.slack.com/download.jpg",
expect.anything(),
);
});
it("returns null when download fails", async () => {
const { resolveSlackMedia } = await import("./media.js");
// Simulate a network error
mockFetch.mockRejectedValueOnce(new Error("Network error"));
const result = await resolveSlackMedia({
files: [{ url_private: "https://files.slack.com/test.jpg", name: "test.jpg" }],
token: "xoxb-test-token",
maxBytes: 1024 * 1024,
});
expect(result).toBeNull();
});
it("returns null when no files are provided", async () => {
const { resolveSlackMedia } = await import("./media.js");
const result = await resolveSlackMedia({
files: [],
token: "xoxb-test-token",
maxBytes: 1024 * 1024,
});
expect(result).toBeNull();
});
it("skips files without url_private", async () => {
const { resolveSlackMedia } = await import("./media.js");
const result = await resolveSlackMedia({
files: [{ name: "test.jpg" }], // No url_private
token: "xoxb-test-token",
maxBytes: 1024 * 1024,
});
expect(result).toBeNull();
expect(mockFetch).not.toHaveBeenCalled();
});
it("falls through to next file when first file returns error", async () => {
// Mock the store module
vi.doMock("../../media/store.js", () => ({
saveMediaBuffer: vi.fn().mockResolvedValue({
path: "/tmp/test.jpg",
contentType: "image/jpeg",
}),
}));
const { resolveSlackMedia } = await import("./media.js");
// First file: 404
const errorResponse = new Response("Not Found", { status: 404 });
// Second file: success
const successResponse = new Response(Buffer.from("image data"), {
status: 200,
headers: { "content-type": "image/jpeg" },
});
mockFetch.mockResolvedValueOnce(errorResponse).mockResolvedValueOnce(successResponse);
const result = await resolveSlackMedia({
files: [
{ url_private: "https://files.slack.com/first.jpg", name: "first.jpg" },
{ url_private: "https://files.slack.com/second.jpg", name: "second.jpg" },
],
token: "xoxb-test-token",
maxBytes: 1024 * 1024,
});
expect(result).not.toBeNull();
expect(mockFetch).toHaveBeenCalledTimes(2);
});
});
+38 -4
View File
@@ -5,6 +5,38 @@ import { fetchRemoteMedia } from "../../media/fetch.js";
import { saveMediaBuffer } from "../../media/store.js";
import type { SlackFile } from "../types.js";
/**
* Fetches a URL with Authorization header, handling cross-origin redirects.
* Node.js fetch strips Authorization headers on cross-origin redirects for security.
* Slack's files.slack.com URLs redirect to CDN domains with pre-signed URLs that
* don't need the Authorization header, so we handle the initial auth request manually.
*/
export async function fetchWithSlackAuth(url: string, token: string): Promise<Response> {
// Initial request with auth and manual redirect handling
const initialRes = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
redirect: "manual",
});
// If not a redirect, return the response directly
if (initialRes.status < 300 || initialRes.status >= 400) {
return initialRes;
}
// Handle redirect - the redirected URL should be pre-signed and not need auth
const redirectUrl = initialRes.headers.get("location");
if (!redirectUrl) {
return initialRes;
}
// Resolve relative URLs against the original
const resolvedUrl = new URL(redirectUrl, url).toString();
// Follow the redirect without the Authorization header
// (Slack's CDN URLs are pre-signed and don't need it)
return fetch(resolvedUrl, { redirect: "follow" });
}
export async function resolveSlackMedia(params: {
files?: SlackFile[];
token: string;
@@ -19,10 +51,12 @@ export async function resolveSlackMedia(params: {
const url = file.url_private_download ?? file.url_private;
if (!url) continue;
try {
const fetchImpl: FetchLike = (input, init) => {
const headers = new Headers(init?.headers);
headers.set("Authorization", `Bearer ${params.token}`);
return fetch(input, { ...init, headers });
// Note: We ignore init options because fetchWithSlackAuth handles
// redirect behavior specially. fetchRemoteMedia only passes the URL.
const fetchImpl: FetchLike = (input) => {
const inputUrl =
typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
return fetchWithSlackAuth(inputUrl, params.token);
};
const fetched = await fetchRemoteMedia({
url,
@@ -0,0 +1,106 @@
import { describe, expect, it, vi } from "vitest";
import type { ChannelGroupPolicy } from "../config/group-policy.js";
import type { ClawdbotConfig } from "../config/config.js";
import type { TelegramAccountConfig } from "../config/types.js";
import type { RuntimeEnv } from "../runtime.js";
import { registerTelegramNativeCommands } from "./bot-native-commands.js";
const getPluginCommandSpecs = vi.hoisted(() => vi.fn());
const matchPluginCommand = vi.hoisted(() => vi.fn());
const executePluginCommand = vi.hoisted(() => vi.fn());
vi.mock("../plugins/commands.js", () => ({
getPluginCommandSpecs,
matchPluginCommand,
executePluginCommand,
}));
const deliverReplies = vi.hoisted(() => vi.fn(async () => {}));
vi.mock("./bot/delivery.js", () => ({ deliverReplies }));
vi.mock("./pairing-store.js", () => ({
readTelegramAllowFromStore: vi.fn(async () => []),
}));
describe("registerTelegramNativeCommands (plugin auth)", () => {
it("allows requireAuth:false plugin command even when sender is unauthorized", async () => {
const command = {
name: "plugin",
description: "Plugin command",
requireAuth: false,
handler: vi.fn(),
} as const;
getPluginCommandSpecs.mockReturnValue([{ name: "plugin", description: "Plugin command" }]);
matchPluginCommand.mockReturnValue({ command, args: undefined });
executePluginCommand.mockResolvedValue({ text: "ok" });
const handlers: Record<string, (ctx: unknown) => Promise<void>> = {};
const bot = {
api: {
setMyCommands: vi.fn().mockResolvedValue(undefined),
sendMessage: vi.fn(),
},
command: (name: string, handler: (ctx: unknown) => Promise<void>) => {
handlers[name] = handler;
},
} as const;
const cfg = {} as ClawdbotConfig;
const telegramCfg = {} as TelegramAccountConfig;
const resolveGroupPolicy = () =>
({
allowlistEnabled: false,
allowed: true,
}) as ChannelGroupPolicy;
registerTelegramNativeCommands({
bot: bot as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
cfg,
runtime: {} as RuntimeEnv,
accountId: "default",
telegramCfg,
allowFrom: ["999"],
groupAllowFrom: [],
replyToMode: "off",
textLimit: 4000,
useAccessGroups: false,
nativeEnabled: false,
nativeSkillsEnabled: false,
nativeDisabledExplicit: false,
resolveGroupPolicy,
resolveTelegramGroupConfig: () => ({
groupConfig: undefined,
topicConfig: undefined,
}),
shouldSkipUpdate: () => false,
opts: { token: "token" },
});
const ctx = {
message: {
chat: { id: 123, type: "private" },
from: { id: 111, username: "nope" },
message_id: 10,
date: 123456,
},
match: "",
};
await handlers.plugin?.(ctx);
expect(matchPluginCommand).toHaveBeenCalled();
expect(executePluginCommand).toHaveBeenCalledWith(
expect.objectContaining({
isAuthorizedSender: false,
}),
);
expect(deliverReplies).toHaveBeenCalledWith(
expect.objectContaining({
replies: [{ text: "ok" }],
}),
);
expect(bot.api.sendMessage).not.toHaveBeenCalled();
});
});
+269 -90
View File
@@ -17,9 +17,18 @@ import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/pr
import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
import { danger, logVerbose } from "../globals.js";
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
import {
normalizeTelegramCommandName,
TELEGRAM_COMMAND_NAME_PATTERN,
} from "../config/telegram-custom-commands.js";
import { resolveAgentRoute } from "../routing/resolve-route.js";
import { resolveThreadSessionKeys } from "../routing/session-key.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../channels/command-gating.js";
import {
executePluginCommand,
getPluginCommandSpecs,
matchPluginCommand,
} from "../plugins/commands.js";
import type { ChannelGroupPolicy } from "../config/group-policy.js";
import type {
ReplyToMode,
@@ -42,6 +51,18 @@ import { readTelegramAllowFromStore } from "./pairing-store.js";
type TelegramNativeCommandContext = Context & { match?: string };
type TelegramCommandAuthResult = {
chatId: number;
isGroup: boolean;
isForum: boolean;
resolvedThreadId?: number;
senderId: string;
senderUsername: string;
groupConfig?: TelegramGroupConfig;
topicConfig?: TelegramTopicConfig;
commandAuthorized: boolean;
};
type RegisterTelegramNativeCommandsParams = {
bot: Bot;
cfg: ClawdbotConfig;
@@ -65,6 +86,134 @@ type RegisterTelegramNativeCommandsParams = {
opts: { token: string };
};
async function resolveTelegramCommandAuth(params: {
msg: NonNullable<TelegramNativeCommandContext["message"]>;
bot: Bot;
cfg: ClawdbotConfig;
telegramCfg: TelegramAccountConfig;
allowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>;
useAccessGroups: boolean;
resolveGroupPolicy: (chatId: string | number) => ChannelGroupPolicy;
resolveTelegramGroupConfig: (
chatId: string | number,
messageThreadId?: number,
) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig };
requireAuth: boolean;
}): Promise<TelegramCommandAuthResult | null> {
const {
msg,
bot,
cfg,
telegramCfg,
allowFrom,
groupAllowFrom,
useAccessGroups,
resolveGroupPolicy,
resolveTelegramGroupConfig,
requireAuth,
} = params;
const chatId = msg.chat.id;
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true;
const resolvedThreadId = resolveTelegramForumThreadId({
isForum,
messageThreadId,
});
const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []);
const { groupConfig, topicConfig } = resolveTelegramGroupConfig(chatId, resolvedThreadId);
const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom);
const effectiveGroupAllow = normalizeAllowFromWithStore({
allowFrom: groupAllowOverride ?? groupAllowFrom,
storeAllowFrom,
});
const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined";
const senderIdRaw = msg.from?.id;
const senderId = senderIdRaw ? String(senderIdRaw) : "";
const senderUsername = msg.from?.username ?? "";
if (isGroup && groupConfig?.enabled === false) {
await bot.api.sendMessage(chatId, "This group is disabled.");
return null;
}
if (isGroup && topicConfig?.enabled === false) {
await bot.api.sendMessage(chatId, "This topic is disabled.");
return null;
}
if (requireAuth && isGroup && hasGroupAllowOverride) {
if (
senderIdRaw == null ||
!isSenderAllowed({
allow: effectiveGroupAllow,
senderId: String(senderIdRaw),
senderUsername,
})
) {
await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
return null;
}
}
if (isGroup && useAccessGroups) {
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = telegramCfg.groupPolicy ?? defaultGroupPolicy ?? "open";
if (groupPolicy === "disabled") {
await bot.api.sendMessage(chatId, "Telegram group commands are disabled.");
return null;
}
if (groupPolicy === "allowlist" && requireAuth) {
if (
senderIdRaw == null ||
!isSenderAllowed({
allow: effectiveGroupAllow,
senderId: String(senderIdRaw),
senderUsername,
})
) {
await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
return null;
}
}
const groupAllowlist = resolveGroupPolicy(chatId);
if (groupAllowlist.allowlistEnabled && !groupAllowlist.allowed) {
await bot.api.sendMessage(chatId, "This group is not allowed.");
return null;
}
}
const dmAllow = normalizeAllowFromWithStore({
allowFrom: allowFrom,
storeAllowFrom,
});
const senderAllowed = isSenderAllowed({
allow: dmAllow,
senderId,
senderUsername,
});
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [{ configured: dmAllow.hasEntries, allowed: senderAllowed }],
modeWhenAccessGroupsOff: "configured",
});
if (requireAuth && !commandAuthorized) {
await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
return null;
}
return {
chatId,
isGroup,
isForum,
resolvedThreadId,
senderId,
senderUsername,
groupConfig,
topicConfig,
commandAuthorized,
};
}
export const registerTelegramNativeCommands = ({
bot,
cfg,
@@ -103,11 +252,50 @@ export const registerTelegramNativeCommands = ({
runtime.error?.(danger(issue.message));
}
const customCommands = customResolution.commands;
const pluginCommandSpecs = getPluginCommandSpecs();
const pluginCommands: Array<{ command: string; description: string }> = [];
const existingCommands = new Set(
[
...nativeCommands.map((command) => command.name),
...customCommands.map((command) => command.command),
].map((command) => command.toLowerCase()),
);
const pluginCommandNames = new Set<string>();
for (const spec of pluginCommandSpecs) {
const normalized = normalizeTelegramCommandName(spec.name);
if (!normalized || !TELEGRAM_COMMAND_NAME_PATTERN.test(normalized)) {
runtime.error?.(
danger(
`Plugin command "/${spec.name}" is invalid for Telegram (use a-z, 0-9, underscore; max 32 chars).`,
),
);
continue;
}
const description = spec.description.trim();
if (!description) {
runtime.error?.(danger(`Plugin command "/${normalized}" is missing a description.`));
continue;
}
if (existingCommands.has(normalized)) {
runtime.error?.(
danger(`Plugin command "/${normalized}" conflicts with an existing Telegram command.`),
);
continue;
}
if (pluginCommandNames.has(normalized)) {
runtime.error?.(danger(`Plugin command "/${normalized}" is duplicated.`));
continue;
}
pluginCommandNames.add(normalized);
existingCommands.add(normalized);
pluginCommands.push({ command: normalized, description });
}
const allCommands: Array<{ command: string; description: string }> = [
...nativeCommands.map((command) => ({
command: command.name,
description: command.description,
})),
...pluginCommands,
...customCommands,
];
@@ -124,99 +312,30 @@ export const registerTelegramNativeCommands = ({
const msg = ctx.message;
if (!msg) return;
if (shouldSkipUpdate(ctx)) return;
const chatId = msg.chat.id;
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true;
const resolvedThreadId = resolveTelegramForumThreadId({
const auth = await resolveTelegramCommandAuth({
msg,
bot,
cfg,
telegramCfg,
allowFrom,
groupAllowFrom,
useAccessGroups,
resolveGroupPolicy,
resolveTelegramGroupConfig,
requireAuth: true,
});
if (!auth) return;
const {
chatId,
isGroup,
isForum,
messageThreadId,
});
const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []);
const { groupConfig, topicConfig } = resolveTelegramGroupConfig(chatId, resolvedThreadId);
const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom);
const effectiveGroupAllow = normalizeAllowFromWithStore({
allowFrom: groupAllowOverride ?? groupAllowFrom,
storeAllowFrom,
});
const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined";
if (isGroup && groupConfig?.enabled === false) {
await bot.api.sendMessage(chatId, "This group is disabled.");
return;
}
if (isGroup && topicConfig?.enabled === false) {
await bot.api.sendMessage(chatId, "This topic is disabled.");
return;
}
if (isGroup && hasGroupAllowOverride) {
const senderId = msg.from?.id;
const senderUsername = msg.from?.username ?? "";
if (
senderId == null ||
!isSenderAllowed({
allow: effectiveGroupAllow,
senderId: String(senderId),
senderUsername,
})
) {
await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
return;
}
}
if (isGroup && useAccessGroups) {
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = telegramCfg.groupPolicy ?? defaultGroupPolicy ?? "open";
if (groupPolicy === "disabled") {
await bot.api.sendMessage(chatId, "Telegram group commands are disabled.");
return;
}
if (groupPolicy === "allowlist") {
const senderId = msg.from?.id;
if (senderId == null) {
await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
return;
}
const senderUsername = msg.from?.username ?? "";
if (
!isSenderAllowed({
allow: effectiveGroupAllow,
senderId: String(senderId),
senderUsername,
})
) {
await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
return;
}
}
const groupAllowlist = resolveGroupPolicy(chatId);
if (groupAllowlist.allowlistEnabled && !groupAllowlist.allowed) {
await bot.api.sendMessage(chatId, "This group is not allowed.");
return;
}
}
const senderId = msg.from?.id ? String(msg.from.id) : "";
const senderUsername = msg.from?.username ?? "";
const dmAllow = normalizeAllowFromWithStore({
allowFrom: allowFrom,
storeAllowFrom,
});
const senderAllowed = isSenderAllowed({
allow: dmAllow,
resolvedThreadId,
senderId,
senderUsername,
});
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [{ configured: dmAllow.hasEntries, allowed: senderAllowed }],
modeWhenAccessGroupsOff: "configured",
});
if (!commandAuthorized) {
await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
return;
}
groupConfig,
topicConfig,
commandAuthorized,
} = auth;
const commandDefinition = findCommandByNativeName(command.name, "telegram");
const rawText = ctx.match?.trim() ?? "";
@@ -362,6 +481,66 @@ export const registerTelegramNativeCommands = ({
});
});
}
for (const pluginCommand of pluginCommands) {
bot.command(pluginCommand.command, async (ctx: TelegramNativeCommandContext) => {
const msg = ctx.message;
if (!msg) return;
if (shouldSkipUpdate(ctx)) return;
const chatId = msg.chat.id;
const rawText = ctx.match?.trim() ?? "";
const commandBody = `/${pluginCommand.command}${rawText ? ` ${rawText}` : ""}`;
const match = matchPluginCommand(commandBody);
if (!match) {
await bot.api.sendMessage(chatId, "Command not found.");
return;
}
const auth = await resolveTelegramCommandAuth({
msg,
bot,
cfg,
telegramCfg,
allowFrom,
groupAllowFrom,
useAccessGroups,
resolveGroupPolicy,
resolveTelegramGroupConfig,
requireAuth: match.command.requireAuth !== false,
});
if (!auth) return;
const { resolvedThreadId, senderId, commandAuthorized } = auth;
const result = await executePluginCommand({
command: match.command,
args: match.args,
senderId,
channel: "telegram",
isAuthorizedSender: commandAuthorized,
commandBody,
config: cfg,
});
const tableMode = resolveMarkdownTableMode({
cfg,
channel: "telegram",
accountId,
});
const chunkMode = resolveChunkMode(cfg, "telegram", accountId);
await deliverReplies({
replies: [result],
chatId: String(chatId),
token: opts.token,
runtime,
bot,
replyToMode,
textLimit,
messageThreadId: resolvedThreadId,
tableMode,
chunkMode,
linkPreview: telegramCfg.linkPreview,
});
});
}
}
} else if (nativeDisabledExplicit) {
bot.api.setMyCommands([]).catch((err) => {
+24 -3
View File
@@ -17,6 +17,7 @@ import { isGifMedia } from "../../media/mime.js";
import { saveMediaBuffer } from "../../media/store.js";
import type { RuntimeEnv } from "../../runtime.js";
import { loadWebMedia } from "../../web/media.js";
import { buildInlineKeyboard } from "../send.js";
import { resolveTelegramVoiceSend } from "../voice.js";
import { buildTelegramThreadParams, resolveTelegramReplyId } from "./helpers.js";
import type { TelegramContext } from "./types.js";
@@ -80,9 +81,17 @@ export async function deliverReplies(params: {
: reply.mediaUrl
? [reply.mediaUrl]
: [];
const telegramData = reply.channelData?.telegram as
| { buttons?: Array<Array<{ text: string; callback_data: string }>> }
| undefined;
const replyMarkup = buildInlineKeyboard(telegramData?.buttons);
if (mediaList.length === 0) {
const chunks = chunkText(reply.text || "");
for (const chunk of chunks) {
for (let i = 0; i < chunks.length; i += 1) {
const chunk = chunks[i];
if (!chunk) continue;
// Only attach buttons to the first chunk.
const shouldAttachButtons = i === 0 && replyMarkup;
await sendTelegramText(bot, chatId, chunk.html, runtime, {
replyToMessageId:
replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined,
@@ -90,6 +99,7 @@ export async function deliverReplies(params: {
textMode: "html",
plainText: chunk.text,
linkPreview,
replyMarkup: shouldAttachButtons ? replyMarkup : undefined,
});
if (replyToId && !hasReplied) {
hasReplied = true;
@@ -125,10 +135,12 @@ export async function deliverReplies(params: {
first = false;
const replyToMessageId =
replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined;
const shouldAttachButtonsToMedia = isFirstMedia && replyMarkup && !followUpText;
const mediaParams: Record<string, unknown> = {
caption: htmlCaption,
reply_to_message_id: replyToMessageId,
...(htmlCaption ? { parse_mode: "HTML" } : {}),
...(shouldAttachButtonsToMedia ? { reply_markup: replyMarkup } : {}),
};
if (threadParams) {
mediaParams.message_thread_id = threadParams.message_thread_id;
@@ -183,6 +195,7 @@ export async function deliverReplies(params: {
hasReplied,
messageThreadId,
linkPreview,
replyMarkup,
});
// Skip this media item; continue with next.
continue;
@@ -207,7 +220,8 @@ export async function deliverReplies(params: {
// Chunk it in case it's extremely long (same logic as text-only replies).
if (pendingFollowUpText && isFirstMedia) {
const chunks = chunkText(pendingFollowUpText);
for (const chunk of chunks) {
for (let i = 0; i < chunks.length; i += 1) {
const chunk = chunks[i];
const replyToMessageIdFollowup =
replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined;
await sendTelegramText(bot, chatId, chunk.html, runtime, {
@@ -216,6 +230,7 @@ export async function deliverReplies(params: {
textMode: "html",
plainText: chunk.text,
linkPreview,
replyMarkup: i === 0 ? replyMarkup : undefined,
});
if (replyToId && !hasReplied) {
hasReplied = true;
@@ -277,10 +292,12 @@ async function sendTelegramVoiceFallbackText(opts: {
hasReplied: boolean;
messageThreadId?: number;
linkPreview?: boolean;
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
}): Promise<boolean> {
const chunks = opts.chunkText(opts.text);
let hasReplied = opts.hasReplied;
for (const chunk of chunks) {
for (let i = 0; i < chunks.length; i += 1) {
const chunk = chunks[i];
await sendTelegramText(opts.bot, opts.chatId, chunk.html, opts.runtime, {
replyToMessageId:
opts.replyToId && (opts.replyToMode === "all" || !hasReplied) ? opts.replyToId : undefined,
@@ -288,6 +305,7 @@ async function sendTelegramVoiceFallbackText(opts: {
textMode: "html",
plainText: chunk.text,
linkPreview: opts.linkPreview,
replyMarkup: i === 0 ? opts.replyMarkup : undefined,
});
if (opts.replyToId && !hasReplied) {
hasReplied = true;
@@ -322,6 +340,7 @@ async function sendTelegramText(
textMode?: "markdown" | "html";
plainText?: string;
linkPreview?: boolean;
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
},
): Promise<number | undefined> {
const baseParams = buildTelegramSendParams({
@@ -337,6 +356,7 @@ async function sendTelegramText(
const res = await bot.api.sendMessage(chatId, htmlText, {
parse_mode: "HTML",
...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}),
...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}),
...baseParams,
});
return res.message_id;
@@ -347,6 +367,7 @@ async function sendTelegramText(
const fallbackText = opts?.plainText ?? text;
const res = await bot.api.sendMessage(chatId, fallbackText, {
...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}),
...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}),
...baseParams,
});
return res.message_id;
-11
View File
@@ -93,11 +93,6 @@ export async function configureGatewayForOnboarding(
: ((await prompter.select({
message: "Gateway auth",
options: [
{
value: "off",
label: "Off (loopback only)",
hint: "Not recommended unless you fully trust local processes",
},
{
value: "token",
label: "Token",
@@ -165,7 +160,6 @@ export async function configureGatewayForOnboarding(
// Safety + constraints:
// - Tailscale wants bind=loopback so we never expose a non-loopback server + tailscale serve/funnel at once.
// - Auth off only allowed for bind=loopback.
// - Funnel requires password auth.
if (tailscaleMode !== "off" && bind !== "loopback") {
await prompter.note("Tailscale requires bind=loopback. Adjusting bind to loopback.", "Note");
@@ -173,11 +167,6 @@ export async function configureGatewayForOnboarding(
customBindHost = undefined;
}
if (authMode === "off" && bind !== "loopback") {
await prompter.note("Non-loopback bind requires auth. Switching to token auth.", "Note");
authMode = "token";
}
if (tailscaleMode === "funnel" && authMode !== "password") {
await prompter.note("Tailscale funnel requires password auth.", "Note");
authMode = "password";
+18 -5
View File
@@ -51,12 +51,26 @@ async function requireRiskAcknowledgement(params: {
await params.prompter.note(
[
"Please read: https://docs.clawd.bot/security",
"Security warning — please read.",
"",
"Clawdbot agents can run commands, read/write files, and act through any tools you enable. They can only send messages on channels you configure (for example, an account you log in on this machine, or a bot account like Slack/Discord).",
"Clawdbot is a hobby project and still in beta. Expect sharp edges.",
"This bot can read files and run actions if tools are enabled.",
"A bad prompt can trick it into doing unsafe things.",
"",
"If you’re new to this, start with the sandbox and least privilege. It helps limit what an agent can do if it’s tricked or makes a mistake.",
"Learn more: https://docs.clawd.bot/sandboxing",
"If you’re not comfortable with basic security and access control, don’t run Clawdbot.",
"Ask someone experienced to help before enabling tools or exposing it to the internet.",
"",
"Recommended baseline:",
"- Pairing/allowlists + mention gating.",
"- Sandbox + least-privilege tools.",
"- Keep secrets out of the agent’s reachable filesystem.",
"- Use the strongest available model for any bot with tools or untrusted inboxes.",
"",
"Run regularly:",
"clawdbot security audit --deep",
"clawdbot security audit --fix",
"",
"Must read: https://docs.clawd.bot/gateway/security",
].join("\n"),
"Security",
);
@@ -230,7 +244,6 @@ export async function runOnboardingWizard(
return "Auto";
};
const formatAuth = (value: GatewayAuthChoice) => {
if (value === "off") return "Off (loopback only)";
if (value === "token") return "Token (default)";
return "Password";
};