Compare commits

..
Author SHA1 Message Date
Peter Steinberger e05039b98f docs: tighten fly private deployment steps (#2121) (thanks @dguido) 2026-01-26 16:52:39 +00:00
Dan GuidoandClaude Opus 4.5 aea8c678e3 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:50:02 +00:00
41 changed files with 198 additions and 2342 deletions

No files matched your search

+2 -8
View File
@@ -6,10 +6,8 @@ Docs: https://docs.clawd.bot
Status: unreleased.
### Changes
- Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281)
- Docs: tighten Fly private deployment steps. (#2289) Thanks @dguido.
- Docs: add Fly private/hardened deployment guide. (#2121) 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.
@@ -20,10 +18,9 @@ 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. Thanks @thewilloftheshadow.
- Docs: add LINE channel guide.
- 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.
@@ -32,7 +29,6 @@ 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.
@@ -52,12 +48,10 @@ 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.
+27 -28
View File
@@ -479,33 +479,32 @@ 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/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/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/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/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>
<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>
</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 <token|password>`
- `--gateway-auth <off|token|password>`
- `--gateway-token <token>`
- `--gateway-password <password>`
- `--remote-url <url>`
+2 -4
View File
@@ -2847,11 +2847,9 @@ 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 when
device identity is omitted (typically over HTTP). Default: `false`. Prefer HTTPS
- `gateway.controlUi.allowInsecureAuth` allows token-only auth for the Control UI and skips
device identity + pairing (even on HTTPS). 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)
+1 -2
View File
@@ -198,8 +198,7 @@ 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
(or `gateway.controlUi.dangerouslyDisableDeviceAuth` for break-glass use).
Control UI can omit it **only** when `gateway.controlUi.allowInsecureAuth` is enabled.
- Non-local connections must sign the server-provided `connect.challenge` nonce.
## TLS + pinning
+1 -5
View File
@@ -58,13 +58,9 @@ 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 when device identity is omitted. This is a security
to **token-only auth** and skips device pairing (even on HTTPS). 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 didn’t configure auth.
- You set `gateway.bind` to a non-loopback mode (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) but left auth off.
- 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
@@ -1,78 +0,0 @@
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,7 +86,6 @@ 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,
@@ -236,7 +235,6 @@ 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,16 +310,6 @@ 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,27 +558,6 @@ 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,20 +157,6 @@ 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,8 +27,6 @@ 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,
@@ -45,8 +43,6 @@ const HANDLERS: CommandHandler[] = [
handleRestartCommand,
handleTtsCommands,
handleHelpCommand,
handlePlanCommand,
handlePlansCommand,
handleCommandsListCommand,
handleStatusCommand,
handleAllowlistCommand,
-396
View File
@@ -1,396 +0,0 @@
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
@@ -1,491 +0,0 @@
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
@@ -1,256 +0,0 @@
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,44 +132,6 @@ 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();
@@ -1,81 +0,0 @@
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,7 +18,6 @@ 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,
@@ -51,46 +50,4 @@ 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: token|password")
.option("--gateway-auth <mode>", "Gateway auth: off|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")
+17 -9
View File
@@ -3,18 +3,26 @@ import { describe, expect, it } from "vitest";
import { buildGatewayAuthConfig } from "./configure.js";
describe("buildGatewayAuthConfig", () => {
it("preserves allowTailscale when switching to token", () => {
it("clears token/password when auth is off", () => {
const result = buildGatewayAuthConfig({
existing: {
mode: "password",
password: "secret",
allowTailscale: true,
},
mode: "token",
token: "abc",
existing: { mode: "token", token: "abc", password: "secret" },
mode: "off",
});
expect(result).toEqual({ mode: "token", token: "abc", allowTailscale: true });
expect(result).toBeUndefined();
});
it("preserves allowTailscale when auth is off", () => {
const result = buildGatewayAuthConfig({
existing: {
mode: "token",
token: "abc",
allowTailscale: true,
},
mode: "off",
});
expect(result).toEqual({ allowTailscale: true });
});
it("drops password when switching to token", () => {
+4 -1
View File
@@ -12,7 +12,7 @@ import {
promptModelAllowlist,
} from "./model-picker.js";
type GatewayAuthChoice = "token" | "password";
type GatewayAuthChoice = "off" | "token" | "password";
const ANTHROPIC_OAUTH_MODEL_KEYS = [
"anthropic/claude-opus-4-5",
@@ -30,6 +30,9 @@ 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 };
}
+11 -1
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 = "token" | "password";
type GatewayAuthChoice = "off" | "token" | "password";
export async function promptGatewayConfig(
cfg: ClawdbotConfig,
@@ -91,6 +91,11 @@ 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" },
],
@@ -160,6 +165,11 @@ 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-generates token auth when binding LAN and persists the token", async () => {
it("auto-enables 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,6 +242,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
installDaemon: false,
gatewayPort: port,
gatewayBind: "lan",
gatewayAuth: "off",
},
runtime,
);
@@ -28,20 +28,16 @@ export function applyNonInteractiveGatewayConfig(params: {
const port = hasGatewayPort ? (opts.gatewayPort as number) : params.defaultPort;
let bind = opts.gatewayBind ?? "loopback";
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;
let authMode = opts.gatewayAuth ?? "token";
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 = "token" | "password";
export type GatewayAuthChoice = "off" | "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,7 +199,6 @@ 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)",
@@ -382,8 +381,6 @@ 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,8 +66,6 @@ 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,7 +319,6 @@ 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,53 +352,6 @@ 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 deviceRaw = connectParams.device;
const device = connectParams.device;
let devicePublicKey: string | null = null;
const hasTokenAuth = Boolean(connectParams.auth?.token);
const hasPasswordAuth = Boolean(connectParams.auth?.password);
@@ -343,10 +343,6 @@ 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", {
@@ -374,9 +370,9 @@ export function attachGatewayWsMessageHandler(params: {
}
if (!device) {
const canSkipDevice = allowControlUiBypass ? hasSharedAuth : hasTokenAuth;
const canSkipDevice = allowInsecureControlUi ? hasSharedAuth : hasTokenAuth;
if (isControlUi && !allowControlUiBypass) {
if (isControlUi && !allowInsecureControlUi) {
const errorMessage = "control ui requires HTTPS or localhost (secure context)";
setHandshakeState("failed");
setCloseCause("control-ui-insecure-auth", {
@@ -619,7 +615,7 @@ export function attachGatewayWsMessageHandler(params: {
return;
}
const skipPairing = allowControlUiBypass && hasSharedAuth;
const skipPairing = allowInsecureControlUi && hasSharedAuth;
if (device && devicePublicKey && !skipPairing) {
const requirePairing = async (reason: string, _paired?: { deviceId: string }) => {
const pairing = await requestDevicePairing({
@@ -740,7 +736,9 @@ export function attachGatewayWsMessageHandler(params: {
const shouldTrackPresence = !isGatewayCliClient(connectParams.client);
const clientId = connectParams.client.id;
const instanceId = connectParams.client.instanceId;
const presenceKey = shouldTrackPresence ? (device?.id ?? instanceId ?? connId) : undefined;
const presenceKey = shouldTrackPresence
? (connectParams.device?.id ?? instanceId ?? connId)
: undefined;
logWs("in", "connect", {
connId,
@@ -768,10 +766,10 @@ export function attachGatewayWsMessageHandler(params: {
deviceFamily: connectParams.client.deviceFamily,
modelIdentifier: connectParams.client.modelIdentifier,
mode: connectParams.client.mode,
deviceId: device?.id,
deviceId: connectParams.device?.id,
roles: [role],
scopes,
instanceId: device?.id ?? instanceId,
instanceId: connectParams.device?.id ?? instanceId,
reason: "connect",
});
incrementPresenceVersion();
-5
View File
@@ -63,11 +63,6 @@ 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";
+1 -24
View File
@@ -293,30 +293,7 @@ describe("security audit", () => {
expect.arrayContaining([
expect.objectContaining({
checkId: "gateway.control_ui.insecure_auth",
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",
severity: "warn",
}),
]),
);
+1 -12
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: "critical",
severity: "warn",
title: "Control UI allows insecure HTTP auth",
detail:
"gateway.controlUi.allowInsecureAuth=true allows token-only auth over HTTP and skips device identity.",
@@ -282,17 +282,6 @@ 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
@@ -1,278 +0,0 @@
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);
});
});
+4 -38
View File
@@ -5,38 +5,6 @@ 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;
@@ -51,12 +19,10 @@ export async function resolveSlackMedia(params: {
const url = file.url_private_download ?? file.url_private;
if (!url) continue;
try {
// 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 fetchImpl: FetchLike = (input, init) => {
const headers = new Headers(init?.headers);
headers.set("Authorization", `Bearer ${params.token}`);
return fetch(input, { ...init, headers });
};
const fetched = await fetchRemoteMedia({
url,
@@ -1,106 +0,0 @@
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();
});
});
+90 -269
View File
@@ -17,18 +17,9 @@ 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,
@@ -51,18 +42,6 @@ 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;
@@ -86,134 +65,6 @@ 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,
@@ -252,50 +103,11 @@ 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,
];
@@ -312,30 +124,99 @@ export const registerTelegramNativeCommands = ({
const msg = ctx.message;
if (!msg) return;
if (shouldSkipUpdate(ctx)) return;
const auth = await resolveTelegramCommandAuth({
msg,
bot,
cfg,
telegramCfg,
allowFrom,
groupAllowFrom,
useAccessGroups,
resolveGroupPolicy,
resolveTelegramGroupConfig,
requireAuth: true,
});
if (!auth) return;
const {
chatId,
isGroup,
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,
resolvedThreadId,
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,
senderId,
senderUsername,
groupConfig,
topicConfig,
commandAuthorized,
} = auth;
});
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;
}
const commandDefinition = findCommandByNativeName(command.name, "telegram");
const rawText = ctx.match?.trim() ?? "";
@@ -481,66 +362,6 @@ 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) => {
+3 -24
View File
@@ -17,7 +17,6 @@ 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";
@@ -81,17 +80,9 @@ 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 (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;
for (const chunk of chunks) {
await sendTelegramText(bot, chatId, chunk.html, runtime, {
replyToMessageId:
replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined,
@@ -99,7 +90,6 @@ export async function deliverReplies(params: {
textMode: "html",
plainText: chunk.text,
linkPreview,
replyMarkup: shouldAttachButtons ? replyMarkup : undefined,
});
if (replyToId && !hasReplied) {
hasReplied = true;
@@ -135,12 +125,10 @@ 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;
@@ -195,7 +183,6 @@ export async function deliverReplies(params: {
hasReplied,
messageThreadId,
linkPreview,
replyMarkup,
});
// Skip this media item; continue with next.
continue;
@@ -220,8 +207,7 @@ 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 (let i = 0; i < chunks.length; i += 1) {
const chunk = chunks[i];
for (const chunk of chunks) {
const replyToMessageIdFollowup =
replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined;
await sendTelegramText(bot, chatId, chunk.html, runtime, {
@@ -230,7 +216,6 @@ export async function deliverReplies(params: {
textMode: "html",
plainText: chunk.text,
linkPreview,
replyMarkup: i === 0 ? replyMarkup : undefined,
});
if (replyToId && !hasReplied) {
hasReplied = true;
@@ -292,12 +277,10 @@ 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 (let i = 0; i < chunks.length; i += 1) {
const chunk = chunks[i];
for (const chunk of chunks) {
await sendTelegramText(opts.bot, opts.chatId, chunk.html, opts.runtime, {
replyToMessageId:
opts.replyToId && (opts.replyToMode === "all" || !hasReplied) ? opts.replyToId : undefined,
@@ -305,7 +288,6 @@ async function sendTelegramVoiceFallbackText(opts: {
textMode: "html",
plainText: chunk.text,
linkPreview: opts.linkPreview,
replyMarkup: i === 0 ? opts.replyMarkup : undefined,
});
if (opts.replyToId && !hasReplied) {
hasReplied = true;
@@ -340,7 +322,6 @@ async function sendTelegramText(
textMode?: "markdown" | "html";
plainText?: string;
linkPreview?: boolean;
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
},
): Promise<number | undefined> {
const baseParams = buildTelegramSendParams({
@@ -356,7 +337,6 @@ 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;
@@ -367,7 +347,6 @@ 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,6 +93,11 @@ 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",
@@ -160,6 +165,7 @@ 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");
@@ -167,6 +173,11 @@ 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";
+5 -18
View File
@@ -51,26 +51,12 @@ async function requireRiskAcknowledgement(params: {
await params.prompter.note(
[
"Security warning — please read.",
"Please read: https://docs.clawd.bot/security",
"",
"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.",
"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).",
"",
"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",
"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",
].join("\n"),
"Security",
);
@@ -244,6 +230,7 @@ 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";
};