Compare commits

..
Author SHA1 Message Date
Peter Steinberger 2ce267400c fix: allow token auth without device identity (#1314) (thanks @dbhurley) 2026-01-21 02:44:28 +00:00
David HurleyandClaude Opus 4.5 d353db42ba fix: allow token auth to bypass device identity requirement
The device identity check was rejecting connections before token
authentication could be attempted. This broke the control-ui (web UI)
which uses token-based authentication via URL parameter.

Changes:
- Skip device identity requirement when a token is provided
- Guard device token verification to only run when device is present

Fixes control-ui showing "device identity required" error when
connecting with a valid token.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 02:20:56 +00:00
86 changed files with 730 additions and 2230 deletions

No files matched your search

+1 -8
View File
@@ -9,7 +9,6 @@ Docs: https://docs.clawd.bot
- Repo: remove the Peekaboo git submodule now that the SPM release is used.
- Update: sync plugin sources on channel switches and update npm-installed plugins during `clawdbot update`.
- Plugins: share npm plugin update logic between `clawdbot update` and `clawdbot plugins update`.
- Browser: allow config defaults for efficient snapshots in the tool/CLI. (#1336) — thanks @sebslight.
- Channels: add the Nostr plugin channel with profile management + onboarding install defaults. (#1323) — thanks @joelklabo.
- Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow.
- Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel.
@@ -21,29 +20,23 @@ Docs: https://docs.clawd.bot
- Security: warn when <=300B models run without sandboxing and with web tools enabled.
- Skills: add download installs with OS-filtered install options; add local sherpa-onnx-tts skill.
- Docs: clarify WhatsApp voice notes and Windows WSL portproxy LAN access notes.
- UI: add copy-as-markdown with error feedback and drop legacy list view. (#1345) — thanks @bradleypriest.
### Fixes
- Tests: cover auth profile scoping when model fallback switches providers. (#1350) — thanks @Jackten.
- Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs.
- Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241) — thanks @gnarco.
- Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs.
- Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry.
- Diagnostics: emit message-flow diagnostics across channels via shared dispatch; gate heartbeat/webhook logging. (#1244) — thanks @oscargavin.
- CLI: preserve cron delivery settings when editing message payloads. (#1322) — thanks @KrauseFx.
- CLI: keep `clawdbot logs` output resilient to broken pipes while preserving progress output.
- Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332) — thanks @dougvk.
- Doctor: clarify plugin auto-enable hint text in the startup banner.
- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.
- Gateway: clarify connect/validation errors for gateway params. (#1347) — thanks @vignesh07.
- Gateway: allow token-auth Control UI connections without device identity. (#1314) — thanks @dbhurley.
- Gateway: preserve restart wake routing + thread replies across restarts. (#1337) — thanks @John-Rood.
- Gateway: reschedule per-agent heartbeats on config hot reload without restarting the runner.
- Config: log invalid config issues once per run and keep invalid-config errors stackless.
- Exec: default gateway/node exec security to allowlist when unset (sandbox stays deny).
- UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315) — thanks @MaudeBot.
- UI: preserve ordered list numbering in chat markdown. (#1341) — thanks @bradleypriest.
- UI: allow Control UI to read gatewayUrl from URL params for remote WebSocket targets. (#1342) — thanks @ameno-.
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
- Web fetch: harden SSRF protection with shared hostname checks and redirect limits. (#1346) — thanks @fogboots.
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.
- TUI: align custom editor initialization with the latest pi-tui API. (#1298) — thanks @sibbl.
- CLI: avoid duplicating --profile/--dev flags when formatting commands.
+23 -23
View File
@@ -479,27 +479,27 @@ Core contributors:
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/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/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/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a>
<a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a>
<a href="https://github.com/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/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a>
<a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/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/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a>
<a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a>
<a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/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/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a>
<a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/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/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a>
<a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/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/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a>
<a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/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/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/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a>
<a href="https://github.com/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/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a>
<a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/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/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a>
<a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/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/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/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/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a>
<a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a>
<a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/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/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a>
<a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a>
<a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/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/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/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a>
<a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a>
<a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a>
<a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/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/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a>
<a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a>
<a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a>
<a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a>
<a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/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/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a>
<a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/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/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a>
<a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a>
<a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/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/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a>
<a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/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/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a>
<a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/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/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a>
<a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/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/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a>
<a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a>
<a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a>
<a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/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/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a>
<a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/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/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/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/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a>
<a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a>
<a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/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/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a>
<a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a>
<a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a>
<a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a>
<a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a>
<a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/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/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a>
<a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a>
<a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a>
<a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
</p>
@@ -263,8 +263,7 @@ extension ConfigSettings {
let subsections = self.resolveSubsections(for: section)
let resolved: (ConfigSchemaNode, ConfigPath) = {
if case let .key(key) = subsection,
let match = subsections.first(where: { $0.key == key })
{
let match = subsections.first(where: { $0.key == key }) {
return (match.node, match.path)
}
return (self.resolvedSchemaNode(section.node), defaultPath)
@@ -319,7 +319,7 @@ private enum ExecHostExecutor {
security: context.security,
allowlistMatch: context.allowlistMatch,
skillAllow: context.skillAllow),
approvalDecision == nil
approvalDecision == nil
{
let decision = ExecApprovalsPromptPresenter.prompt(
ExecApprovalPromptRequest(
@@ -172,10 +172,6 @@ actor GatewayEndpointStore {
return configToken
}
if isRemote {
return nil
}
if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines),
!token.isEmpty
{
@@ -558,16 +554,13 @@ actor GatewayEndpointStore {
{
switch bindMode {
case "tailnet":
return tailscaleIP ?? "127.0.0.1"
tailscaleIP ?? "127.0.0.1"
case "auto":
if let tailscaleIP, !tailscaleIP.isEmpty {
return tailscaleIP
}
return "127.0.0.1"
"127.0.0.1"
case "custom":
return customBindHost ?? "127.0.0.1"
customBindHost ?? "127.0.0.1"
default:
return "127.0.0.1"
"127.0.0.1"
}
}
}
@@ -638,12 +631,11 @@ extension GatewayEndpointStore {
static func _testResolveLocalGatewayHost(
bindMode: String?,
tailscaleIP: String?,
customBindHost: String? = nil) -> String
tailscaleIP: String?) -> String
{
self.resolveLocalGatewayHost(
bindMode: bindMode,
customBindHost: customBindHost,
customBindHost: nil,
tailscaleIP: tailscaleIP)
}
}
@@ -2,9 +2,6 @@ import AppKit
import Foundation
import Observation
import os
#if canImport(Darwin)
import Darwin
#endif
/// Manages Tailscale integration and status checking.
@Observable
@@ -104,12 +101,15 @@ final class TailscaleService {
func checkTailscaleStatus() async {
self.isInstalled = self.checkAppInstallation()
if !self.isInstalled {
guard self.isInstalled else {
self.isRunning = false
self.tailscaleHostname = nil
self.tailscaleIP = nil
self.statusError = "Tailscale is not installed"
} else if let apiResponse = await fetchTailscaleStatus() {
return
}
if let apiResponse = await fetchTailscaleStatus() {
self.isRunning = apiResponse.status.lowercased() == "running"
if self.isRunning {
@@ -138,15 +138,6 @@ final class TailscaleService {
self.statusError = "Please start the Tailscale app"
self.logger.info("Tailscale API not responding; app likely not running")
}
if self.tailscaleIP == nil, let fallback = Self.detectTailnetIPv4() {
self.tailscaleIP = fallback
if !self.isRunning {
self.isRunning = true
}
self.statusError = nil
self.logger.info("Tailscale interface IP detected (fallback) ip=\(fallback, privacy: .public)")
}
}
func openTailscaleApp() {
@@ -172,46 +163,4 @@ final class TailscaleService {
NSWorkspace.shared.open(url)
}
}
private static func isTailnetIPv4(_ address: String) -> Bool {
let parts = address.split(separator: ".")
guard parts.count == 4 else { return false }
let octets = parts.compactMap { Int($0) }
guard octets.count == 4 else { return false }
let a = octets[0]
let b = octets[1]
return a == 100 && b >= 64 && b <= 127
}
private static func detectTailnetIPv4() -> String? {
var addrList: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
defer { freeifaddrs(addrList) }
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
let flags = Int32(ptr.pointee.ifa_flags)
let isUp = (flags & IFF_UP) != 0
let isLoopback = (flags & IFF_LOOPBACK) != 0
let family = ptr.pointee.ifa_addr.pointee.sa_family
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
var addr = ptr.pointee.ifa_addr.pointee
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
let result = getnameinfo(
&addr,
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
&buffer,
socklen_t(buffer.count),
nil,
0,
NI_NUMERICHOST)
guard result == 0 else { continue }
let len = buffer.prefix { $0 != 0 }
let bytes = len.map { UInt8(bitPattern: $0) }
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
if Self.isTailnetIPv4(ip) { return ip }
}
return nil
}
}
@@ -1,16 +1,13 @@
import ClawdbotKit
import ClawdbotProtocol
import Foundation
#if canImport(Darwin)
import Darwin
#endif
struct ConnectOptions {
var url: String?
var token: String?
var password: String?
var mode: String?
var timeoutMs: Int = 15000
var timeoutMs: Int = 15_000
var json: Bool = false
var probe: Bool = false
var clientId: String = "clawdbot-macos"
@@ -22,43 +19,53 @@ struct ConnectOptions {
static func parse(_ args: [String]) -> ConnectOptions {
var opts = ConnectOptions()
let flagHandlers: [String: (inout ConnectOptions) -> Void] = [
"-h": { $0.help = true },
"--help": { $0.help = true },
"--json": { $0.json = true },
"--probe": { $0.probe = true },
]
let valueHandlers: [String: (inout ConnectOptions, String) -> Void] = [
"--url": { $0.url = $1 },
"--token": { $0.token = $1 },
"--password": { $0.password = $1 },
"--mode": { $0.mode = $1 },
"--timeout": { opts, raw in
if let parsed = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)) {
opts.timeoutMs = max(250, parsed)
}
},
"--client-id": { $0.clientId = $1 },
"--client-mode": { $0.clientMode = $1 },
"--display-name": { $0.displayName = $1 },
"--role": { $0.role = $1 },
"--scopes": { opts, raw in
opts.scopes = raw.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
},
]
var i = 0
while i < args.count {
let arg = args[i]
if let handler = flagHandlers[arg] {
handler(&opts)
i += 1
continue
}
if let handler = valueHandlers[arg], let value = self.nextValue(args, index: &i) {
handler(&opts, value)
i += 1
continue
switch arg {
case "-h", "--help":
opts.help = true
case "--json":
opts.json = true
case "--probe":
opts.probe = true
case "--url":
opts.url = self.nextValue(args, index: &i)
case "--token":
opts.token = self.nextValue(args, index: &i)
case "--password":
opts.password = self.nextValue(args, index: &i)
case "--mode":
if let value = self.nextValue(args, index: &i) {
opts.mode = value
}
case "--timeout":
if let raw = self.nextValue(args, index: &i),
let parsed = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines))
{
opts.timeoutMs = max(250, parsed)
}
case "--client-id":
if let value = self.nextValue(args, index: &i) {
opts.clientId = value
}
case "--client-mode":
if let value = self.nextValue(args, index: &i) {
opts.clientMode = value
}
case "--display-name":
opts.displayName = self.nextValue(args, index: &i)
case "--role":
if let value = self.nextValue(args, index: &i) {
opts.role = value
}
case "--scopes":
if let value = self.nextValue(args, index: &i) {
opts.scopes = value.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}
default:
break
}
i += 1
}
@@ -247,12 +254,8 @@ private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig)
if resolvedMode == "remote" {
guard let raw = config.remoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines),
!raw.isEmpty
else {
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url is missing"])
!raw.isEmpty else {
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url is missing"])
}
guard let url = URL(string: raw) else {
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"])
@@ -265,12 +268,9 @@ private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig)
}
let port = config.port ?? 18789
let host = resolveLocalHost(bind: config.bind)
let host = "127.0.0.1"
guard let url = URL(string: "ws://\(host):\(port)") else {
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "invalid url: ws://\(host):\(port)"])
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: ws://\(host):\(port)"])
}
return GatewayEndpoint(
url: url,
@@ -280,7 +280,7 @@ private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig)
}
private func bestEffortEndpoint(opts: ConnectOptions, config: GatewayConfig) -> GatewayEndpoint? {
try? resolveGatewayEndpoint(opts: opts, config: config)
return try? resolveGatewayEndpoint(opts: opts, config: config)
}
private func resolvedToken(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? {
@@ -304,56 +304,3 @@ private func resolvedPassword(opts: ConnectOptions, mode: String, config: Gatewa
}
return config.password
}
private func resolveLocalHost(bind: String?) -> String {
let normalized = (bind ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let tailnetIP = detectTailnetIPv4()
switch normalized {
case "tailnet", "auto":
return tailnetIP ?? "127.0.0.1"
default:
return "127.0.0.1"
}
}
private func detectTailnetIPv4() -> String? {
var addrList: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
defer { freeifaddrs(addrList) }
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
let flags = Int32(ptr.pointee.ifa_flags)
let isUp = (flags & IFF_UP) != 0
let isLoopback = (flags & IFF_LOOPBACK) != 0
let family = ptr.pointee.ifa_addr.pointee.sa_family
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
var addr = ptr.pointee.ifa_addr.pointee
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
let result = getnameinfo(
&addr,
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
&buffer,
socklen_t(buffer.count),
nil,
0,
NI_NUMERICHOST)
guard result == 0 else { continue }
let len = buffer.prefix { $0 != 0 }
let bytes = len.map { UInt8(bitPattern: $0) }
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
if isTailnetIPv4(ip) { return ip }
}
return nil
}
private func isTailnetIPv4(_ address: String) -> Bool {
let parts = address.split(separator: ".")
guard parts.count == 4 else { return false }
let octets = parts.compactMap { Int($0) }
guard octets.count == 4 else { return false }
let a = octets[0]
let b = octets[1]
return a == 100 && b >= 64 && b <= 127
}
@@ -3,8 +3,6 @@ import SwiftUI
import Testing
@testable import Clawdbot
private typealias SnapshotAnyCodable = Clawdbot.AnyCodable
@Suite(.serialized)
@MainActor
struct ChannelsSettingsSmokeTests {
@@ -19,11 +17,8 @@ struct ChannelsSettingsSmokeTests {
"signal": "Signal",
"imessage": "iMessage",
],
channelDetailLabels: nil,
channelSystemImages: nil,
channelMeta: nil,
channels: [
"whatsapp": SnapshotAnyCodable([
"whatsapp": AnyCodable([
"configured": true,
"linked": true,
"authAgeMs": 86_400_000,
@@ -42,7 +37,7 @@ struct ChannelsSettingsSmokeTests {
"lastEventAt": 1_700_000_060_000,
"lastError": "needs login",
]),
"telegram": SnapshotAnyCodable([
"telegram": AnyCodable([
"configured": true,
"tokenSource": "env",
"running": true,
@@ -57,7 +52,7 @@ struct ChannelsSettingsSmokeTests {
],
"lastProbeAt": 1_700_000_050_000,
]),
"signal": SnapshotAnyCodable([
"signal": AnyCodable([
"configured": true,
"baseUrl": "http://127.0.0.1:8080",
"running": true,
@@ -70,7 +65,7 @@ struct ChannelsSettingsSmokeTests {
],
"lastProbeAt": 1_700_000_050_000,
]),
"imessage": SnapshotAnyCodable([
"imessage": AnyCodable([
"configured": false,
"running": false,
"lastError": "not configured",
@@ -105,18 +100,15 @@ struct ChannelsSettingsSmokeTests {
"signal": "Signal",
"imessage": "iMessage",
],
channelDetailLabels: nil,
channelSystemImages: nil,
channelMeta: nil,
channels: [
"whatsapp": SnapshotAnyCodable([
"whatsapp": AnyCodable([
"configured": false,
"linked": false,
"running": false,
"connected": false,
"reconnectAttempts": 0,
]),
"telegram": SnapshotAnyCodable([
"telegram": AnyCodable([
"configured": false,
"running": false,
"lastError": "bot missing",
@@ -128,7 +120,7 @@ struct ChannelsSettingsSmokeTests {
],
"lastProbeAt": 1_700_000_100_000,
]),
"signal": SnapshotAnyCodable([
"signal": AnyCodable([
"configured": false,
"baseUrl": "http://127.0.0.1:8080",
"running": false,
@@ -141,7 +133,7 @@ struct ChannelsSettingsSmokeTests {
],
"lastProbeAt": 1_700_000_200_000,
]),
"imessage": SnapshotAnyCodable([
"imessage": AnyCodable([
"configured": false,
"running": false,
"lastError": "not configured",
@@ -11,19 +11,16 @@ struct CronJobEditorSmokeTests {
}
@Test func cronJobEditorBuildsBodyForNewJob() {
let channelsStore = ChannelsStore(isPreview: true)
let view = CronJobEditor(
job: nil,
isSaving: .constant(false),
error: .constant(nil),
channelsStore: channelsStore,
onCancel: {},
onSave: { _ in })
_ = view.body
}
@Test func cronJobEditorBuildsBodyForExistingJob() {
let channelsStore = ChannelsStore(isPreview: true)
let job = CronJob(
id: "job-1",
agentId: "ops",
@@ -57,36 +54,31 @@ struct CronJobEditorSmokeTests {
job: job,
isSaving: .constant(false),
error: .constant(nil),
channelsStore: channelsStore,
onCancel: {},
onSave: { _ in })
_ = view.body
}
@Test func cronJobEditorExercisesBuilders() {
let channelsStore = ChannelsStore(isPreview: true)
var view = CronJobEditor(
job: nil,
isSaving: .constant(false),
error: .constant(nil),
channelsStore: channelsStore,
onCancel: {},
onSave: { _ in })
view.exerciseForTesting()
}
@Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() throws {
let channelsStore = ChannelsStore(isPreview: true)
let view = CronJobEditor(
job: nil,
isSaving: .constant(false),
error: .constant(nil),
channelsStore: channelsStore,
onCancel: {},
onSave: { _ in })
var root: [String: Any] = [:]
view.applyDeleteAfterRun(to: &root, scheduleKind: CronJobEditor.ScheduleKind.at, deleteAfterRun: true)
view.applyDeleteAfterRun(to: &root, scheduleKind: .at, deleteAfterRun: true)
let raw = root["deleteAfterRun"] as? Bool
#expect(raw == true)
}
@@ -1,4 +1,3 @@
import ClawdbotKit
import Foundation
import os
import Testing
@@ -1,4 +1,3 @@
import ClawdbotKit
import Foundation
import os
import Testing
@@ -1,4 +1,3 @@
import ClawdbotKit
import Foundation
import os
import Testing
@@ -1,4 +1,3 @@
import ClawdbotKit
import Foundation
import os
import Testing
@@ -1,4 +1,3 @@
import ClawdbotKit
import Foundation
import Testing
@testable import Clawdbot
@@ -139,40 +139,4 @@ import Testing
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
#expect(resolved.mode == .remote)
}
@Test func resolveLocalGatewayHostPrefersTailnetForAuto() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "auto",
tailscaleIP: "100.64.1.2")
#expect(host == "100.64.1.2")
}
@Test func resolveLocalGatewayHostFallsBackToLoopbackForAuto() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "auto",
tailscaleIP: nil)
#expect(host == "127.0.0.1")
}
@Test func resolveLocalGatewayHostPrefersTailnetForTailnetMode() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "tailnet",
tailscaleIP: "100.64.1.5")
#expect(host == "100.64.1.5")
}
@Test func resolveLocalGatewayHostFallsBackToLoopbackForTailnetMode() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "tailnet",
tailscaleIP: nil)
#expect(host == "127.0.0.1")
}
@Test func resolveLocalGatewayHostUsesCustomBindHost() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "custom",
tailscaleIP: "100.64.1.9",
customBindHost: "192.168.1.10")
#expect(host == "192.168.1.10")
}
}
@@ -7,17 +7,15 @@ import Testing
@Suite(.serialized)
struct LowCoverageHelperTests {
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
@Test func anyCodableHelperAccessors() throws {
let payload: [String: ProtoAnyCodable] = [
"title": ProtoAnyCodable("Hello"),
"flag": ProtoAnyCodable(true),
"count": ProtoAnyCodable(3),
"ratio": ProtoAnyCodable(1.25),
"list": ProtoAnyCodable([ProtoAnyCodable("a"), ProtoAnyCodable(2)]),
let payload: [String: AnyCodable] = [
"title": AnyCodable("Hello"),
"flag": AnyCodable(true),
"count": AnyCodable(3),
"ratio": AnyCodable(1.25),
"list": AnyCodable([AnyCodable("a"), AnyCodable(2)]),
]
let any = ProtoAnyCodable(payload)
let any = AnyCodable(payload)
let dict = try #require(any.dictionaryValue)
#expect(dict["title"]?.stringValue == "Hello")
#expect(dict["flag"]?.boolValue == true)
@@ -78,27 +76,31 @@ struct LowCoverageHelperTests {
#expect(result.stderr.contains("stderr-1999"))
}
@Test func nodeInfoCodableRoundTrip() throws {
let info = NodeInfo(
@Test func pairedNodesStorePersists() async throws {
let dir = FileManager().temporaryDirectory
.appendingPathComponent("paired-\(UUID().uuidString)", isDirectory: true)
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
let url = dir.appendingPathComponent("nodes.json")
let store = PairedNodesStore(fileURL: url)
await store.load()
#expect(await store.all().isEmpty)
let node = PairedNode(
nodeId: "node-1",
displayName: "Node One",
platform: "macOS",
version: "1.0",
coreVersion: "1.0-core",
uiVersion: "1.0-ui",
deviceFamily: "Mac",
modelIdentifier: "MacBookPro",
remoteIp: "192.168.1.2",
caps: ["chat"],
commands: ["send"],
permissions: ["send": true],
paired: true,
connected: false)
let data = try JSONEncoder().encode(info)
let decoded = try JSONDecoder().decode(NodeInfo.self, from: data)
#expect(decoded.nodeId == "node-1")
#expect(decoded.isPaired == true)
#expect(decoded.isConnected == false)
token: "token",
createdAtMs: 1,
lastSeenAtMs: nil)
try await store.upsert(node)
#expect(await store.find(nodeId: "node-1")?.displayName == "Node One")
try await store.touchSeen(nodeId: "node-1")
let updated = await store.find(nodeId: "node-1")
#expect(updated?.lastSeenAtMs != nil)
}
@Test @MainActor func presenceReporterHelpers() {
@@ -21,7 +21,6 @@ import Testing
features: [:],
snapshot: snapshot,
canvashosturl: nil,
auth: nil,
policy: [:])
let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.snapshot(hello))
@@ -3,15 +3,13 @@ import SwiftUI
import Testing
@testable import Clawdbot
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
@Suite(.serialized)
@MainActor
struct OnboardingWizardStepViewTests {
@Test func noteStepBuilds() {
let step = WizardStep(
id: "step-1",
type: ProtoAnyCodable("note"),
type: AnyCodable("note"),
title: "Welcome",
message: "Hello",
options: nil,
@@ -24,17 +22,17 @@ struct OnboardingWizardStepViewTests {
}
@Test func selectStepBuilds() {
let options: [[String: ProtoAnyCodable]] = [
["value": ProtoAnyCodable("local"), "label": ProtoAnyCodable("Local"), "hint": ProtoAnyCodable("This Mac")],
["value": ProtoAnyCodable("remote"), "label": ProtoAnyCodable("Remote")],
let options: [[String: AnyCodable]] = [
["value": AnyCodable("local"), "label": AnyCodable("Local"), "hint": AnyCodable("This Mac")],
["value": AnyCodable("remote"), "label": AnyCodable("Remote")],
]
let step = WizardStep(
id: "step-2",
type: ProtoAnyCodable("select"),
type: AnyCodable("select"),
title: "Mode",
message: "Choose a mode",
options: options,
initialvalue: ProtoAnyCodable("local"),
initialvalue: AnyCodable("local"),
placeholder: nil,
sensitive: nil,
executor: nil)
@@ -15,9 +15,6 @@ extension URLSessionWebSocketTask: WebSocketTasking {}
public struct WebSocketTaskBox: @unchecked Sendable {
public let task: any WebSocketTasking
public init(task: any WebSocketTasking) {
self.task = task
}
public var state: URLSessionTask.State { self.task.state }
+1 -2
View File
@@ -23,11 +23,10 @@ clawdbot nodes approve <requestId>
clawdbot nodes status
```
`nodes list` prints pending/paired tables. Paired rows include the most recent connect age (Last Connect).
## Invoke / run
```bash
clawdbot nodes invoke --node <id|name|ip> --command <command> --params <json>
clawdbot nodes run --node <id|name|ip> <command...>
```
-4
View File
@@ -1774,7 +1774,6 @@ Note: `applyPatch` is only under `tools.exec`.
- `tools.web.fetch.maxChars` (default 50000)
- `tools.web.fetch.timeoutSeconds` (default 30)
- `tools.web.fetch.cacheTtlMinutes` (default 15)
- `tools.web.fetch.maxRedirects` (default 3)
- `tools.web.fetch.userAgent` (optional override)
- `tools.web.fetch.readability` (default true; disable to use basic HTML cleanup only)
- `tools.web.fetch.firecrawl.enabled` (default true when an API key is set)
@@ -2615,13 +2614,10 @@ Defaults:
// noSandbox: false,
// executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
// attachOnly: false, // set true when tunneling a remote CDP to localhost
// snapshotDefaults: { mode: "efficient" }, // tool/CLI default snapshot preset
}
}
```
Note: `browser.snapshotDefaults` only affects Clawdbot's browser tool + CLI. Direct HTTP clients must pass `mode` explicitly.
### `ui` (Appearance)
Optional accent color used by the native apps for UI chrome (e.g. Talk Mode bubble tint).
-1
View File
@@ -500,7 +500,6 @@ Notes:
- `--format ai` (default when Playwright is installed): returns an AI snapshot with numeric refs (`aria-ref="<n>"`).
- `--format aria`: returns the accessibility tree (no refs; inspection only).
- `--efficient` (or `--mode efficient`): compact role snapshot preset (interactive + compact + depth + lower maxChars).
- Config default (tool/CLI only): set `browser.snapshotDefaults.mode: "efficient"` to use efficient snapshots when the caller does not pass a mode (see [Gateway configuration](/gateway/configuration#browser-clawd-managed-browser)).
- Role snapshot options (`--interactive`, `--compact`, `--depth`, `--selector`) force a role-based snapshot with refs like `ref=e12`.
- `--frame "<iframe selector>"` scopes role snapshots to an iframe (pairs with role refs like `e12`).
- `--interactive` outputs a flat, easy-to-pick list of interactive elements (best for driving actions).
+1 -1
View File
@@ -39,7 +39,7 @@ Notes:
- `tools.exec.notifyOnExit` (default: true): when true, backgrounded exec sessions enqueue a system event and request a heartbeat on exit.
- `tools.exec.host` (default: `sandbox`)
- `tools.exec.security` (default: `deny` for sandbox, `allowlist` for gateway + node when unset)
- `tools.exec.security` (default: `deny`)
- `tools.exec.ask` (default: `on-miss`)
- `tools.exec.node` (default: unset)
- `tools.exec.pathPrepend`: list of directories to prepend to `PATH` for exec runs.
-2
View File
@@ -215,7 +215,6 @@ Fetch a URL and extract readable content.
maxChars: 50000,
timeoutSeconds: 30,
cacheTtlMinutes: 15,
maxRedirects: 3,
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
readability: true,
firecrawl: {
@@ -242,7 +241,6 @@ Notes:
- `web_fetch` uses Readability (main-content extraction) first, then Firecrawl (if configured). If both fail, the tool returns an error.
- Firecrawl requests use bot-circumvention mode and cache results by default.
- `web_fetch` sends a Chrome-like User-Agent and `Accept-Language` by default; override `userAgent` if needed.
- `web_fetch` blocks private/internal hostnames and re-checks redirects (limit with `maxRedirects`).
- `web_fetch` is best-effort extraction; some sites will need the browser tool.
- See [Firecrawl](/tools/firecrawl) for key setup and service details.
- Responses are cached (default 15 minutes) to reduce repeated fetches.
+1
View File
@@ -110,6 +110,7 @@ const writeBuildStamp = () => {
};
if (!shouldBuild()) {
logRunner("Skipping build; dist is fresh.");
runNode();
} else {
logRunner("Building TypeScript (dist is stale).");
+3 -6
View File
@@ -400,7 +400,7 @@ export function createExecTool(
host = "gateway";
}
const configuredSecurity = defaults?.security ?? (host === "sandbox" ? "deny" : "allowlist");
const configuredSecurity = defaults?.security ?? "deny";
const requestedSecurity = normalizeExecSecurity(params.security);
let security = minSecurity(configuredSecurity, requestedSecurity ?? configuredSecurity);
if (elevatedRequested) {
@@ -447,10 +447,7 @@ export function createExecTool(
applyPathPrepend(env, defaultPathPrepend);
if (host === "node") {
const approvals = resolveExecApprovals(
defaults?.agentId,
host === "node" ? { security: "allowlist" } : undefined,
);
const approvals = resolveExecApprovals(defaults?.agentId);
const hostSecurity = minSecurity(security, approvals.agent.security);
const hostAsk = maxAsk(ask, approvals.agent.ask);
const askFallback = approvals.agent.askFallback;
@@ -619,7 +616,7 @@ export function createExecTool(
}
if (host === "gateway") {
const approvals = resolveExecApprovals(defaults?.agentId, { security: "allowlist" });
const approvals = resolveExecApprovals(defaults?.agentId);
const hostSecurity = minSecurity(security, approvals.agent.security);
const hostAsk = maxAsk(ask, approvals.agent.ask);
const askFallback = approvals.agent.askFallback;
+7 -37
View File
@@ -49,10 +49,9 @@ const browserConfigMocks = vi.hoisted(() => ({
}));
vi.mock("../../browser/config.js", () => browserConfigMocks);
const configMocks = vi.hoisted(() => ({
vi.mock("../../config/config.js", () => ({
loadConfig: vi.fn(() => ({ browser: {} })),
}));
vi.mock("../../config/config.js", () => configMocks);
const toolCommonMocks = vi.hoisted(() => ({
imageResultFromFile: vi.fn(),
@@ -71,12 +70,11 @@ import { createBrowserTool } from "./browser-tool.js";
describe("browser tool snapshot maxChars", () => {
afterEach(() => {
vi.clearAllMocks();
configMocks.loadConfig.mockReturnValue({ browser: {} });
});
it("applies the default ai snapshot limit", async () => {
const tool = createBrowserTool();
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai" });
await tool.execute?.(null, { action: "snapshot", format: "ai" });
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
"http://127.0.0.1:18791",
@@ -92,7 +90,7 @@ describe("browser tool snapshot maxChars", () => {
const override = 2_000;
await tool.execute?.(null, {
action: "snapshot",
snapshotFormat: "ai",
format: "ai",
maxChars: override,
});
@@ -108,7 +106,7 @@ describe("browser tool snapshot maxChars", () => {
const tool = createBrowserTool();
await tool.execute?.(null, {
action: "snapshot",
snapshotFormat: "ai",
format: "ai",
maxChars: 0,
});
@@ -126,7 +124,7 @@ describe("browser tool snapshot maxChars", () => {
it("passes refs mode through to browser snapshot", async () => {
const tool = createBrowserTool();
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai", refs: "aria" });
await tool.execute?.(null, { action: "snapshot", format: "ai", refs: "aria" });
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
"http://127.0.0.1:18791",
@@ -137,36 +135,9 @@ describe("browser tool snapshot maxChars", () => {
);
});
it("uses config snapshot defaults when mode is not provided", async () => {
configMocks.loadConfig.mockReturnValue({
browser: { snapshotDefaults: { mode: "efficient" } },
});
const tool = createBrowserTool();
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai" });
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
"http://127.0.0.1:18791",
expect.objectContaining({
mode: "efficient",
}),
);
});
it("does not apply config snapshot defaults to aria snapshots", async () => {
configMocks.loadConfig.mockReturnValue({
browser: { snapshotDefaults: { mode: "efficient" } },
});
const tool = createBrowserTool();
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "aria" });
expect(browserClientMocks.browserSnapshot).toHaveBeenCalled();
const [, opts] = browserClientMocks.browserSnapshot.mock.calls.at(-1) ?? [];
expect(opts?.mode).toBeUndefined();
});
it("defaults to host when using profile=chrome (even in sandboxed sessions)", async () => {
const tool = createBrowserTool({ defaultControlUrl: "http://127.0.0.1:9999" });
await tool.execute?.(null, { action: "snapshot", profile: "chrome", snapshotFormat: "ai" });
await tool.execute?.(null, { action: "snapshot", profile: "chrome", format: "ai" });
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
"http://127.0.0.1:18791",
@@ -180,7 +151,6 @@ describe("browser tool snapshot maxChars", () => {
describe("browser tool snapshot labels", () => {
afterEach(() => {
vi.clearAllMocks();
configMocks.loadConfig.mockReturnValue({ browser: {} });
});
it("returns image + text when labels are requested", async () => {
@@ -205,7 +175,7 @@ describe("browser tool snapshot labels", () => {
const result = await tool.execute?.(null, {
action: "snapshot",
snapshotFormat: "ai",
format: "ai",
labels: true,
});
+1 -7
View File
@@ -190,17 +190,11 @@ export function createBrowserTool(opts?: {
return jsonResult({ ok: true });
}
case "snapshot": {
const snapshotDefaults = loadConfig().browser?.snapshotDefaults;
const format =
params.snapshotFormat === "ai" || params.snapshotFormat === "aria"
? (params.snapshotFormat as "ai" | "aria")
: "ai";
const mode =
params.mode === "efficient"
? "efficient"
: format === "ai" && snapshotDefaults?.mode === "efficient"
? "efficient"
: undefined;
const mode = params.mode === "efficient" ? "efficient" : undefined;
const labels = typeof params.labels === "boolean" ? params.labels : undefined;
const refs = params.refs === "aria" || params.refs === "role" ? params.refs : undefined;
const hasMaxChars = Object.hasOwn(params, "maxChars");
-160
View File
@@ -1,160 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const lookupMock = vi.fn();
vi.mock("node:dns/promises", () => ({
lookup: lookupMock,
}));
function makeHeaders(map: Record<string, string>): { get: (key: string) => string | null } {
return {
get: (key) => map[key.toLowerCase()] ?? null,
};
}
function redirectResponse(location: string): Response {
return {
ok: false,
status: 302,
headers: makeHeaders({ location }),
body: { cancel: vi.fn() },
} as Response;
}
function textResponse(body: string): Response {
return {
ok: true,
status: 200,
headers: makeHeaders({ "content-type": "text/plain" }),
text: async () => body,
} as Response;
}
describe("web_fetch SSRF protection", () => {
const priorFetch = global.fetch;
afterEach(() => {
// @ts-expect-error restore
global.fetch = priorFetch;
lookupMock.mockReset();
vi.restoreAllMocks();
});
it("blocks localhost hostnames before fetch/firecrawl", async () => {
const fetchSpy = vi.fn();
// @ts-expect-error mock fetch
global.fetch = fetchSpy;
const { createWebFetchTool } = await import("./web-tools.js");
const tool = createWebFetchTool({
config: {
tools: {
web: {
fetch: {
cacheTtlMinutes: 0,
firecrawl: { apiKey: "firecrawl-test" },
},
},
},
},
});
await expect(tool?.execute?.("call", { url: "http://localhost/test" })).rejects.toThrow(
/Blocked hostname/i,
);
expect(fetchSpy).not.toHaveBeenCalled();
expect(lookupMock).not.toHaveBeenCalled();
});
it("blocks private IP literals without DNS", async () => {
const fetchSpy = vi.fn();
// @ts-expect-error mock fetch
global.fetch = fetchSpy;
const { createWebFetchTool } = await import("./web-tools.js");
const tool = createWebFetchTool({
config: {
tools: { web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false } } } },
},
});
await expect(tool?.execute?.("call", { url: "http://127.0.0.1/test" })).rejects.toThrow(
/private|internal|blocked/i,
);
await expect(tool?.execute?.("call", { url: "http://[::ffff:127.0.0.1]/" })).rejects.toThrow(
/private|internal|blocked/i,
);
expect(fetchSpy).not.toHaveBeenCalled();
expect(lookupMock).not.toHaveBeenCalled();
});
it("blocks when DNS resolves to private addresses", async () => {
lookupMock.mockImplementation(async (hostname: string) => {
if (hostname === "public.test") {
return [{ address: "93.184.216.34", family: 4 }];
}
return [{ address: "10.0.0.5", family: 4 }];
});
const fetchSpy = vi.fn();
// @ts-expect-error mock fetch
global.fetch = fetchSpy;
const { createWebFetchTool } = await import("./web-tools.js");
const tool = createWebFetchTool({
config: {
tools: { web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false } } } },
},
});
await expect(tool?.execute?.("call", { url: "https://private.test/resource" })).rejects.toThrow(
/private|internal|blocked/i,
);
expect(fetchSpy).not.toHaveBeenCalled();
});
it("blocks redirects to private hosts", async () => {
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
const fetchSpy = vi.fn().mockResolvedValueOnce(redirectResponse("http://127.0.0.1/secret"));
// @ts-expect-error mock fetch
global.fetch = fetchSpy;
const { createWebFetchTool } = await import("./web-tools.js");
const tool = createWebFetchTool({
config: {
tools: {
web: {
fetch: { cacheTtlMinutes: 0, firecrawl: { apiKey: "firecrawl-test" } },
},
},
},
});
await expect(tool?.execute?.("call", { url: "https://example.com" })).rejects.toThrow(
/private|internal|blocked/i,
);
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it("allows public hosts", async () => {
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
const fetchSpy = vi.fn().mockResolvedValue(textResponse("ok"));
// @ts-expect-error mock fetch
global.fetch = fetchSpy;
const { createWebFetchTool } = await import("./web-tools.js");
const tool = createWebFetchTool({
config: {
tools: { web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false } } } },
},
});
const result = await tool?.execute?.("call", { url: "https://example.com" });
expect(result?.details).toMatchObject({
status: 200,
extractor: "raw",
});
});
});
+14 -88
View File
@@ -1,7 +1,6 @@
import { Type } from "@sinclair/typebox";
import type { ClawdbotConfig } from "../../config/config.js";
import { assertPublicHostname, SsrFBlockedError } from "../../infra/net/ssrf.js";
import { stringEnum } from "../schema/typebox.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult, readNumberParam, readStringParam } from "./common.js";
@@ -30,7 +29,6 @@ export { extractReadableContent } from "./web-fetch-utils.js";
const EXTRACT_MODES = ["markdown", "text"] as const;
const DEFAULT_FETCH_MAX_CHARS = 50_000;
const DEFAULT_FETCH_MAX_REDIRECTS = 3;
const DEFAULT_ERROR_MAX_CHARS = 4_000;
const DEFAULT_FIRECRAWL_BASE_URL = "https://api.firecrawl.dev";
const DEFAULT_FIRECRAWL_MAX_AGE_MS = 172_800_000;
@@ -146,11 +144,6 @@ function resolveMaxChars(value: unknown, fallback: number): number {
return Math.max(100, Math.floor(parsed));
}
function resolveMaxRedirects(value: unknown, fallback: number): number {
const parsed = typeof value === "number" && Number.isFinite(value) ? value : fallback;
return Math.max(0, Math.floor(parsed));
}
function looksLikeHtml(value: string): boolean {
const trimmed = value.trimStart();
if (!trimmed) return false;
@@ -158,68 +151,6 @@ function looksLikeHtml(value: string): boolean {
return head.startsWith("<!doctype html") || head.startsWith("<html");
}
function isRedirectStatus(status: number): boolean {
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
}
async function fetchWithRedirects(params: {
url: string;
maxRedirects: number;
timeoutSeconds: number;
userAgent: string;
}): Promise<{ response: Response; finalUrl: string }> {
const signal = withTimeout(undefined, params.timeoutSeconds * 1000);
const visited = new Set<string>();
let currentUrl = params.url;
let redirectCount = 0;
while (true) {
let parsedUrl: URL;
try {
parsedUrl = new URL(currentUrl);
} catch {
throw new Error("Invalid URL: must be http or https");
}
if (!["http:", "https:"].includes(parsedUrl.protocol)) {
throw new Error("Invalid URL: must be http or https");
}
await assertPublicHostname(parsedUrl.hostname);
const res = await fetch(parsedUrl.toString(), {
method: "GET",
headers: {
Accept: "*/*",
"User-Agent": params.userAgent,
"Accept-Language": "en-US,en;q=0.9",
},
signal,
redirect: "manual",
});
if (isRedirectStatus(res.status)) {
const location = res.headers.get("location");
if (!location) {
throw new Error(`Redirect missing location header (${res.status})`);
}
redirectCount += 1;
if (redirectCount > params.maxRedirects) {
throw new Error(`Too many redirects (limit: ${params.maxRedirects})`);
}
const nextUrl = new URL(location, parsedUrl).toString();
if (visited.has(nextUrl)) {
throw new Error("Redirect loop detected");
}
visited.add(nextUrl);
void res.body?.cancel();
currentUrl = nextUrl;
continue;
}
return { response: res, finalUrl: currentUrl };
}
}
function formatWebFetchErrorDetail(params: {
detail: string;
contentType?: string | null;
@@ -316,7 +247,6 @@ async function runWebFetch(params: {
url: string;
extractMode: ExtractMode;
maxChars: number;
maxRedirects: number;
timeoutSeconds: number;
cacheTtlMs: number;
userAgent: string;
@@ -348,23 +278,20 @@ async function runWebFetch(params: {
const start = Date.now();
let res: Response;
let finalUrl = params.url;
try {
const result = await fetchWithRedirects({
url: params.url,
maxRedirects: params.maxRedirects,
timeoutSeconds: params.timeoutSeconds,
userAgent: params.userAgent,
res = await fetch(parsedUrl.toString(), {
method: "GET",
headers: {
Accept: "*/*",
"User-Agent": params.userAgent,
"Accept-Language": "en-US,en;q=0.9",
},
signal: withTimeout(undefined, params.timeoutSeconds * 1000),
});
res = result.response;
finalUrl = result.finalUrl;
} catch (error) {
if (error instanceof SsrFBlockedError) {
throw error;
}
if (params.firecrawlEnabled && params.firecrawlApiKey) {
const firecrawl = await fetchFirecrawlContent({
url: finalUrl,
url: params.url,
extractMode: params.extractMode,
apiKey: params.firecrawlApiKey,
baseUrl: params.firecrawlBaseUrl,
@@ -377,7 +304,7 @@ async function runWebFetch(params: {
const truncated = truncateText(firecrawl.text, params.maxChars);
const payload = {
url: params.url,
finalUrl: firecrawl.finalUrl || finalUrl,
finalUrl: firecrawl.finalUrl || params.url,
status: firecrawl.status ?? 200,
contentType: "text/markdown",
title: firecrawl.title,
@@ -412,7 +339,7 @@ async function runWebFetch(params: {
const truncated = truncateText(firecrawl.text, params.maxChars);
const payload = {
url: params.url,
finalUrl: firecrawl.finalUrl || finalUrl,
finalUrl: firecrawl.finalUrl || params.url,
status: firecrawl.status ?? res.status,
contentType: "text/markdown",
title: firecrawl.title,
@@ -447,7 +374,7 @@ async function runWebFetch(params: {
if (params.readabilityEnabled) {
const readable = await extractReadableContent({
html: body,
url: finalUrl,
url: res.url || params.url,
extractMode: params.extractMode,
});
if (readable?.text) {
@@ -455,7 +382,7 @@ async function runWebFetch(params: {
title = readable.title;
extractor = "readability";
} else {
const firecrawl = await tryFirecrawlFallback({ ...params, url: finalUrl });
const firecrawl = await tryFirecrawlFallback(params);
if (firecrawl) {
text = firecrawl.text;
title = firecrawl.title;
@@ -484,7 +411,7 @@ async function runWebFetch(params: {
const truncated = truncateText(text, params.maxChars);
const payload = {
url: params.url,
finalUrl,
finalUrl: res.url || params.url,
status: res.status,
contentType,
title,
@@ -581,7 +508,6 @@ export function createWebFetchTool(options?: {
url,
extractMode,
maxChars: resolveMaxChars(maxChars ?? fetch?.maxChars, DEFAULT_FETCH_MAX_CHARS),
maxRedirects: resolveMaxRedirects(fetch?.maxRedirects, DEFAULT_FETCH_MAX_REDIRECTS),
timeoutSeconds: resolveTimeoutSeconds(fetch?.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS),
cacheTtlMs: resolveCacheTtlMs(fetch?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES),
userAgent,
@@ -1,149 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { TemplateContext } from "../templating.js";
import type { FollowupRun, QueueSettings } from "./queue.js";
import { createMockTypingController } from "./test-helpers.js";
const runEmbeddedPiAgentMock = vi.fn();
vi.mock("../../agents/model-fallback.js", () => ({
runWithModelFallback: async ({
run,
}: {
run: (provider: string, model: string) => Promise<unknown>;
}) => ({
// Force a cross-provider fallback candidate
result: await run("openai-codex", "gpt-5.2"),
provider: "openai-codex",
model: "gpt-5.2",
}),
}));
vi.mock("../../agents/pi-embedded.js", () => ({
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params),
}));
vi.mock("./queue.js", async () => {
const actual = await vi.importActual<typeof import("./queue.js")>("./queue.js");
return {
...actual,
enqueueFollowupRun: vi.fn(),
scheduleFollowupDrain: vi.fn(),
};
});
import { runReplyAgent } from "./agent-runner.js";
function createBaseRun(params: { runOverrides?: Partial<FollowupRun["run"]> }) {
const typing = createMockTypingController();
const sessionCtx = {
Provider: "telegram",
OriginatingTo: "chat",
AccountId: "primary",
MessageSid: "msg",
Surface: "telegram",
} as unknown as TemplateContext;
const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings;
const followupRun = {
prompt: "hello",
summaryLine: "hello",
enqueuedAt: Date.now(),
run: {
agentId: "main",
agentDir: "/tmp/agent",
sessionId: "session",
sessionKey: "main",
messageProvider: "telegram",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
config: {},
skillsSnapshot: {},
provider: "anthropic",
model: "claude-opus",
authProfileId: "anthropic:clawd",
authProfileIdSource: "user",
thinkLevel: "low",
verboseLevel: "off",
elevatedLevel: "off",
bashElevated: {
enabled: false,
allowed: false,
defaultLevel: "off",
},
timeoutMs: 5_000,
blockReplyBreak: "message_end",
},
} as unknown as FollowupRun;
return {
typing,
sessionCtx,
resolvedQueue,
followupRun: {
...followupRun,
run: { ...followupRun.run, ...params.runOverrides },
},
};
}
describe("authProfileId fallback scoping", () => {
it("drops authProfileId when provider changes during fallback", async () => {
runEmbeddedPiAgentMock.mockReset();
runEmbeddedPiAgentMock.mockResolvedValue({ payloads: [{ text: "ok" }], meta: {} });
const sessionKey = "main";
const sessionEntry = {
sessionId: "session",
updatedAt: Date.now(),
totalTokens: 1,
compactionCount: 0,
};
const { typing, sessionCtx, resolvedQueue, followupRun } = createBaseRun({
runOverrides: {
provider: "anthropic",
model: "claude-opus",
authProfileId: "anthropic:clawd",
authProfileIdSource: "user",
},
});
await runReplyAgent({
commandBody: "hello",
followupRun,
queueKey: sessionKey,
resolvedQueue,
shouldSteer: false,
shouldFollowup: false,
isActive: false,
isStreaming: false,
typing,
sessionCtx,
sessionEntry,
sessionStore: { [sessionKey]: sessionEntry },
sessionKey,
storePath: undefined,
defaultModel: "anthropic/claude-opus-4-5",
agentCfgContextTokens: 100_000,
resolvedVerboseLevel: "off",
isNewSession: false,
blockStreamingEnabled: false,
resolvedBlockStreamingBreak: "message_end",
shouldInjectGroupIntro: false,
typingMode: "instant",
});
expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1);
const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0] as {
authProfileId?: unknown;
authProfileIdSource?: unknown;
provider?: unknown;
};
expect(call.provider).toBe("openai-codex");
expect(call.authProfileId).toBeUndefined();
expect(call.authProfileIdSource).toBeUndefined();
});
});
+3 -1
View File
@@ -184,7 +184,9 @@ export async function buildStatusReply(params: {
const requesterKey = resolveInternalSessionKey({ key: sessionKey, alias, mainKey });
const runs = listSubagentRunsForRequester(requesterKey);
const verboseEnabled = resolvedVerboseLevel && resolvedVerboseLevel !== "off";
if (runs.length > 0) {
if (runs.length === 0) {
if (verboseEnabled) subagentsLine = "🤖 Subagents: none";
} else {
const active = runs.filter((entry) => !entry.endedAt);
const done = runs.length - active.length;
if (verboseEnabled) {
-14
View File
@@ -215,20 +215,6 @@ describe("handleCommands subagents", () => {
expect(result.reply?.text).toContain("Subagents: none");
});
it("omits subagent status line when none exist", async () => {
resetSubagentRegistryForTests();
const cfg = {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
session: { mainKey: "main", scope: "per-sender" },
} as ClawdbotConfig;
const params = buildParams("/status", cfg);
params.resolvedVerboseLevel = "on";
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).not.toContain("Subagents:");
});
it("returns help for unknown subagents action", async () => {
resetSubagentRegistryForTests();
const cfg = {
-16
View File
@@ -143,22 +143,6 @@ describe("buildStatusMessage", () => {
expect(normalized).toContain("Media: image ok (openai/gpt-5.2) · audio skipped (maxBytes)");
});
it("omits media line when all decisions are none", () => {
const text = buildStatusMessage({
agent: { model: "anthropic/claude-opus-4-5" },
sessionEntry: { sessionId: "media-none", updatedAt: 0 },
sessionKey: "agent:main:main",
queue: { mode: "none" },
mediaDecisions: [
{ capability: "image", outcome: "no-attachment", attachments: [] },
{ capability: "audio", outcome: "no-attachment", attachments: [] },
{ capability: "video", outcome: "no-attachment", attachments: [] },
],
});
expect(normalizeTestText(text)).not.toContain("Media:");
});
it("does not show elevated label when session explicitly disables it", () => {
const text = buildStatusMessage({
agent: { model: "anthropic/claude-opus-4-5", elevatedDefault: "on" },
+1 -2
View File
@@ -201,9 +201,8 @@ const formatMediaUnderstandingLine = (decisions?: MediaUnderstandingDecision[])
}
return null;
})
.filter((part): part is string => part != null);
.filter(Boolean);
if (parts.length === 0) return null;
if (parts.every((part) => part.endsWith(" none"))) return null;
return `📎 Media: ${parts.join(" · ")}`;
};
-78
View File
@@ -1,78 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { Command } from "commander";
const clientMocks = vi.hoisted(() => ({
browserSnapshot: vi.fn(async () => ({
ok: true,
format: "ai",
targetId: "t1",
url: "https://example.com",
snapshot: "ok",
})),
resolveBrowserControlUrl: vi.fn(() => "http://127.0.0.1:18791"),
}));
vi.mock("../browser/client.js", () => clientMocks);
const configMocks = vi.hoisted(() => ({
loadConfig: vi.fn(() => ({ browser: {} })),
}));
vi.mock("../config/config.js", () => configMocks);
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
vi.mock("../runtime.js", () => ({
defaultRuntime: runtime,
}));
describe("browser cli snapshot defaults", () => {
afterEach(() => {
vi.clearAllMocks();
configMocks.loadConfig.mockReturnValue({ browser: {} });
});
it("uses config snapshot defaults when mode is not provided", async () => {
configMocks.loadConfig.mockReturnValue({
browser: { snapshotDefaults: { mode: "efficient" } },
});
const { registerBrowserInspectCommands } = await import("./browser-cli-inspect.js");
const program = new Command();
const browser = program.command("browser").option("--json", false);
registerBrowserInspectCommands(browser, () => ({}));
await program.parseAsync(["browser", "snapshot"], { from: "user" });
expect(clientMocks.browserSnapshot).toHaveBeenCalledWith(
"http://127.0.0.1:18791",
expect.objectContaining({
format: "ai",
mode: "efficient",
}),
);
});
it("does not apply config snapshot defaults to aria snapshots", async () => {
configMocks.loadConfig.mockReturnValue({
browser: { snapshotDefaults: { mode: "efficient" } },
});
clientMocks.browserSnapshot.mockResolvedValueOnce({
ok: true,
format: "aria",
targetId: "t1",
url: "https://example.com",
nodes: [],
});
const { registerBrowserInspectCommands } = await import("./browser-cli-inspect.js");
const program = new Command();
const browser = program.command("browser").option("--json", false);
registerBrowserInspectCommands(browser, () => ({}));
await program.parseAsync(["browser", "snapshot", "--format", "aria"], { from: "user" });
expect(clientMocks.browserSnapshot).toHaveBeenCalled();
const [, opts] = clientMocks.browserSnapshot.mock.calls.at(-1) ?? [];
expect(opts?.mode).toBeUndefined();
});
});
+1 -6
View File
@@ -2,7 +2,6 @@ import type { Command } from "commander";
import { browserSnapshot, resolveBrowserControlUrl } from "../browser/client.js";
import { browserScreenshotAction } from "../browser/client-actions.js";
import { loadConfig } from "../config/config.js";
import { danger } from "../globals.js";
import { defaultRuntime } from "../runtime.js";
import type { BrowserParentOpts } from "./browser-cli-shared.js";
@@ -63,11 +62,7 @@ export function registerBrowserInspectCommands(
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
const format = opts.format === "aria" ? "aria" : "ai";
const configMode =
format === "ai" && loadConfig().browser?.snapshotDefaults?.mode === "efficient"
? "efficient"
: undefined;
const mode = opts.efficient === true || opts.mode === "efficient" ? "efficient" : configMode;
const mode = opts.efficient === true || opts.mode === "efficient" ? "efficient" : undefined;
try {
const result = await browserSnapshot(baseUrl, {
format,
-124
View File
@@ -246,128 +246,4 @@ describe("cron cli", () => {
expect(patch?.patch?.payload?.kind).toBe("agentTurn");
expect(patch?.patch?.payload?.deliver).toBe(false);
});
it("does not include undefined delivery fields when updating message", async () => {
callGatewayFromCli.mockClear();
const { registerCronCli } = await import("./cron-cli.js");
const program = new Command();
program.exitOverride();
registerCronCli(program);
// Update message without delivery flags - should NOT include undefined delivery fields
await program.parseAsync(["cron", "edit", "job-1", "--message", "Updated message"], {
from: "user",
});
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
const patch = updateCall?.[2] as {
patch?: {
payload?: {
message?: string;
deliver?: boolean;
channel?: string;
to?: string;
bestEffortDeliver?: boolean;
};
};
};
// Should include the new message
expect(patch?.patch?.payload?.message).toBe("Updated message");
// Should NOT include delivery fields at all (to preserve existing values)
expect(patch?.patch?.payload).not.toHaveProperty("deliver");
expect(patch?.patch?.payload).not.toHaveProperty("channel");
expect(patch?.patch?.payload).not.toHaveProperty("to");
expect(patch?.patch?.payload).not.toHaveProperty("bestEffortDeliver");
});
it("includes delivery fields when explicitly provided with message", async () => {
callGatewayFromCli.mockClear();
const { registerCronCli } = await import("./cron-cli.js");
const program = new Command();
program.exitOverride();
registerCronCli(program);
// Update message AND delivery - should include both
await program.parseAsync(
[
"cron",
"edit",
"job-1",
"--message",
"Updated message",
"--deliver",
"--channel",
"telegram",
"--to",
"19098680",
],
{ from: "user" },
);
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
const patch = updateCall?.[2] as {
patch?: {
payload?: {
message?: string;
deliver?: boolean;
channel?: string;
to?: string;
};
};
};
// Should include everything
expect(patch?.patch?.payload?.message).toBe("Updated message");
expect(patch?.patch?.payload?.deliver).toBe(true);
expect(patch?.patch?.payload?.channel).toBe("telegram");
expect(patch?.patch?.payload?.to).toBe("19098680");
});
it("includes best-effort delivery when provided with message", async () => {
callGatewayFromCli.mockClear();
const { registerCronCli } = await import("./cron-cli.js");
const program = new Command();
program.exitOverride();
registerCronCli(program);
await program.parseAsync(
["cron", "edit", "job-1", "--message", "Updated message", "--best-effort-deliver"],
{ from: "user" },
);
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
const patch = updateCall?.[2] as {
patch?: { payload?: { message?: string; bestEffortDeliver?: boolean } };
};
expect(patch?.patch?.payload?.message).toBe("Updated message");
expect(patch?.patch?.payload?.bestEffortDeliver).toBe(true);
});
it("includes no-best-effort delivery when provided with message", async () => {
callGatewayFromCli.mockClear();
const { registerCronCli } = await import("./cron-cli.js");
const program = new Command();
program.exitOverride();
registerCronCli(program);
await program.parseAsync(
["cron", "edit", "job-1", "--message", "Updated message", "--no-best-effort-deliver"],
{ from: "user" },
);
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
const patch = updateCall?.[2] as {
patch?: { payload?: { message?: string; bestEffortDeliver?: boolean } };
};
expect(patch?.patch?.payload?.message).toBe("Updated message");
expect(patch?.patch?.payload?.bestEffortDeliver).toBe(false);
});
});
+12 -22
View File
@@ -10,15 +10,6 @@ import {
warnIfCronSchedulerDisabled,
} from "./shared.js";
const assignIf = (
target: Record<string, unknown>,
key: string,
value: unknown,
shouldAssign: boolean,
) => {
if (shouldAssign) target[key] = value;
};
export function registerCronEditCommand(cron: Command) {
addGatewayClientOptions(
cron
@@ -145,19 +136,18 @@ export function registerCronEditCommand(cron: Command) {
};
} else if (hasAgentTurnPatch) {
const payload: Record<string, unknown> = { kind: "agentTurn" };
assignIf(payload, "message", String(opts.message), typeof opts.message === "string");
assignIf(payload, "model", model, Boolean(model));
assignIf(payload, "thinking", thinking, Boolean(thinking));
assignIf(payload, "timeoutSeconds", timeoutSeconds, hasTimeoutSeconds);
assignIf(payload, "deliver", opts.deliver, typeof opts.deliver === "boolean");
assignIf(payload, "channel", opts.channel, typeof opts.channel === "string");
assignIf(payload, "to", opts.to, typeof opts.to === "string");
assignIf(
payload,
"bestEffortDeliver",
opts.bestEffortDeliver,
typeof opts.bestEffortDeliver === "boolean",
);
if (typeof opts.message === "string") payload.message = String(opts.message);
if (model) payload.model = model;
if (thinking) payload.thinking = thinking;
if (hasTimeoutSeconds) {
payload.timeoutSeconds = timeoutSeconds;
}
if (typeof opts.deliver === "boolean") payload.deliver = opts.deliver;
if (typeof opts.channel === "string") payload.channel = opts.channel;
if (typeof opts.to === "string") payload.to = opts.to;
if (typeof opts.bestEffortDeliver === "boolean") {
payload.bestEffortDeliver = opts.bestEffortDeliver;
}
patch.payload = payload;
}
+4 -114
View File
@@ -5,8 +5,7 @@ import type { Command } from "commander";
import type { ExecApprovalsAgent, ExecApprovalsFile } from "../infra/exec-approvals.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { isRich, theme } from "../terminal/theme.js";
import { renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js";
import { callGatewayFromCli } from "./gateway-rpc.js";
import { nodesCallOpts, resolveNodeId } from "./nodes-cli/rpc.js";
import type { NodesRpcOpts } from "./nodes-cli/types.js";
@@ -25,17 +24,6 @@ type ExecApprovalsCliOpts = NodesRpcOpts & {
agent?: string;
};
function formatAge(msAgo: number) {
const s = Math.max(0, Math.floor(msAgo / 1000));
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h`;
const d = Math.floor(h / 24);
return `${d}d`;
}
async function readStdin(): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
@@ -101,9 +89,6 @@ function isEmptyAgent(agent: ExecApprovalsAgent): boolean {
}
export function registerExecApprovalsCli(program: Command) {
const formatExample = (cmd: string, desc: string) =>
` ${theme.command(cmd)}\n ${theme.muted(desc)}`;
const approvals = program
.command("approvals")
.alias("exec-approvals")
@@ -121,87 +106,8 @@ export function registerExecApprovalsCli(program: Command) {
.action(async (opts: ExecApprovalsCliOpts) => {
const nodeId = await resolveTargetNodeId(opts);
const snapshot = await loadSnapshot(opts, nodeId);
if (opts.json) {
defaultRuntime.log(JSON.stringify(snapshot));
return;
}
const rich = isRich();
const heading = (text: string) => (rich ? theme.heading(text) : text);
const muted = (text: string) => (rich ? theme.muted(text) : text);
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
const file = snapshot.file ?? { version: 1 };
const defaults = file.defaults ?? {};
const defaultsParts = [
defaults.security ? `security=${defaults.security}` : null,
defaults.ask ? `ask=${defaults.ask}` : null,
defaults.askFallback ? `askFallback=${defaults.askFallback}` : null,
typeof defaults.autoAllowSkills === "boolean"
? `autoAllowSkills=${defaults.autoAllowSkills ? "on" : "off"}`
: null,
].filter(Boolean) as string[];
const agents = file.agents ?? {};
const allowlistRows: Array<{ Agent: string; Pattern: string; LastUsed: string }> = [];
const now = Date.now();
for (const [agentId, agent] of Object.entries(agents)) {
const allowlist = Array.isArray(agent.allowlist) ? agent.allowlist : [];
for (const entry of allowlist) {
const pattern = entry?.pattern?.trim() ?? "";
if (!pattern) continue;
const lastUsedAt = typeof entry.lastUsedAt === "number" ? entry.lastUsedAt : null;
allowlistRows.push({
Agent: agentId,
Pattern: pattern,
LastUsed: lastUsedAt
? `${formatAge(Math.max(0, now - lastUsedAt))} ago`
: muted("unknown"),
});
}
}
const summaryRows = [
{ Field: "Path", Value: snapshot.path },
{ Field: "Exists", Value: snapshot.exists ? "yes" : "no" },
{ Field: "Hash", Value: snapshot.hash },
{ Field: "Version", Value: String(file.version ?? 1) },
{ Field: "Socket", Value: file.socket?.path ?? "default" },
{ Field: "Defaults", Value: defaultsParts.length > 0 ? defaultsParts.join(", ") : "none" },
{ Field: "Agents", Value: String(Object.keys(agents).length) },
{ Field: "Allowlist", Value: String(allowlistRows.length) },
];
defaultRuntime.log(heading("Approvals"));
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Field", header: "Field", minWidth: 8 },
{ key: "Value", header: "Value", minWidth: 24, flex: true },
],
rows: summaryRows,
}).trimEnd(),
);
if (allowlistRows.length === 0) {
defaultRuntime.log("");
defaultRuntime.log(muted("No allowlist entries."));
return;
}
defaultRuntime.log("");
defaultRuntime.log(heading("Allowlist"));
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Agent", header: "Agent", minWidth: 8 },
{ key: "Pattern", header: "Pattern", minWidth: 20, flex: true },
{ key: "LastUsed", header: "Last Used", minWidth: 10 },
],
rows: allowlistRows,
}).trimEnd(),
);
const payload = opts.json ? JSON.stringify(snapshot) : JSON.stringify(snapshot, null, 2);
defaultRuntime.log(payload);
});
nodesCallOpts(getCmd);
@@ -245,23 +151,7 @@ export function registerExecApprovalsCli(program: Command) {
});
nodesCallOpts(setCmd);
const allowlist = approvals
.command("allowlist")
.description("Edit the per-agent allowlist")
.addHelpText(
"after",
() =>
`\n${theme.heading("Examples:")}\n${formatExample(
'clawdbot approvals allowlist add "~/Projects/**/bin/rg"',
"Allowlist a local binary pattern for the default agent.",
)}\n${formatExample(
'clawdbot approvals allowlist add --agent main --node <id|name|ip> "/usr/bin/uptime"',
"Allowlist on a specific node/agent.",
)}\n${formatExample(
'clawdbot approvals allowlist remove "~/Projects/**/bin/rg"',
"Remove an allowlist pattern.",
)}\n\n${theme.muted("Docs:")} ${formatDocsLink("/cli/approvals", "docs.clawd.bot/cli/approvals")}\n`,
);
const allowlist = approvals.command("allowlist").description("Edit the per-agent allowlist");
const allowlistAdd = allowlist
.command("add <pattern>")
-85
View File
@@ -1,85 +0,0 @@
import { Command } from "commander";
import { afterEach, describe, expect, it, vi } from "vitest";
const callGatewayFromCli = vi.fn();
vi.mock("./gateway-rpc.js", async () => {
const actual = await vi.importActual<typeof import("./gateway-rpc.js")>("./gateway-rpc.js");
return {
...actual,
callGatewayFromCli: (...args: unknown[]) => callGatewayFromCli(...args),
};
});
describe("logs cli", () => {
afterEach(() => {
callGatewayFromCli.mockReset();
});
it("writes output directly to stdout/stderr", async () => {
callGatewayFromCli.mockResolvedValueOnce({
file: "/tmp/clawdbot.log",
cursor: 1,
size: 123,
lines: ["raw line"],
truncated: true,
reset: true,
});
const stdoutWrites: string[] = [];
const stderrWrites: string[] = [];
const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
stdoutWrites.push(String(chunk));
return true;
});
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
stderrWrites.push(String(chunk));
return true;
});
const { registerLogsCli } = await import("./logs-cli.js");
const program = new Command();
program.exitOverride();
registerLogsCli(program);
await program.parseAsync(["logs"], { from: "user" });
stdoutSpy.mockRestore();
stderrSpy.mockRestore();
expect(stdoutWrites.join("")).toContain("Log file:");
expect(stdoutWrites.join("")).toContain("raw line");
expect(stderrWrites.join("")).toContain("Log tail truncated");
expect(stderrWrites.join("")).toContain("Log cursor reset");
});
it("warns when the output pipe closes", async () => {
callGatewayFromCli.mockResolvedValueOnce({
file: "/tmp/clawdbot.log",
lines: ["line one"],
});
const stderrWrites: string[] = [];
const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => {
const err = new Error("EPIPE") as NodeJS.ErrnoException;
err.code = "EPIPE";
throw err;
});
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
stderrWrites.push(String(chunk));
return true;
});
const { registerLogsCli } = await import("./logs-cli.js");
const program = new Command();
program.exitOverride();
registerLogsCli(program);
await program.parseAsync(["logs"], { from: "user" });
stdoutSpy.mockRestore();
stderrSpy.mockRestore();
expect(stderrWrites.join("")).toContain("output stdout closed");
});
});
+45 -97
View File
@@ -2,9 +2,8 @@ import { setTimeout as delay } from "node:timers/promises";
import type { Command } from "commander";
import { buildGatewayConnectionDetails } from "../gateway/call.js";
import { parseLogLine } from "../logging/parse-log-line.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { clearActiveProgressLine } from "../terminal/progress-line.js";
import { createSafeStreamWriter } from "../terminal/stream-writer.js";
import { colorize, isRich, theme } from "../terminal/theme.js";
import { formatCliCommand } from "./command-format.js";
import { addGatewayClientOptions, callGatewayFromCli } from "./gateway-rpc.js";
@@ -105,28 +104,10 @@ function formatLogLine(
return [head, messageValue].filter(Boolean).join(" ").trim();
}
function createLogWriters() {
const writer = createSafeStreamWriter({
beforeWrite: () => clearActiveProgressLine(),
onBrokenPipe: (err, stream) => {
const code = err.code ?? "EPIPE";
const target = stream === process.stdout ? "stdout" : "stderr";
const message = `clawdbot logs: output ${target} closed (${code}). Stopping tail.`;
try {
clearActiveProgressLine();
process.stderr.write(`${message}\n`);
} catch {
// ignore secondary failures while reporting the broken pipe
}
},
});
return {
logLine: (text: string) => writer.writeLine(process.stdout, text),
errorLine: (text: string) => writer.writeLine(process.stderr, text),
emitJsonLine: (payload: Record<string, unknown>, toStdErr = false) =>
writer.write(toStdErr ? process.stderr : process.stdout, `${JSON.stringify(payload)}\n`),
};
function emitJsonLine(payload: Record<string, unknown>, toStdErr = false) {
const text = `${JSON.stringify(payload)}\n`;
if (toStdErr) process.stderr.write(text);
else process.stdout.write(text);
}
function emitGatewayError(
@@ -134,8 +115,6 @@ function emitGatewayError(
opts: LogsCliOptions,
mode: "json" | "text",
rich: boolean,
emitJsonLine: (payload: Record<string, unknown>, toStdErr?: boolean) => boolean,
errorLine: (text: string) => boolean,
) {
const details = buildGatewayConnectionDetails({ url: opts.url });
const message = "Gateway not reachable. Is it running and accessible?";
@@ -143,26 +122,22 @@ function emitGatewayError(
const errorText = err instanceof Error ? err.message : String(err);
if (mode === "json") {
if (
!emitJsonLine(
{
type: "error",
message,
error: errorText,
details,
hint,
},
true,
)
) {
return;
}
emitJsonLine(
{
type: "error",
message,
error: errorText,
details,
hint,
},
true,
);
return;
}
if (!errorLine(colorize(rich, theme.error, message))) return;
if (!errorLine(details.message)) return;
errorLine(colorize(rich, theme.muted, hint));
defaultRuntime.error(colorize(rich, theme.error, message));
defaultRuntime.error(details.message);
defaultRuntime.error(colorize(rich, theme.muted, hint));
}
export function registerLogsCli(program: Command) {
@@ -184,7 +159,6 @@ export function registerLogsCli(program: Command) {
addGatewayClientOptions(logs);
logs.action(async (opts: LogsCliOptions) => {
const { logLine, errorLine, emitJsonLine } = createLogWriters();
const interval = parsePositiveInt(opts.interval, 1000);
let cursor: number | undefined;
let first = true;
@@ -197,84 +171,58 @@ export function registerLogsCli(program: Command) {
try {
payload = await fetchLogs(opts, cursor);
} catch (err) {
emitGatewayError(err, opts, jsonMode ? "json" : "text", rich, emitJsonLine, errorLine);
process.exit(1);
emitGatewayError(err, opts, jsonMode ? "json" : "text", rich);
defaultRuntime.exit(1);
return;
}
const lines = Array.isArray(payload.lines) ? payload.lines : [];
if (jsonMode) {
if (first) {
if (
!emitJsonLine({
type: "meta",
file: payload.file,
cursor: payload.cursor,
size: payload.size,
})
) {
return;
}
emitJsonLine({
type: "meta",
file: payload.file,
cursor: payload.cursor,
size: payload.size,
});
}
for (const line of lines) {
const parsed = parseLogLine(line);
if (parsed) {
if (!emitJsonLine({ type: "log", ...parsed })) {
return;
}
emitJsonLine({ type: "log", ...parsed });
} else {
if (!emitJsonLine({ type: "raw", raw: line })) {
return;
}
emitJsonLine({ type: "raw", raw: line });
}
}
if (payload.truncated) {
if (
!emitJsonLine({
type: "notice",
message: "Log tail truncated (increase --max-bytes).",
})
) {
return;
}
emitJsonLine({
type: "notice",
message: "Log tail truncated (increase --max-bytes).",
});
}
if (payload.reset) {
if (
!emitJsonLine({
type: "notice",
message: "Log cursor reset (file rotated).",
})
) {
return;
}
emitJsonLine({
type: "notice",
message: "Log cursor reset (file rotated).",
});
}
} else {
if (first && payload.file) {
const prefix = pretty ? colorize(rich, theme.muted, "Log file:") : "Log file:";
if (!logLine(`${prefix} ${payload.file}`)) {
return;
}
defaultRuntime.log(`${prefix} ${payload.file}`);
}
for (const line of lines) {
if (
!logLine(
formatLogLine(line, {
pretty,
rich,
}),
)
) {
return;
}
defaultRuntime.log(
formatLogLine(line, {
pretty,
rich,
}),
);
}
if (payload.truncated) {
if (!errorLine("Log tail truncated (increase --max-bytes).")) {
return;
}
defaultRuntime.error("Log tail truncated (increase --max-bytes).");
}
if (payload.reset) {
if (!errorLine("Log cursor reset (file rotated).")) {
return;
}
defaultRuntime.error("Log cursor reset (file rotated).");
}
}
cursor =
+2 -17
View File
@@ -1,28 +1,13 @@
import { defaultRuntime } from "../../runtime.js";
import { isRich, theme } from "../../terminal/theme.js";
import { runCommandWithRuntime } from "../cli-utils.js";
import { unauthorizedHintForMessage } from "./rpc.js";
export function getNodesTheme() {
const rich = isRich();
const color = (fn: (value: string) => string) => (value: string) => (rich ? fn(value) : value);
return {
rich,
heading: color(theme.heading),
ok: color(theme.success),
warn: color(theme.warn),
muted: color(theme.muted),
error: color(theme.error),
};
}
export function runNodesCommand(label: string, action: () => Promise<void>) {
return runCommandWithRuntime(defaultRuntime, action, (err) => {
const message = String(err);
const { error, warn } = getNodesTheme();
defaultRuntime.error(error(`nodes ${label} failed: ${message}`));
defaultRuntime.error(`nodes ${label} failed: ${message}`);
const hint = unauthorizedHintForMessage(message);
if (hint) defaultRuntime.error(warn(hint));
if (hint) defaultRuntime.error(hint);
defaultRuntime.exit(1);
});
}
+8 -23
View File
@@ -9,10 +9,9 @@ import {
writeBase64ToFile,
} from "../nodes-camera.js";
import { parseDurationMs } from "../parse-duration.js";
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
import { runNodesCommand } from "./cli-utils.js";
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
import type { NodesRpcOpts } from "./types.js";
import { renderTable } from "../../terminal/table.js";
const parseFacing = (value: string): CameraFacing => {
const v = String(value ?? "")
@@ -53,30 +52,16 @@ export function registerNodesCameraCommands(nodes: Command) {
}
if (devices.length === 0) {
const { muted } = getNodesTheme();
defaultRuntime.log(muted("No cameras reported."));
defaultRuntime.log("No cameras reported.");
return;
}
const { heading, muted } = getNodesTheme();
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
const rows = devices.map((device) => ({
Name: typeof device.name === "string" ? device.name : "Unknown Camera",
Position: typeof device.position === "string" ? device.position : muted("unspecified"),
ID: typeof device.id === "string" ? device.id : "",
}));
defaultRuntime.log(heading("Cameras"));
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Name", header: "Name", minWidth: 14, flex: true },
{ key: "Position", header: "Position", minWidth: 10 },
{ key: "ID", header: "ID", minWidth: 10, flex: true },
],
rows,
}).trimEnd(),
);
for (const device of devices) {
const id = typeof device.id === "string" ? device.id : "";
const name = typeof device.name === "string" ? device.name : "Unknown Camera";
const position = typeof device.position === "string" ? device.position : "unspecified";
defaultRuntime.log(`${name} (${position})${id ? ` — ${id}` : ""}`);
}
});
}),
{ timeoutMs: 60_000 },
+7 -25
View File
@@ -6,7 +6,7 @@ import { writeBase64ToFile } from "../nodes-camera.js";
import { canvasSnapshotTempPath, parseCanvasSnapshotPayload } from "../nodes-canvas.js";
import { parseTimeoutMs } from "../nodes-run.js";
import { buildA2UITextJsonl, validateA2UIJsonl } from "./a2ui-jsonl.js";
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
import { runNodesCommand } from "./cli-utils.js";
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
import type { NodesRpcOpts } from "./types.js";
@@ -121,10 +121,7 @@ export function registerNodesCanvasCommands(nodes: Command) {
params.placement = placement;
}
await invokeCanvas(opts, "canvas.present", params);
if (!opts.json) {
const { ok } = getNodesTheme();
defaultRuntime.log(ok("canvas present ok"));
}
if (!opts.json) defaultRuntime.log("canvas present ok");
});
}),
);
@@ -138,10 +135,7 @@ export function registerNodesCanvasCommands(nodes: Command) {
.action(async (opts: NodesRpcOpts) => {
await runNodesCommand("canvas hide", async () => {
await invokeCanvas(opts, "canvas.hide", undefined);
if (!opts.json) {
const { ok } = getNodesTheme();
defaultRuntime.log(ok("canvas hide ok"));
}
if (!opts.json) defaultRuntime.log("canvas hide ok");
});
}),
);
@@ -156,10 +150,7 @@ export function registerNodesCanvasCommands(nodes: Command) {
.action(async (url: string, opts: NodesRpcOpts) => {
await runNodesCommand("canvas navigate", async () => {
await invokeCanvas(opts, "canvas.navigate", { url });
if (!opts.json) {
const { ok } = getNodesTheme();
defaultRuntime.log(ok("canvas navigate ok"));
}
if (!opts.json) defaultRuntime.log("canvas navigate ok");
});
}),
);
@@ -188,10 +179,7 @@ export function registerNodesCanvasCommands(nodes: Command) {
? (raw as { payload?: { result?: string } }).payload
: undefined;
if (payload?.result) defaultRuntime.log(payload.result);
else {
const { ok } = getNodesTheme();
defaultRuntime.log(ok("canvas eval ok"));
}
else defaultRuntime.log("canvas eval ok");
});
}),
);
@@ -225,11 +213,8 @@ export function registerNodesCanvasCommands(nodes: Command) {
}
await invokeCanvas(opts, "canvas.a2ui.pushJSONL", { jsonl });
if (!opts.json) {
const { ok } = getNodesTheme();
defaultRuntime.log(
ok(
`canvas a2ui push ok (v0.8, ${messageCount} message${messageCount === 1 ? "" : "s"})`,
),
`canvas a2ui push ok (v0.8, ${messageCount} message${messageCount === 1 ? "" : "s"})`,
);
}
});
@@ -245,10 +230,7 @@ export function registerNodesCanvasCommands(nodes: Command) {
.action(async (opts: NodesRpcOpts) => {
await runNodesCommand("canvas a2ui reset", async () => {
await invokeCanvas(opts, "canvas.a2ui.reset", undefined);
if (!opts.json) {
const { ok } = getNodesTheme();
defaultRuntime.log(ok("canvas a2ui reset ok"));
}
if (!opts.json) defaultRuntime.log("canvas a2ui reset ok");
});
}),
);
+5 -11
View File
@@ -2,7 +2,7 @@ import type { Command } from "commander";
import { randomIdempotencyKey } from "../../gateway/call.js";
import { defaultRuntime } from "../../runtime.js";
import { parseEnvPairs, parseTimeoutMs } from "../nodes-run.js";
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
import { runNodesCommand } from "./cli-utils.js";
import { callGatewayCli, nodesCallOpts, resolveNodeId, unauthorizedHintForMessage } from "./rpc.js";
import type { NodesRpcOpts } from "./types.js";
@@ -21,8 +21,7 @@ export function registerNodesInvokeCommands(nodes: Command) {
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
const command = String(opts.command ?? "").trim();
if (!nodeId || !command) {
const { error } = getNodesTheme();
defaultRuntime.error(error("--node and --command required"));
defaultRuntime.error("--node and --command required");
defaultRuntime.exit(1);
return;
}
@@ -109,21 +108,16 @@ export function registerNodesInvokeCommands(nodes: Command) {
if (stdout) process.stdout.write(stdout);
if (stderr) process.stderr.write(stderr);
if (timedOut) {
const { error } = getNodesTheme();
defaultRuntime.error(error("run timed out"));
defaultRuntime.error("run timed out");
defaultRuntime.exit(1);
return;
}
if (exitCode !== null && exitCode !== 0) {
const hint = unauthorizedHintForMessage(`${stderr}\n${stdout}`);
if (hint) {
const { warn } = getNodesTheme();
defaultRuntime.error(warn(hint));
}
if (hint) defaultRuntime.error(hint);
}
if (exitCode !== null && exitCode !== 0 && !success) {
const { error } = getNodesTheme();
defaultRuntime.error(error(`run exit ${exitCode}`));
defaultRuntime.error(`run exit ${exitCode}`);
defaultRuntime.exit(1);
return;
}
+2 -3
View File
@@ -1,7 +1,7 @@
import type { Command } from "commander";
import { randomIdempotencyKey } from "../../gateway/call.js";
import { defaultRuntime } from "../../runtime.js";
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
import { runNodesCommand } from "./cli-utils.js";
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
import type { NodesRpcOpts } from "./types.js";
@@ -49,8 +49,7 @@ export function registerNodesNotifyCommand(nodes: Command) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
const { ok } = getNodesTheme();
defaultRuntime.log(ok("notify ok"));
defaultRuntime.log("notify ok");
});
}),
);
+10 -33
View File
@@ -1,10 +1,9 @@
import type { Command } from "commander";
import { defaultRuntime } from "../../runtime.js";
import { formatAge, parsePairingList } from "./format.js";
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
import { runNodesCommand } from "./cli-utils.js";
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
import type { NodesRpcOpts } from "./types.js";
import { renderTable } from "../../terminal/table.js";
export function registerNodesPairingCommands(nodes: Command) {
nodesCallOpts(
@@ -20,37 +19,16 @@ export function registerNodesPairingCommands(nodes: Command) {
return;
}
if (pending.length === 0) {
const { muted } = getNodesTheme();
defaultRuntime.log(muted("No pending pairing requests."));
defaultRuntime.log("No pending pairing requests.");
return;
}
const { heading, warn, muted } = getNodesTheme();
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
const now = Date.now();
const rows = pending.map((r) => ({
Request: r.requestId,
Node: r.displayName?.trim() ? r.displayName.trim() : r.nodeId,
IP: r.remoteIp ?? "",
Requested:
typeof r.ts === "number"
? `${formatAge(Math.max(0, now - r.ts))} ago`
: muted("unknown"),
Repair: r.isRepair ? warn("yes") : "",
}));
defaultRuntime.log(heading("Pending"));
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Request", header: "Request", minWidth: 8 },
{ key: "Node", header: "Node", minWidth: 14, flex: true },
{ key: "IP", header: "IP", minWidth: 10 },
{ key: "Requested", header: "Requested", minWidth: 12 },
{ key: "Repair", header: "Repair", minWidth: 6 },
],
rows,
}).trimEnd(),
);
for (const r of pending) {
const name = r.displayName || r.nodeId;
const repair = r.isRepair ? " (repair)" : "";
const ip = r.remoteIp ? ` · ${r.remoteIp}` : "";
const age = typeof r.ts === "number" ? ` · ${formatAge(Date.now() - r.ts)} ago` : "";
defaultRuntime.log(`- ${r.requestId}: ${name}${repair}${ip}${age}`);
}
});
}),
);
@@ -108,8 +86,7 @@ export function registerNodesPairingCommands(nodes: Command) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
const { ok } = getNodesTheme();
defaultRuntime.log(ok(`node rename ok: ${nodeId} -> ${name}`));
defaultRuntime.log(`node rename ok: ${nodeId} -> ${name}`);
});
}),
);
+44 -134
View File
@@ -1,10 +1,9 @@
import type { Command } from "commander";
import { defaultRuntime } from "../../runtime.js";
import { formatAge, formatPermissions, parseNodeList, parsePairingList } from "./format.js";
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
import { runNodesCommand } from "./cli-utils.js";
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
import type { NodesRpcOpts } from "./types.js";
import { renderTable } from "../../terminal/table.js";
function formatVersionLabel(raw: string) {
const trimmed = raw.trim();
@@ -55,61 +54,32 @@ export function registerNodesStatusCommands(nodes: Command) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
const { ok, warn, muted } = getNodesTheme();
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
const now = Date.now();
const nodes = parseNodeList(result);
const pairedCount = nodes.filter((n) => Boolean(n.paired)).length;
const connectedCount = nodes.filter((n) => Boolean(n.connected)).length;
defaultRuntime.log(
`Known: ${nodes.length} · Paired: ${pairedCount} · Connected: ${connectedCount}`,
);
if (nodes.length === 0) return;
const rows = nodes.map((n) => {
const name = n.displayName?.trim() ? n.displayName.trim() : n.nodeId;
for (const n of nodes) {
const name = n.displayName || n.nodeId;
const ip = n.remoteIp ? ` · ${n.remoteIp}` : "";
const device = n.deviceFamily ? ` · device: ${n.deviceFamily}` : "";
const hw = n.modelIdentifier ? ` · hw: ${n.modelIdentifier}` : "";
const perms = formatPermissions(n.permissions);
const permsText = perms ? ` · perms: ${perms}` : "";
const versions = formatNodeVersions(n);
const detailParts = [
n.deviceFamily ? `device: ${n.deviceFamily}` : null,
n.modelIdentifier ? `hw: ${n.modelIdentifier}` : null,
perms ? `perms: ${perms}` : null,
versions,
].filter(Boolean) as string[];
const caps = Array.isArray(n.caps)
? n.caps.map(String).filter(Boolean).sort().join(", ")
: "?";
const paired = n.paired ? ok("paired") : warn("unpaired");
const connected = n.connected ? ok("connected") : muted("disconnected");
const since =
typeof n.connectedAtMs === "number"
? ` (${formatAge(Math.max(0, now - n.connectedAtMs))} ago)`
: "";
return {
Node: name,
ID: n.nodeId,
IP: n.remoteIp ?? "",
Detail: detailParts.join(" · "),
Status: `${paired} · ${connected}${since}`,
Caps: caps,
};
});
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Node", header: "Node", minWidth: 14, flex: true },
{ key: "ID", header: "ID", minWidth: 10 },
{ key: "IP", header: "IP", minWidth: 10 },
{ key: "Detail", header: "Detail", minWidth: 18, flex: true },
{ key: "Status", header: "Status", minWidth: 18 },
{ key: "Caps", header: "Caps", minWidth: 12, flex: true },
],
rows,
}).trimEnd(),
);
const versionText = versions ? ` · ${versions}` : "";
const caps =
Array.isArray(n.caps) && n.caps.length > 0
? `[${n.caps.map(String).filter(Boolean).sort().join(",")}]`
: Array.isArray(n.caps)
? "[]"
: "?";
const pairing = n.paired ? "paired" : "unpaired";
defaultRuntime.log(
`- ${name} · ${n.nodeId}${ip}${device}${hw}${permsText}${versionText} · ${pairing} · ${n.connected ? "connected" : "disconnected"} · caps: ${caps}`,
);
}
});
}),
);
@@ -136,7 +106,6 @@ export function registerNodesStatusCommands(nodes: Command) {
: {};
const displayName = typeof obj.displayName === "string" ? obj.displayName : nodeId;
const connected = Boolean(obj.connected);
const paired = Boolean(obj.paired);
const caps = Array.isArray(obj.caps) ? obj.caps.map(String).filter(Boolean).sort() : null;
const commands = Array.isArray(obj.commands)
? obj.commands.map(String).filter(Boolean).sort()
@@ -154,38 +123,18 @@ export function registerNodesStatusCommands(nodes: Command) {
},
);
const { heading, ok, warn, muted } = getNodesTheme();
const status = `${paired ? ok("paired") : warn("unpaired")} · ${
connected ? ok("connected") : muted("disconnected")
}`;
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
const rows = [
{ Field: "ID", Value: nodeId },
displayName ? { Field: "Name", Value: displayName } : null,
ip ? { Field: "IP", Value: ip } : null,
family ? { Field: "Device", Value: family } : null,
model ? { Field: "Model", Value: model } : null,
perms ? { Field: "Perms", Value: perms } : null,
versions ? { Field: "Version", Value: versions } : null,
{ Field: "Status", Value: status },
{ Field: "Caps", Value: caps ? caps.join(", ") : "?" },
].filter(Boolean) as Array<{ Field: string; Value: string }>;
defaultRuntime.log(heading("Node"));
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Field", header: "Field", minWidth: 8 },
{ key: "Value", header: "Value", minWidth: 24, flex: true },
],
rows,
}).trimEnd(),
);
defaultRuntime.log("");
defaultRuntime.log(heading("Commands"));
const parts: string[] = ["Node:", displayName, nodeId];
if (ip) parts.push(ip);
if (family) parts.push(`device: ${family}`);
if (model) parts.push(`hw: ${model}`);
if (perms) parts.push(`perms: ${perms}`);
if (versions) parts.push(versions);
parts.push(connected ? "connected" : "disconnected");
parts.push(`caps: ${caps ? `[${caps.join(",")}]` : "?"}`);
defaultRuntime.log(parts.join(" · "));
defaultRuntime.log("Commands:");
if (commands.length === 0) {
defaultRuntime.log(muted("- (none reported)"));
defaultRuntime.log("- (none reported)");
return;
}
for (const c of commands) defaultRuntime.log(`- ${c}`);
@@ -206,62 +155,23 @@ export function registerNodesStatusCommands(nodes: Command) {
}
const { pending, paired } = parsePairingList(result);
defaultRuntime.log(`Pending: ${pending.length} · Paired: ${paired.length}`);
const { heading, muted, warn } = getNodesTheme();
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
const now = Date.now();
if (pending.length > 0) {
const pendingRows = pending.map((r) => ({
Request: r.requestId,
Node: r.displayName?.trim() ? r.displayName.trim() : r.nodeId,
IP: r.remoteIp ?? "",
Requested:
typeof r.ts === "number"
? `${formatAge(Math.max(0, now - r.ts))} ago`
: muted("unknown"),
Repair: r.isRepair ? warn("yes") : "",
}));
defaultRuntime.log("");
defaultRuntime.log(heading("Pending"));
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Request", header: "Request", minWidth: 8 },
{ key: "Node", header: "Node", minWidth: 14, flex: true },
{ key: "IP", header: "IP", minWidth: 10 },
{ key: "Requested", header: "Requested", minWidth: 12 },
{ key: "Repair", header: "Repair", minWidth: 6 },
],
rows: pendingRows,
}).trimEnd(),
);
defaultRuntime.log("\nPending:");
for (const r of pending) {
const name = r.displayName || r.nodeId;
const repair = r.isRepair ? " (repair)" : "";
const ip = r.remoteIp ? ` · ${r.remoteIp}` : "";
const age = typeof r.ts === "number" ? ` · ${formatAge(Date.now() - r.ts)} ago` : "";
defaultRuntime.log(`- ${r.requestId}: ${name}${repair}${ip}${age}`);
}
}
if (paired.length > 0) {
const pairedRows = paired.map((n) => ({
Node: n.displayName?.trim() ? n.displayName.trim() : n.nodeId,
Id: n.nodeId,
IP: n.remoteIp ?? "",
LastConnect:
typeof n.lastConnectedAtMs === "number"
? `${formatAge(Math.max(0, now - n.lastConnectedAtMs))} ago`
: muted("unknown"),
}));
defaultRuntime.log("");
defaultRuntime.log(heading("Paired"));
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Node", header: "Node", minWidth: 14, flex: true },
{ key: "Id", header: "ID", minWidth: 10 },
{ key: "IP", header: "IP", minWidth: 10 },
{ key: "LastConnect", header: "Last Connect", minWidth: 14 },
],
rows: pairedRows,
}).trimEnd(),
);
defaultRuntime.log("\nPaired:");
for (const n of paired) {
const name = n.displayName || n.nodeId;
const ip = n.remoteIp ? ` · ${n.remoteIp}` : "";
defaultRuntime.log(`- ${n.nodeId}: ${name}${ip}`);
}
}
});
}),
-2
View File
@@ -56,7 +56,6 @@ export type NodeListNode = {
permissions?: Record<string, boolean>;
paired?: boolean;
connected?: boolean;
connectedAtMs?: number;
};
export type PendingRequest = {
@@ -84,7 +83,6 @@ export type PairedNode = {
permissions?: Record<string, boolean>;
createdAtMs?: number;
approvedAtMs?: number;
lastConnectedAtMs?: number;
};
export type PairingList = {
+5 -15
View File
@@ -95,14 +95,10 @@ describe("cli program (nodes basics)", () => {
const output = runtime.log.mock.calls.map((c) => String(c[0] ?? "")).join("\n");
expect(output).toContain("Known: 1 · Paired: 1 · Connected: 1");
expect(output).toContain("iOS Node");
expect(output).toContain("Detail");
expect(output).toContain("device: iPad");
expect(output).toContain("hw: iPad16,6");
expect(output).toContain("Status");
expect(output).toContain("paired");
expect(output).toContain("Caps");
expect(output).toContain("camera");
expect(output).toContain("canvas");
expect(output).toContain("caps: [camera,canvas]");
});
it("runs nodes status and shows unpaired nodes", async () => {
@@ -127,18 +123,12 @@ describe("cli program (nodes basics)", () => {
const output = runtime.log.mock.calls.map((c) => String(c[0] ?? "")).join("\n");
expect(output).toContain("Known: 1 · Paired: 0 · Connected: 1");
expect(output).toContain("Peter's Tab");
expect(output).toContain("S10 Ultra");
expect(output).toContain("Detail");
expect(output).toContain("Peter's Tab S10 Ultra");
expect(output).toContain("device: Android");
expect(output).toContain("hw: samsung");
expect(output).toContain("SM-X926B");
expect(output).toContain("Status");
expect(output).toContain("hw: samsung SM-X926B");
expect(output).toContain("unpaired");
expect(output).toContain("connected");
expect(output).toContain("Caps");
expect(output).toContain("camera");
expect(output).toContain("canvas");
expect(output).toContain("caps: [camera,canvas]");
});
it("runs nodes describe and calls node.describe", async () => {
@@ -186,7 +176,7 @@ describe("cli program (nodes basics)", () => {
);
const out = runtime.log.mock.calls.map((c) => String(c[0] ?? "")).join("\n");
expect(out).toContain("Commands");
expect(out).toContain("Commands:");
expect(out).toContain("canvas.eval");
});
+1 -2
View File
@@ -80,8 +80,7 @@ export function configureProgramHelp(program: Command, ctx: ProgramContext) {
([cmd, desc]) => ` ${theme.command(cmd)}\n ${theme.muted(desc)}`,
).join("\n");
program.addHelpText("afterAll", ({ command }) => {
if (command !== program) return "";
program.addHelpText("afterAll", () => {
const docs = formatDocsLink("/cli", "docs.clawd.bot/cli");
return `\n${theme.heading("Examples:")}\n${fmtExamples}\n\n${theme.muted("Docs:")} ${docs}\n`;
});
+1 -28
View File
@@ -20,40 +20,13 @@ import {
} from "./node-cli/daemon.js";
export function registerServiceCli(program: Command) {
const formatExample = (cmd: string, desc: string) =>
` ${theme.command(cmd)}\n ${theme.muted(desc)}`;
const formatGroup = (label: string, examples: Array<[string, string]>) =>
`${theme.muted(label)}\n${examples.map(([cmd, desc]) => formatExample(cmd, desc)).join("\n")}`;
const gatewayExamples: Array<[string, string]> = [
["clawdbot service gateway status", "Show gateway service status + probe."],
[
"clawdbot service gateway install --port 18789 --token <token>",
"Install the Gateway service on port 18789.",
],
["clawdbot service gateway restart", "Restart the Gateway service."],
];
const nodeExamples: Array<[string, string]> = [
["clawdbot service node status", "Show node host service status."],
[
"clawdbot service node install --host gateway.local --port 18789 --tls",
"Install the node host service with TLS.",
],
["clawdbot service node restart", "Restart the node host service."],
];
const service = program
.command("service")
.description("Manage Gateway and node host services (launchd/systemd/schtasks)")
.addHelpText(
"after",
() =>
`\n${theme.heading("Examples:")}\n${formatGroup("Gateway:", gatewayExamples)}\n\n${formatGroup(
"Node:",
nodeExamples,
)}\n\n${theme.muted("Docs:")} ${formatDocsLink("/cli/service", "docs.clawd.bot/cli/service")}\n`,
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/service", "docs.clawd.bot/cli/service")}\n`,
);
const gateway = service.command("gateway").description("Manage the Gateway service");
-4
View File
@@ -178,7 +178,6 @@ const FIELD_LABELS: Record<string, string> = {
"tools.web.fetch.maxChars": "Web Fetch Max Chars",
"tools.web.fetch.timeoutSeconds": "Web Fetch Timeout (sec)",
"tools.web.fetch.cacheTtlMinutes": "Web Fetch Cache TTL (min)",
"tools.web.fetch.maxRedirects": "Web Fetch Max Redirects",
"tools.web.fetch.userAgent": "Web Fetch User-Agent",
"gateway.controlUi.basePath": "Control UI Base Path",
"gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint",
@@ -250,8 +249,6 @@ const FIELD_LABELS: Record<string, string> = {
"commands.useAccessGroups": "Use Access Groups",
"ui.seamColor": "Accent Color",
"browser.controlUrl": "Browser Control URL",
"browser.snapshotDefaults": "Browser Snapshot Defaults",
"browser.snapshotDefaults.mode": "Browser Snapshot Mode",
"browser.remoteCdpTimeoutMs": "Remote CDP Timeout (ms)",
"browser.remoteCdpHandshakeTimeoutMs": "Remote CDP Handshake Timeout (ms)",
"session.dmScope": "DM Session Scope",
@@ -381,7 +378,6 @@ const FIELD_HELP: Record<string, string> = {
"tools.web.fetch.maxChars": "Max characters returned by web_fetch (truncated).",
"tools.web.fetch.timeoutSeconds": "Timeout in seconds for web_fetch requests.",
"tools.web.fetch.cacheTtlMinutes": "Cache TTL in minutes for web_fetch results.",
"tools.web.fetch.maxRedirects": "Maximum redirects allowed for web_fetch (default: 3).",
"tools.web.fetch.userAgent": "Override User-Agent header for web_fetch requests.",
"tools.web.fetch.readability":
"Use Readability to extract main content from HTML (fallbacks to basic HTML cleanup).",
-6
View File
@@ -8,10 +8,6 @@ export type BrowserProfileConfig = {
/** Profile color (hex). Auto-assigned at creation. */
color: string;
};
export type BrowserSnapshotDefaults = {
/** Default snapshot mode (applies when mode is not provided). */
mode?: "efficient";
};
export type BrowserConfig = {
enabled?: boolean;
/** Base URL of the clawd browser control server. Default: http://127.0.0.1:18791 */
@@ -43,6 +39,4 @@ export type BrowserConfig = {
defaultProfile?: string;
/** Named browser profiles with explicit CDP ports or URLs. */
profiles?: Record<string, BrowserProfileConfig>;
/** Default snapshot options (applied by the browser tool/CLI when unset). */
snapshotDefaults?: BrowserSnapshotDefaults;
};
-2
View File
@@ -309,8 +309,6 @@ export type ToolsConfig = {
timeoutSeconds?: number;
/** Cache TTL in minutes for fetched content. */
cacheTtlMinutes?: number;
/** Maximum number of redirects to follow (default: 3). */
maxRedirects?: number;
/** Override User-Agent header for fetch requests. */
userAgent?: string;
/** Use Readability to extract main content (default: true). */
-1
View File
@@ -147,7 +147,6 @@ export const ToolsWebFetchSchema = z
maxChars: z.number().int().positive().optional(),
timeoutSeconds: z.number().int().positive().optional(),
cacheTtlMinutes: z.number().nonnegative().optional(),
maxRedirects: z.number().int().nonnegative().optional(),
userAgent: z.string().optional(),
})
.strict()
-8
View File
@@ -6,13 +6,6 @@ import { HookMappingSchema, HooksGmailSchema, InternalHooksSchema } from "./zod-
import { ChannelsSchema } from "./zod-schema.providers.js";
import { CommandsSchema, MessagesSchema, SessionSchema } from "./zod-schema.session.js";
const BrowserSnapshotDefaultsSchema = z
.object({
mode: z.literal("efficient").optional(),
})
.strict()
.optional();
export const ClawdbotSchema = z
.object({
meta: z
@@ -120,7 +113,6 @@ export const ClawdbotSchema = z
noSandbox: z.boolean().optional(),
attachOnly: z.boolean().optional(),
defaultProfile: z.string().optional(),
snapshotDefaults: BrowserSnapshotDefaultsSchema,
profiles: z
.record(
z
-65
View File
@@ -1,65 +0,0 @@
import { describe, expect, it } from "vitest";
import type { ErrorObject } from "ajv";
import { formatValidationErrors } from "./index.js";
const makeError = (overrides: Partial<ErrorObject>): ErrorObject => ({
keyword: "type",
instancePath: "",
schemaPath: "#/",
params: {},
message: "validation error",
...overrides,
});
describe("formatValidationErrors", () => {
it("returns unknown validation error when missing errors", () => {
expect(formatValidationErrors(undefined)).toBe("unknown validation error");
expect(formatValidationErrors(null)).toBe("unknown validation error");
});
it("returns unknown validation error when errors list is empty", () => {
expect(formatValidationErrors([])).toBe("unknown validation error");
});
it("formats additionalProperties at root", () => {
const err = makeError({
keyword: "additionalProperties",
params: { additionalProperty: "token" },
});
expect(formatValidationErrors([err])).toBe("at root: unexpected property 'token'");
});
it("formats additionalProperties with instancePath", () => {
const err = makeError({
keyword: "additionalProperties",
instancePath: "/auth",
params: { additionalProperty: "token" },
});
expect(formatValidationErrors([err])).toBe("at /auth: unexpected property 'token'");
});
it("formats message with path for other errors", () => {
const err = makeError({
keyword: "required",
instancePath: "/auth",
message: "must have required property 'token'",
});
expect(formatValidationErrors([err])).toBe("at /auth: must have required property 'token'");
});
it("de-dupes repeated entries", () => {
const err = makeError({
keyword: "required",
instancePath: "/auth",
message: "must have required property 'token'",
});
expect(formatValidationErrors([err, err])).toBe(
"at /auth: must have required property 'token'",
);
});
});
+2 -31
View File
@@ -310,37 +310,8 @@ export const validateWebLoginStartParams =
export const validateWebLoginWaitParams = ajv.compile<WebLoginWaitParams>(WebLoginWaitParamsSchema);
export function formatValidationErrors(errors: ErrorObject[] | null | undefined) {
if (!errors?.length) return "unknown validation error";
const parts: string[] = [];
for (const err of errors) {
const keyword = typeof err?.keyword === "string" ? err.keyword : "";
const instancePath = typeof err?.instancePath === "string" ? err.instancePath : "";
if (keyword === "additionalProperties") {
const params = err?.params as { additionalProperty?: unknown } | undefined;
const additionalProperty = params?.additionalProperty;
if (typeof additionalProperty === "string" && additionalProperty.trim()) {
const where = instancePath ? `at ${instancePath}` : "at root";
parts.push(`${where}: unexpected property '${additionalProperty}'`);
continue;
}
}
const message =
typeof err?.message === "string" && err.message.trim() ? err.message : "validation error";
const where = instancePath ? `at ${instancePath}: ` : "";
parts.push(`${where}${message}`);
}
// De-dupe while preserving order.
const unique = Array.from(new Set(parts.filter((part) => part.trim())));
if (!unique.length) {
const fallback = ajv.errorsText(errors, { separator: "; " });
return fallback || "unknown validation error";
}
return unique.join("; ");
if (!errors) return "unknown validation error";
return ajv.errorsText(errors, { separator: "; " });
}
export {
-2
View File
@@ -258,7 +258,6 @@ export const nodeHandlers: GatewayRequestHandlers = {
caps,
commands,
permissions: live?.permissions ?? paired?.permissions,
connectedAtMs: live?.connectedAtMs,
paired: Boolean(paired),
connected: Boolean(live),
};
@@ -321,7 +320,6 @@ export const nodeHandlers: GatewayRequestHandlers = {
caps,
commands,
permissions: live?.permissions,
connectedAtMs: live?.connectedAtMs,
paired: Boolean(paired),
connected: Boolean(live),
},
+27
View File
@@ -102,6 +102,33 @@ describe("gateway server auth/connect", () => {
}
});
test("accepts token auth without device identity", async () => {
const { server, ws, prevToken } = await startServerWithClient("secret");
const res = await connectReq(ws, { token: "secret", device: null });
expect(res.ok).toBe(true);
ws.close();
await server.close();
if (prevToken === undefined) {
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
} else {
process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
}
});
test("requires device identity when auth mode is none", async () => {
const { server, ws, prevToken } = await startServerWithClient();
const res = await connectReq(ws, { device: null });
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain("device identity required");
ws.close();
await server.close();
if (prevToken === undefined) {
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
} else {
process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
}
});
test("accepts password auth when configured", async () => {
testState.gatewayAuth = { mode: "password", password: "secret" };
const port = await getFreePort();
@@ -15,7 +15,6 @@ import {
updatePairedDeviceMetadata,
verifyDeviceToken,
} from "../../../infra/device-pairing.js";
import { updatePairedNodeMetadata } from "../../../infra/node-pairing.js";
import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../../../infra/skills-remote.js";
import { loadVoiceWakeConfig } from "../../../infra/voicewake.js";
import { upsertPresence } from "../../../infra/system-presence.js";
@@ -291,11 +290,18 @@ export function attachGatewayWsMessageHandler(params: {
connectParams.role = role;
connectParams.scopes = scopes;
const authResult = await authorizeGatewayConnect({
auth: resolvedAuth,
connectAuth: connectParams.auth,
req: upgradeReq,
});
let authOk = authResult.ok;
let authMethod = authResult.method ?? "none";
const allowsDeviceOptional = authOk && authMethod === "token";
const device = connectParams.device;
let devicePublicKey: string | null = null;
// Allow token-authenticated connections (e.g., control-ui) to skip device identity
const hasTokenAuth = !!connectParams.auth?.token;
if (!device && !hasTokenAuth) {
if (!device && !allowsDeviceOptional) {
setHandshakeState("failed");
setCloseCause("device-required", {
client: connectParams.client.id,
@@ -461,13 +467,6 @@ export function attachGatewayWsMessageHandler(params: {
}
}
const authResult = await authorizeGatewayConnect({
auth: resolvedAuth,
connectAuth: connectParams.auth,
req: upgradeReq,
});
let authOk = authResult.ok;
let authMethod = authResult.method ?? "none";
if (!authOk && connectParams.auth?.token && device) {
const tokenCheck = await verifyDeviceToken({
deviceId: device.id,
@@ -719,13 +718,6 @@ export function attachGatewayWsMessageHandler(params: {
if (role === "node") {
const context = buildRequestContext();
const nodeSession = context.nodeRegistry.register(nextClient, { remoteIp: remoteAddr });
void updatePairedNodeMetadata(nodeSession.nodeId, {
lastConnectedAtMs: nodeSession.connectedAtMs,
}).catch((err) =>
logGateway.warn(
`failed to record last connect for ${nodeSession.nodeId}: ${formatForLog(err)}`,
),
);
recordRemoteNodeInfo({
nodeId: nodeSession.nodeId,
displayName: nodeSession.displayName,
+2 -1
View File
@@ -280,7 +280,7 @@ export async function connectReq(
signature: string;
signedAt: number;
nonce?: string;
};
} | null;
},
): Promise<ConnectResponse> {
const { randomUUID } = await import("node:crypto");
@@ -294,6 +294,7 @@ export async function connectReq(
const role = opts?.role ?? "operator";
const requestedScopes = Array.isArray(opts?.scopes) ? opts?.scopes : [];
const device = (() => {
if (opts?.device === null) return undefined;
if (opts?.device) return opts.device;
const identity = loadOrCreateDeviceIdentity();
const signedAtMs = Date.now();
+12 -35
View File
@@ -188,54 +188,31 @@ export function ensureExecApprovals(): ExecApprovalsFile {
return updated;
}
function normalizeSecurity(value: ExecSecurity | undefined, fallback: ExecSecurity): ExecSecurity {
function normalizeSecurity(value?: ExecSecurity): ExecSecurity {
if (value === "allowlist" || value === "full" || value === "deny") return value;
return fallback;
return DEFAULT_SECURITY;
}
function normalizeAsk(value: ExecAsk | undefined, fallback: ExecAsk): ExecAsk {
function normalizeAsk(value?: ExecAsk): ExecAsk {
if (value === "always" || value === "off" || value === "on-miss") return value;
return fallback;
return DEFAULT_ASK;
}
export type ExecApprovalsDefaultOverrides = {
security?: ExecSecurity;
ask?: ExecAsk;
askFallback?: ExecSecurity;
autoAllowSkills?: boolean;
};
export function resolveExecApprovals(
agentId?: string,
overrides?: ExecApprovalsDefaultOverrides,
): ExecApprovalsResolved {
export function resolveExecApprovals(agentId?: string): ExecApprovalsResolved {
const file = ensureExecApprovals();
const defaults = file.defaults ?? {};
const agentKey = agentId ?? "default";
const agent = file.agents?.[agentKey] ?? {};
const fallbackSecurity = overrides?.security ?? DEFAULT_SECURITY;
const fallbackAsk = overrides?.ask ?? DEFAULT_ASK;
const fallbackAskFallback = overrides?.askFallback ?? DEFAULT_ASK_FALLBACK;
const fallbackAutoAllowSkills = overrides?.autoAllowSkills ?? DEFAULT_AUTO_ALLOW_SKILLS;
const resolvedDefaults: Required<ExecApprovalsDefaults> = {
security: normalizeSecurity(defaults.security, fallbackSecurity),
ask: normalizeAsk(defaults.ask, fallbackAsk),
askFallback: normalizeSecurity(
defaults.askFallback ?? fallbackAskFallback,
fallbackAskFallback,
),
autoAllowSkills: Boolean(defaults.autoAllowSkills ?? fallbackAutoAllowSkills),
security: normalizeSecurity(defaults.security),
ask: normalizeAsk(defaults.ask),
askFallback: normalizeSecurity(defaults.askFallback ?? DEFAULT_ASK_FALLBACK),
autoAllowSkills: Boolean(defaults.autoAllowSkills ?? DEFAULT_AUTO_ALLOW_SKILLS),
};
const resolvedAgent: Required<ExecApprovalsDefaults> = {
security: normalizeSecurity(
agent.security ?? resolvedDefaults.security,
resolvedDefaults.security,
),
ask: normalizeAsk(agent.ask ?? resolvedDefaults.ask, resolvedDefaults.ask),
askFallback: normalizeSecurity(
agent.askFallback ?? resolvedDefaults.askFallback,
resolvedDefaults.askFallback,
),
security: normalizeSecurity(agent.security ?? resolvedDefaults.security),
ask: normalizeAsk(agent.ask ?? resolvedDefaults.ask),
askFallback: normalizeSecurity(agent.askFallback ?? resolvedDefaults.askFallback),
autoAllowSkills: Boolean(agent.autoAllowSkills ?? resolvedDefaults.autoAllowSkills),
};
const allowlist = Array.isArray(agent.allowlist) ? agent.allowlist : [];
-131
View File
@@ -1,131 +0,0 @@
import { lookup as dnsLookup } from "node:dns/promises";
export class SsrFBlockedError extends Error {
constructor(message: string) {
super(message);
this.name = "SsrFBlockedError";
}
}
type LookupFn = typeof dnsLookup;
const PRIVATE_IPV6_PREFIXES = ["fe80:", "fec0:", "fc", "fd"];
const BLOCKED_HOSTNAMES = new Set(["localhost", "metadata.google.internal"]);
function normalizeHostname(hostname: string): string {
const normalized = hostname.trim().toLowerCase().replace(/\.$/, "");
if (normalized.startsWith("[") && normalized.endsWith("]")) {
return normalized.slice(1, -1);
}
return normalized;
}
function parseIpv4(address: string): number[] | null {
const parts = address.split(".");
if (parts.length !== 4) return null;
const numbers = parts.map((part) => Number.parseInt(part, 10));
if (numbers.some((value) => Number.isNaN(value) || value < 0 || value > 255)) return null;
return numbers;
}
function parseIpv4FromMappedIpv6(mapped: string): number[] | null {
if (mapped.includes(".")) {
return parseIpv4(mapped);
}
const parts = mapped.split(":").filter(Boolean);
if (parts.length === 1) {
const value = Number.parseInt(parts[0], 16);
if (Number.isNaN(value) || value < 0 || value > 0xffff_ffff) return null;
return [(value >>> 24) & 0xff, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff];
}
if (parts.length !== 2) return null;
const high = Number.parseInt(parts[0], 16);
const low = Number.parseInt(parts[1], 16);
if (
Number.isNaN(high) ||
Number.isNaN(low) ||
high < 0 ||
low < 0 ||
high > 0xffff ||
low > 0xffff
) {
return null;
}
const value = (high << 16) + low;
return [(value >>> 24) & 0xff, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff];
}
function isPrivateIpv4(parts: number[]): boolean {
const [octet1, octet2] = parts;
if (octet1 === 0) return true;
if (octet1 === 10) return true;
if (octet1 === 127) return true;
if (octet1 === 169 && octet2 === 254) return true;
if (octet1 === 172 && octet2 >= 16 && octet2 <= 31) return true;
if (octet1 === 192 && octet2 === 168) return true;
if (octet1 === 100 && octet2 >= 64 && octet2 <= 127) return true;
return false;
}
export function isPrivateIpAddress(address: string): boolean {
let normalized = address.trim().toLowerCase();
if (normalized.startsWith("[") && normalized.endsWith("]")) {
normalized = normalized.slice(1, -1);
}
if (!normalized) return false;
if (normalized.startsWith("::ffff:")) {
const mapped = normalized.slice("::ffff:".length);
const ipv4 = parseIpv4FromMappedIpv6(mapped);
if (ipv4) return isPrivateIpv4(ipv4);
}
if (normalized.includes(":")) {
if (normalized === "::" || normalized === "::1") return true;
return PRIVATE_IPV6_PREFIXES.some((prefix) => normalized.startsWith(prefix));
}
const ipv4 = parseIpv4(normalized);
if (!ipv4) return false;
return isPrivateIpv4(ipv4);
}
export function isBlockedHostname(hostname: string): boolean {
const normalized = normalizeHostname(hostname);
if (!normalized) return false;
if (BLOCKED_HOSTNAMES.has(normalized)) return true;
return (
normalized.endsWith(".localhost") ||
normalized.endsWith(".local") ||
normalized.endsWith(".internal")
);
}
export async function assertPublicHostname(
hostname: string,
lookupFn: LookupFn = dnsLookup,
): Promise<void> {
const normalized = normalizeHostname(hostname);
if (!normalized) {
throw new Error("Invalid hostname");
}
if (isBlockedHostname(normalized)) {
throw new SsrFBlockedError(`Blocked hostname: ${hostname}`);
}
if (isPrivateIpAddress(normalized)) {
throw new SsrFBlockedError("Blocked: private/internal IP address");
}
const results = await lookupFn(normalized, { all: true });
if (results.length === 0) {
throw new Error(`Unable to resolve hostname: ${hostname}`);
}
for (const entry of results) {
if (isPrivateIpAddress(entry.address)) {
throw new SsrFBlockedError("Blocked: resolves to private/internal IP address");
}
}
}
-2
View File
@@ -39,7 +39,6 @@ export type NodePairingPairedNode = {
remoteIp?: string;
createdAtMs: number;
approvedAtMs: number;
lastConnectedAtMs?: number;
};
export type NodePairingList = {
@@ -296,7 +295,6 @@ export async function updatePairedNodeMetadata(
commands: patch.commands ?? existing.commands,
bins: patch.bins ?? existing.bins,
permissions: patch.permissions ?? existing.permissions,
lastConnectedAtMs: patch.lastConnectedAtMs ?? existing.lastConnectedAtMs,
};
state.pairedByNodeId[normalized] = next;
+45 -1
View File
@@ -1,5 +1,5 @@
import { lookup } from "node:dns/promises";
import { logWarn } from "../logger.js";
import { assertPublicHostname } from "../infra/net/ssrf.js";
type CanvasModule = typeof import("@napi-rs/canvas");
type PdfJsModule = typeof import("pdfjs-dist/legacy/build/pdf.mjs");
@@ -107,6 +107,50 @@ export const DEFAULT_INPUT_PDF_MAX_PAGES = 4;
export const DEFAULT_INPUT_PDF_MAX_PIXELS = 4_000_000;
export const DEFAULT_INPUT_PDF_MIN_TEXT_CHARS = 200;
const PRIVATE_IPV4_PATTERNS = [
/^127\./,
/^10\./,
/^192\.168\./,
/^172\.(1[6-9]|2[0-9]|3[0-1])\./,
/^0\./,
];
const PRIVATE_IPV6_PREFIXES = ["::1", "fe80:", "fec0:", "fc", "fd"];
function isPrivateIpAddress(address: string): boolean {
if (address.includes(":")) {
const lower = address.toLowerCase();
if (lower === "::1") return true;
return PRIVATE_IPV6_PREFIXES.some((prefix) => lower.startsWith(prefix));
}
return PRIVATE_IPV4_PATTERNS.some((pattern) => pattern.test(address));
}
function isBlockedHostname(hostname: string): boolean {
const lower = hostname.toLowerCase();
return (
lower === "localhost" ||
lower.endsWith(".localhost") ||
lower.endsWith(".local") ||
lower.endsWith(".internal")
);
}
async function assertPublicHostname(hostname: string): Promise<void> {
if (isBlockedHostname(hostname)) {
throw new Error(`Blocked hostname: ${hostname}`);
}
const results = await lookup(hostname, { all: true });
if (results.length === 0) {
throw new Error(`Unable to resolve hostname: ${hostname}`);
}
for (const entry of results) {
if (isPrivateIpAddress(entry.address)) {
throw new Error(`Private IP addresses are not allowed: ${entry.address}`);
}
}
}
function isRedirectStatus(status: number): boolean {
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
}
+1 -1
View File
@@ -545,7 +545,7 @@ async function handleInvoke(
const rawCommand = typeof params.rawCommand === "string" ? params.rawCommand.trim() : "";
const cmdText = rawCommand || formatCommand(argv);
const agentId = params.agentId?.trim() || undefined;
const approvals = resolveExecApprovals(agentId, { security: "allowlist" });
const approvals = resolveExecApprovals(agentId);
const security = approvals.agent.security;
const ask = approvals.agent.ask;
const autoAllowSkills = approvals.agent.autoAllowSkills;
-42
View File
@@ -1,42 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { createSafeStreamWriter } from "./stream-writer.js";
describe("createSafeStreamWriter", () => {
it("signals broken pipes and closes the writer", () => {
const onBrokenPipe = vi.fn();
const writer = createSafeStreamWriter({ onBrokenPipe });
const stream = {
write: vi.fn(() => {
const err = new Error("EPIPE") as NodeJS.ErrnoException;
err.code = "EPIPE";
throw err;
}),
} as unknown as NodeJS.WriteStream;
expect(writer.writeLine(stream, "hello")).toBe(false);
expect(writer.isClosed()).toBe(true);
expect(onBrokenPipe).toHaveBeenCalledTimes(1);
onBrokenPipe.mockClear();
expect(writer.writeLine(stream, "again")).toBe(false);
expect(onBrokenPipe).toHaveBeenCalledTimes(0);
});
it("treats broken pipes from beforeWrite as closed", () => {
const onBrokenPipe = vi.fn();
const writer = createSafeStreamWriter({
onBrokenPipe,
beforeWrite: () => {
const err = new Error("EIO") as NodeJS.ErrnoException;
err.code = "EIO";
throw err;
},
});
const stream = { write: vi.fn(() => true) } as unknown as NodeJS.WriteStream;
expect(writer.write(stream, "hi")).toBe(false);
expect(writer.isClosed()).toBe(true);
expect(onBrokenPipe).toHaveBeenCalledTimes(1);
});
});
-64
View File
@@ -1,64 +0,0 @@
export type SafeStreamWriterOptions = {
beforeWrite?: () => void;
onBrokenPipe?: (err: NodeJS.ErrnoException, stream: NodeJS.WriteStream) => void;
};
export type SafeStreamWriter = {
write: (stream: NodeJS.WriteStream, text: string) => boolean;
writeLine: (stream: NodeJS.WriteStream, text: string) => boolean;
reset: () => void;
isClosed: () => boolean;
};
function isBrokenPipeError(err: unknown): err is NodeJS.ErrnoException {
const code = (err as NodeJS.ErrnoException)?.code;
return code === "EPIPE" || code === "EIO";
}
export function createSafeStreamWriter(options: SafeStreamWriterOptions = {}): SafeStreamWriter {
let closed = false;
let notified = false;
const noteBrokenPipe = (err: NodeJS.ErrnoException, stream: NodeJS.WriteStream) => {
if (notified) return;
notified = true;
options.onBrokenPipe?.(err, stream);
};
const handleError = (err: unknown, stream: NodeJS.WriteStream): boolean => {
if (!isBrokenPipeError(err)) {
throw err;
}
closed = true;
noteBrokenPipe(err, stream);
return false;
};
const write = (stream: NodeJS.WriteStream, text: string): boolean => {
if (closed) return false;
try {
options.beforeWrite?.();
} catch (err) {
return handleError(err, process.stderr);
}
try {
stream.write(text);
return !closed;
} catch (err) {
return handleError(err, stream);
}
};
const writeLine = (stream: NodeJS.WriteStream, text: string): boolean =>
write(stream, `${text}\n`);
return {
write,
writeLine,
reset: () => {
closed = false;
notified = false;
},
isClosed: () => closed,
};
}
+1
View File
@@ -1,4 +1,5 @@
@import "./chat/layout.css";
@import "./chat/legacy.css";
@import "./chat/text.css";
@import "./chat/grouped.css";
@import "./chat/tool-cards.css";
+1 -72
View File
@@ -91,7 +91,6 @@
/* Minimal Bubble Design - dynamic width based on content */
.chat-bubble {
position: relative;
display: inline-block;
border: 1px solid var(--border);
background: rgba(0, 0, 0, 0.12);
@@ -103,77 +102,6 @@
word-wrap: break-word;
}
.chat-bubble.has-copy {
padding-right: 36px;
}
.chat-copy-btn {
position: absolute;
top: 6px;
right: 8px;
border: 1px solid var(--border);
background: rgba(0, 0, 0, 0.22);
color: var(--muted);
border-radius: 8px;
padding: 4px 6px;
font-size: 14px;
line-height: 1;
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease-out, background 120ms ease-out;
}
.chat-copy-btn__icon {
display: inline-block;
width: 1em;
text-align: center;
}
.chat-bubble:hover .chat-copy-btn {
opacity: 1;
pointer-events: auto;
}
.chat-copy-btn:hover {
background: rgba(0, 0, 0, 0.3);
}
.chat-copy-btn[data-copying="1"] {
opacity: 0;
pointer-events: none;
}
.chat-copy-btn[data-error="1"] {
opacity: 1;
pointer-events: auto;
border-color: rgba(255, 69, 58, 0.8);
background: rgba(255, 69, 58, 0.18);
color: rgba(255, 69, 58, 1);
}
.chat-copy-btn[data-copied="1"] {
opacity: 1;
pointer-events: auto;
border-color: rgba(52, 199, 183, 0.8);
background: rgba(52, 199, 183, 0.18);
color: rgba(52, 199, 183, 1);
}
.chat-copy-btn:focus-visible {
opacity: 1;
pointer-events: auto;
outline: 2px solid var(--accent);
outline-offset: 2px;
}
@media (hover: none) {
.chat-copy-btn {
opacity: 1;
pointer-events: auto;
}
}
.chat-bubble:hover {
background: rgba(0, 0, 0, 0.18);
}
@@ -217,3 +145,4 @@
transform: translateY(0);
}
}
+43
View File
@@ -0,0 +1,43 @@
/* =============================================
LEGACY CHAT LINE LAYOUT (non-grouped)
============================================= */
.chat-line {
display: flex;
margin-bottom: 12px;
}
.chat-line.user {
justify-content: flex-end;
}
.chat-line.assistant,
.chat-line.other,
.chat-line.tool {
justify-content: flex-start;
}
.chat-line.tool .chat-bubble {
border-style: dashed;
opacity: 0.95;
}
.chat-msg {
display: grid;
gap: 6px;
max-width: min(900px, 95%);
}
.chat-line.user .chat-msg {
justify-items: end;
}
.chat-stamp {
font-size: 11px;
color: var(--muted);
}
.chat-line.user .chat-stamp {
text-align: right;
}
+16
View File
@@ -39,6 +39,10 @@ export function renderTab(state: AppViewState, tab: Tab) {
export function renderChatControls(state: AppViewState) {
const sessionOptions = resolveSessionOptions(state.sessionKey, state.sessionsResult);
// Icon for list view (legacy)
const listIcon = html`<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg>`;
// Icon for grouped view
const groupIcon = html`<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"></rect><rect x="14" y="3" width="7" height="7"></rect><rect x="14" y="14" width="7" height="7"></rect><rect x="3" y="14" width="7" height="7"></rect></svg>`;
// Refresh icon
const refreshIcon = html`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path></svg>`;
const focusIcon = html`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h3"></path><path d="M20 7V4h-3"></path><path d="M4 17v3h3"></path><path d="M20 17v3h-3"></path><circle cx="12" cy="12" r="3"></circle></svg>`;
@@ -112,6 +116,18 @@ export function renderChatControls(state: AppViewState) {
>
${focusIcon}
</button>
<button
class="btn btn--sm btn--icon ${state.settings.useNewChatLayout ? "active" : ""}"
@click=${() =>
state.applySettings({
...state.settings,
useNewChatLayout: !state.settings.useNewChatLayout,
})}
aria-pressed=${state.settings.useNewChatLayout}
title="${state.settings.useNewChatLayout ? "Switch to list view" : "Switch to grouped view"}"
>
${state.settings.useNewChatLayout ? groupIcon : listIcon}
</button>
</div>
`;
}
+9
View File
@@ -429,7 +429,11 @@ export function renderApp(state: AppViewState) {
disabledReason: chatDisabledReason,
error: state.lastError,
sessions: state.sessionsResult,
isToolOutputExpanded: (id) => state.toolOutputExpanded.has(id),
onToolOutputToggle: (id, expanded) =>
state.toggleToolOutput(id, expanded),
focusMode: state.settings.chatFocusMode,
useNewChatLayout: state.settings.useNewChatLayout,
onRefresh: () => {
state.resetToolStream();
return loadChatHistory(state);
@@ -439,6 +443,11 @@ export function renderApp(state: AppViewState) {
...state.settings,
chatFocusMode: !state.settings.chatFocusMode,
}),
onToggleLayout: () =>
state.applySettings({
...state.settings,
useNewChatLayout: !state.settings.useNewChatLayout,
}),
onChatScroll: (event) => state.handleChatScroll(event),
onDraftChange: (next) => (state.chatMessage = next),
onSend: () => state.handleSendChat(),
+12
View File
@@ -31,6 +31,7 @@ type ToolStreamHost = {
toolStreamById: Map<string, ToolStreamEntry>;
toolStreamOrder: string[];
chatToolMessages: Record<string, unknown>[];
toolOutputExpanded: Set<string>;
toolStreamSyncTimer: number | null;
};
@@ -135,9 +136,20 @@ export function resetToolStream(host: ToolStreamHost) {
host.toolStreamById.clear();
host.toolStreamOrder = [];
host.chatToolMessages = [];
host.toolOutputExpanded = new Set();
flushToolStreamSync(host);
}
export function toggleToolOutput(host: ToolStreamHost, id: string, expanded: boolean) {
const next = new Set(host.toolOutputExpanded);
if (expanded) {
next.add(id);
} else {
next.delete(id);
}
host.toolOutputExpanded = next;
}
export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPayload) {
if (!payload || payload.stream !== "tool") return;
const sessionKey =
+9
View File
@@ -33,6 +33,7 @@ import type { DevicePairingList } from "./controllers/devices";
import type { ExecApprovalRequest } from "./controllers/exec-approval";
import {
resetToolStream as resetToolStreamInternal,
toggleToolOutput as toggleToolOutputInternal,
type ToolStreamEntry,
} from "./app-tool-stream";
import {
@@ -108,6 +109,7 @@ export class ClawdbotApp extends LitElement {
@state() chatRunId: string | null = null;
@state() chatThinkingLevel: string | null = null;
@state() chatQueue: ChatQueueItem[] = [];
@state() toolOutputExpanded = new Set<string>();
// Sidebar state for tool output viewing
@state() sidebarOpen = false;
@state() sidebarContent: string | null = null;
@@ -299,6 +301,13 @@ export class ClawdbotApp extends LitElement {
);
}
toggleToolOutput(id: string, expanded: boolean) {
toggleToolOutputInternal(
this as unknown as Parameters<typeof toggleToolOutputInternal>[0],
id,
expanded,
);
}
applySettings(next: UiSettings) {
applySettingsInternal(
this as unknown as Parameters<typeof applySettingsInternal>[0],
+9 -8
View File
@@ -28,7 +28,12 @@ afterEach(() => {
});
describe("chat markdown rendering", () => {
it("renders markdown inside tool output sidebar", async () => {
it("renders markdown inside tool result cards", async () => {
localStorage.setItem(
"clawdbot.control.settings.v1",
JSON.stringify({ useNewChatLayout: false }),
);
const app = mountApp("/chat");
await app.updateComplete;
@@ -43,16 +48,12 @@ describe("chat markdown rendering", () => {
timestamp,
},
];
// Expand the tool output card so its markdown is rendered into the DOM.
app.toolOutputExpanded = new Set([`${timestamp}:1`]);
await app.updateComplete;
const toolCard = app.querySelector(".chat-tool-card") as HTMLElement | null;
expect(toolCard).not.toBeNull();
toolCard?.click();
await app.updateComplete;
const strong = app.querySelector(".sidebar-markdown strong");
const strong = app.querySelector(".chat-tool-card__output strong");
expect(strong?.textContent).toBe("world");
});
});
-94
View File
@@ -1,94 +0,0 @@
import { html, type TemplateResult } from "lit";
import { renderEmojiIcon, setEmojiIcon } from "../icons";
const COPIED_FOR_MS = 1500;
const ERROR_FOR_MS = 2000;
const COPY_LABEL = "Copy as markdown";
const COPIED_LABEL = "Copied";
const ERROR_LABEL = "Copy failed";
const COPY_ICON = "📋";
const COPIED_ICON = "✓";
const ERROR_ICON = "!";
type CopyButtonOptions = {
text: () => string;
label?: string;
};
async function copyTextToClipboard(text: string): Promise<boolean> {
if (!text) return false;
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
}
}
function setButtonLabel(button: HTMLButtonElement, label: string) {
button.title = label;
button.setAttribute("aria-label", label);
}
function createCopyButton(options: CopyButtonOptions): TemplateResult {
const idleLabel = options.label ?? COPY_LABEL;
return html`
<button
class="chat-copy-btn"
type="button"
title=${idleLabel}
aria-label=${idleLabel}
@click=${async (e: Event) => {
const btn = e.currentTarget as HTMLButtonElement | null;
const icon = btn?.querySelector(
".chat-copy-btn__icon",
) as HTMLElement | null;
if (!btn || btn.dataset.copying === "1") return;
btn.dataset.copying = "1";
btn.setAttribute("aria-busy", "true");
btn.disabled = true;
const copied = await copyTextToClipboard(options.text());
if (!btn.isConnected) return;
delete btn.dataset.copying;
btn.removeAttribute("aria-busy");
btn.disabled = false;
if (!copied) {
btn.dataset.error = "1";
setButtonLabel(btn, ERROR_LABEL);
setEmojiIcon(icon, ERROR_ICON);
window.setTimeout(() => {
if (!btn.isConnected) return;
delete btn.dataset.error;
setButtonLabel(btn, idleLabel);
setEmojiIcon(icon, COPY_ICON);
}, ERROR_FOR_MS);
return;
}
btn.dataset.copied = "1";
setButtonLabel(btn, COPIED_LABEL);
setEmojiIcon(icon, COPIED_ICON);
window.setTimeout(() => {
if (!btn.isConnected) return;
delete btn.dataset.copied;
setButtonLabel(btn, idleLabel);
setEmojiIcon(icon, COPY_ICON);
}, COPIED_FOR_MS);
}}
>
${renderEmojiIcon(COPY_ICON, "chat-copy-btn__icon")}
</button>
`;
}
export function renderCopyAsMarkdownButton(markdown: string): TemplateResult {
return createCopyButton({ text: () => markdown, label: COPY_LABEL });
}
+1 -4
View File
@@ -3,7 +3,6 @@ import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { toSanitizedMarkdownHtml } from "../markdown";
import type { MessageGroup } from "../types/chat-types";
import { renderCopyAsMarkdownButton } from "./copy-as-markdown";
import { isToolResultMessage, normalizeRoleForGrouping } from "./message-normalizer";
import {
extractText,
@@ -151,11 +150,9 @@ function renderGroupedMessage(
? formatReasoningMarkdown(extractedThinking)
: null;
const markdown = markdownBase;
const canCopyMarkdown = role === "assistant" && Boolean(markdown?.trim());
const bubbleClasses = [
"chat-bubble",
canCopyMarkdown ? "has-copy" : "",
opts.isStreaming ? "streaming" : "",
"fade-in",
]
@@ -172,7 +169,6 @@ function renderGroupedMessage(
return html`
<div class="${bubbleClasses}">
${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing}
${reasoningMarkdown
? html`<div class="chat-thinking">${unsafeHTML(
toSanitizedMarkdownHtml(reasoningMarkdown),
@@ -185,3 +181,4 @@ function renderGroupedMessage(
</div>
`;
}
+129
View File
@@ -0,0 +1,129 @@
import { html, nothing } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { toSanitizedMarkdownHtml } from "../markdown";
import {
isToolResultMessage,
normalizeRoleForGrouping,
} from "./message-normalizer";
import {
extractText,
extractThinking,
formatReasoningMarkdown,
} from "./message-extract";
import { extractToolCards, renderToolCardLegacy } from "./tool-cards";
export type LegacyToolOutputProps = {
isToolOutputExpanded?: (id: string) => boolean;
onToolOutputToggle?: (id: string, expanded: boolean) => void;
};
export function renderReadingIndicator() {
return html`
<div class="chat-line assistant">
<div class="chat-msg">
<div class="chat-bubble chat-reading-indicator" aria-hidden="true">
<span class="chat-reading-indicator__dots">
<span></span><span></span><span></span>
</span>
</div>
</div>
</div>
`;
}
export function renderMessage(
message: unknown,
props?: LegacyToolOutputProps,
opts?: { streaming?: boolean; showReasoning?: boolean },
) {
const m = message as Record<string, unknown>;
const role = typeof m.role === "string" ? m.role : "unknown";
const toolCards = extractToolCards(message);
const hasToolCards = toolCards.length > 0;
const isToolResult =
isToolResultMessage(message) ||
typeof m.toolCallId === "string" ||
typeof m.tool_call_id === "string";
const extractedText = extractText(message);
const extractedThinking =
opts?.showReasoning && role === "assistant" ? extractThinking(message) : null;
const contentText = typeof m.content === "string" ? m.content : null;
const fallback = hasToolCards ? null : JSON.stringify(message, null, 2);
const display =
!isToolResult && extractedText?.trim()
? { kind: "text" as const, value: extractedText }
: !isToolResult && contentText?.trim()
? { kind: "text" as const, value: contentText }
: !isToolResult && fallback
? { kind: "json" as const, value: fallback }
: null;
const markdownBase =
display?.kind === "json"
? ["```json", display.value, "```"].join("\n")
: (display?.value ?? null);
const reasoningMarkdown = extractedThinking
? formatReasoningMarkdown(extractedThinking)
: null;
const markdown = markdownBase;
const timestamp =
typeof m.timestamp === "number" ? new Date(m.timestamp).toLocaleTimeString() : "";
const normalizedRole = normalizeRoleForGrouping(role);
const klass =
normalizedRole === "assistant"
? "assistant"
: normalizedRole === "user"
? "user"
: normalizedRole === "tool"
? "tool"
: "other";
const who =
normalizedRole === "assistant"
? "Assistant"
: normalizedRole === "user"
? "You"
: normalizedRole === "tool"
? "Working"
: normalizedRole;
const toolCallId = typeof m.toolCallId === "string" ? m.toolCallId : "";
const toolCardBase =
toolCallId ||
(typeof m.id === "string" ? m.id : "") ||
(typeof m.messageId === "string" ? m.messageId : "") ||
(typeof m.timestamp === "number" ? String(m.timestamp) : "tool-card");
return html`
<div class="chat-line ${klass}">
<div class="chat-msg">
<div class="chat-bubble ${opts?.streaming ? "streaming" : ""}">
${reasoningMarkdown
? html`<div class="chat-thinking">${unsafeHTML(
toSanitizedMarkdownHtml(reasoningMarkdown),
)}</div>`
: nothing}
${markdown
? html`<div class="chat-text">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
: nothing}
${toolCards.map((card, index) =>
renderToolCardLegacy(card, {
id: `${toolCardBase}:${index}`,
expanded: props?.isToolOutputExpanded
? props.isToolOutputExpanded(`${toolCardBase}:${index}`)
: false,
onToggle: props?.onToolOutputToggle,
}),
)}
</div>
<div class="chat-stamp mono">
${who}${timestamp ? html` · ${timestamp}` : nothing}
</div>
</div>
</div>
`;
}
+57
View File
@@ -1,5 +1,7 @@
import { html, nothing } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { toSanitizedMarkdownHtml } from "../markdown";
import { formatToolDetail, resolveToolDisplay } from "../tool-display";
import type { ToolCard } from "../types/chat-types";
import { TOOL_INLINE_THRESHOLD } from "./constants";
@@ -52,6 +54,60 @@ export function extractToolCards(message: unknown): ToolCard[] {
return cards;
}
export function renderToolCardLegacy(
card: ToolCard,
opts?: {
id: string;
expanded: boolean;
onToggle?: (id: string, expanded: boolean) => void;
},
) {
const display = resolveToolDisplay({ name: card.name, args: card.args });
const detail = formatToolDetail(display);
const hasOutput = typeof card.text === "string" && card.text.length > 0;
const expanded = opts?.expanded ?? false;
const id = opts?.id ?? `${card.name}-${Math.random()}`;
return html`
<div class="chat-tool-card">
<div class="chat-tool-card__header">
<div class="chat-tool-card__title">
<span class="chat-tool-card__icon">${display.emoji}</span>
<span>${display.label}</span>
</div>
${!hasOutput ? html`<span class="chat-tool-card__status">✓</span>` : nothing}
</div>
${detail
? html`<div class="chat-tool-card__detail">${detail}</div>`
: nothing}
${hasOutput
? html`
<details
class="chat-tool-card__details"
?open=${expanded}
@toggle=${(e: Event) => {
if (!opts?.onToggle) return;
const target = e.currentTarget as HTMLDetailsElement;
opts.onToggle(id, target.open);
}}
>
<summary class="chat-tool-card__summary">
${expanded ? "Hide output" : "Show output"}
<span class="chat-tool-card__summary-meta">
(${card.text?.length ?? 0} chars)
</span>
</summary>
${expanded
? html`<div class="chat-tool-card__output chat-text">
${unsafeHTML(toSanitizedMarkdownHtml(card.text ?? ""))}
</div>`
: nothing}
</details>
`
: nothing}
</div>
`;
}
export function renderToolCardSidebar(
card: ToolCard,
onOpenSidebar?: (content: string) => void,
@@ -141,3 +197,4 @@ function extractToolText(item: Record<string, unknown>): string | undefined {
if (typeof item.content === "string") return item.content;
return undefined;
}
-10
View File
@@ -1,10 +0,0 @@
import { html, type TemplateResult } from "lit";
export function renderEmojiIcon(icon: string, className: string): TemplateResult {
return html`<span class=${className} aria-hidden="true">${icon}</span>`;
}
export function setEmojiIcon(target: HTMLElement | null, icon: string): void {
if (!target) return;
target.textContent = icon;
}
+6
View File
@@ -11,6 +11,7 @@ export type UiSettings = {
chatFocusMode: boolean;
chatShowThinking: boolean;
splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6)
useNewChatLayout: boolean; // Slack-style grouped messages layout
navCollapsed: boolean; // Collapsible sidebar state
navGroupsCollapsed: Record<string, boolean>; // Which nav groups are collapsed
};
@@ -30,6 +31,7 @@ export function loadSettings(): UiSettings {
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
useNewChatLayout: true, // Enabled by default
navCollapsed: false,
navGroupsCollapsed: {},
};
@@ -75,6 +77,10 @@ export function loadSettings(): UiSettings {
parsed.splitRatio <= 0.7
? parsed.splitRatio
: defaults.splitRatio,
useNewChatLayout:
typeof parsed.useNewChatLayout === "boolean"
? parsed.useNewChatLayout
: defaults.useNewChatLayout,
navCollapsed:
typeof parsed.navCollapsed === "boolean"
? parsed.navCollapsed
+31 -10
View File
@@ -8,6 +8,7 @@ import {
normalizeRoleForGrouping,
} from "../chat/message-normalizer";
import { extractText } from "../chat/message-extract";
import { renderMessage, renderReadingIndicator } from "../chat/legacy-render";
import {
renderMessageGroup,
renderReadingIndicatorGroup,
@@ -35,9 +36,14 @@ export type ChatProps = {
disabledReason: string | null;
error: string | null;
sessions: SessionsListResult | null;
// Legacy tool output expand/collapse (used when useNewChatLayout is false)
isToolOutputExpanded: (id: string) => boolean;
onToolOutputToggle: (id: string, expanded: boolean) => void;
// Focus mode
focusMode: boolean;
// Sidebar state
// Feature flag for new Slack-style layout with sidebar
useNewChatLayout?: boolean;
// Sidebar state (used when useNewChatLayout is true)
sidebarOpen?: boolean;
sidebarContent?: string | null;
sidebarError?: string | null;
@@ -45,6 +51,7 @@ export type ChatProps = {
// Event handlers
onRefresh: () => void;
onToggleFocusMode: () => void;
onToggleLayout?: () => void;
onDraftChange: (next: string) => void;
onSend: () => void;
onAbort?: () => void;
@@ -71,6 +78,7 @@ export function renderChat(props: ChatProps) {
const splitRatio = props.splitRatio ?? 0.6;
const sidebarOpen = Boolean(props.sidebarOpen && props.onCloseSidebar);
const useNewLayout = props.useNewChatLayout ?? false;
return html`
<section class="card chat">
@@ -114,15 +122,27 @@ export function renderChat(props: ChatProps) {
: nothing}
${repeat(buildChatItems(props), (item) => item.key, (item) => {
if (item.kind === "reading-indicator") {
return renderReadingIndicatorGroup();
return useNewLayout
? renderReadingIndicatorGroup()
: renderReadingIndicator();
}
if (item.kind === "stream") {
return renderStreamingGroup(
item.text,
item.startedAt,
props.onOpenSidebar,
);
return useNewLayout
? renderStreamingGroup(
item.text,
item.startedAt,
props.onOpenSidebar,
)
: renderMessage(
{
role: "assistant",
content: [{ type: "text", text: item.text }],
timestamp: item.startedAt,
},
props,
{ streaming: true, showReasoning },
);
}
if (item.kind === "group") {
@@ -132,12 +152,12 @@ export function renderChat(props: ChatProps) {
});
}
return nothing;
return renderMessage(item.message, props, { showReasoning });
})}
</div>
</div>
${sidebarOpen
${useNewLayout && sidebarOpen
? html`
<resizable-divider
.splitRatio=${splitRatio}
@@ -328,7 +348,8 @@ function buildChatItems(props: ChatProps): Array<ChatItem | MessageGroup> {
}
}
return groupMessages(items);
if (props.useNewChatLayout) return groupMessages(items);
return items;
}
function messageKey(message: unknown, index: number): string {