Compare commits

..
102 changed files with 784 additions and 5483 deletions

No files matched your search

+1 -8
View File
@@ -6,19 +6,12 @@ Docs: https://docs.clawd.bot
### Changes ### Changes
- Repo: remove the Peekaboo git submodule now that the SPM release is used. - Repo: remove the Peekaboo git submodule now that the SPM release is used.
- Gateway: raise default lane concurrency for main and sub-agent runs.
- Config: centralize default agent concurrency limits.
### Fixes ### Fixes
- Cron: serialize scheduler operations per store path to prevent duplicate runs across hot reloads. (#1216) — thanks @carlulsoe.
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter). - Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
- Agents: treat OAuth refresh failures as auth errors to trigger model fallback. (#1261) — thanks @zknicker.
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs. - TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.
- CLI: avoid duplicating --profile/--dev flags when formatting commands. - CLI: avoid duplicating --profile/--dev flags when formatting commands.
- Auth: dedupe codex-cli profiles when tokens match custom openai-codex entries. (#1264) — thanks @odrobnik. - Agents: enforce tool policy/sandbox rules for tool-dispatched skill commands and forward tool media outputs. (#1235) — thanks @dougvk.
- Agents: avoid misclassifying context-window-too-small errors as context overflow. (#1266) — thanks @humanwritten.
- Slack: resolve Bolt default-export shapes for monitor startup. (#1208) — thanks @24601.
- UI: allow editing session labels in the Sessions table. (#1294) — thanks @bradleypriest.
## 2026.1.19-3 ## 2026.1.19-3
+19 -20
View File
@@ -471,25 +471,24 @@ Thanks to all clawtributors:
<p align="left"> <p align="left">
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a>
<a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a>
<a href="https://github.com/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/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a>
<a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a>
<a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/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/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a>
<a href="https://github.com/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/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/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/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a>
<a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a>
<a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/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/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/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/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/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a>
<a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/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/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a>
<a href="https://github.com/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/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/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/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a>
<a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/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/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/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a>
<a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a>
<a href="https://github.com/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/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a>
<a href="https://github.com/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=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a>
<a href="https://github.com/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/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/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/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a>
<a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a>
<a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></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/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=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
<a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
</p> </p>
File diff suppressed because it is too large. Load diff
-66
View File
@@ -1,66 +0,0 @@
---
summary: "CLI reference for `clawdbot devices` (device pairing + token rotation/revocation)"
read_when:
- You are approving device pairing requests
- You need to rotate or revoke device tokens
---
# `clawdbot devices`
Manage device pairing requests and device-scoped tokens.
## Commands
### `clawdbot devices list`
List pending pairing requests and paired devices.
```
clawdbot devices list
clawdbot devices list --json
```
### `clawdbot devices approve <requestId>`
Approve a pending device pairing request.
```
clawdbot devices approve <requestId>
```
### `clawdbot devices reject <requestId>`
Reject a pending device pairing request.
```
clawdbot devices reject <requestId>
```
### `clawdbot devices rotate --device <id> --role <role> [--scope <scope...>]`
Rotate a device token for a specific role (optionally updating scopes).
```
clawdbot devices rotate --device <deviceId> --role operator --scope operator.read --scope operator.write
```
### `clawdbot devices revoke --device <id> --role <role>`
Revoke a device token for a specific role.
```
clawdbot devices revoke --device <deviceId> --role node
```
## Common options
- `--url <url>`: Gateway WebSocket URL (defaults to `gateway.remote.url` when configured).
- `--token <token>`: Gateway token (if required).
- `--password <password>`: Gateway password (password auth).
- `--timeout <ms>`: RPC timeout.
- `--json`: JSON output (recommended for scripting).
## Notes
- Token rotation returns a new token (sensitive). Treat it like a secret.
- These commands require `operator.pairing` (or `operator.admin`) scope.
+2 -3
View File
@@ -116,18 +116,17 @@ clawdbot gateway call logs.tail --params '{"sinceMs": 60000}'
## Discover gateways (Bonjour) ## Discover gateways (Bonjour)
`gateway discover` scans for Gateway beacons (`_clawdbot-gateway._tcp`). `gateway discover` scans for Gateway bridge beacons (`_clawdbot-bridge._tcp`).
- Multicast DNS-SD: `local.` - Multicast DNS-SD: `local.`
- Unicast DNS-SD (Wide-Area Bonjour): `clawdbot.internal.` (requires split DNS + DNS server; see [/gateway/bonjour](/gateway/bonjour)) - Unicast DNS-SD (Wide-Area Bonjour): `clawdbot.internal.` (requires split DNS + DNS server; see [/gateway/bonjour](/gateway/bonjour))
Only gateways with Bonjour discovery enabled (default) advertise the beacon. Only gateways with the **bridge enabled** will advertise the discovery beacon.
Wide-Area discovery records include (TXT): Wide-Area discovery records include (TXT):
- `gatewayPort` (WebSocket port, usually `18789`) - `gatewayPort` (WebSocket port, usually `18789`)
- `sshPort` (SSH port; defaults to `22` if not present) - `sshPort` (SSH port; defaults to `22` if not present)
- `tailnetDns` (MagicDNS hostname, when available) - `tailnetDns` (MagicDNS hostname, when available)
- `gatewayTls` / `gatewayTlsSha256` (TLS enabled + cert fingerprint)
- `cliPath` (optional hint for remote installs) - `cliPath` (optional hint for remote installs)
### `gateway discover` ### `gateway discover`
-2
View File
@@ -34,7 +34,6 @@ This page describes the current CLI behavior. If commands change, update this do
- [`models`](/cli/models) - [`models`](/cli/models)
- [`memory`](/cli/memory) - [`memory`](/cli/memory)
- [`nodes`](/cli/nodes) - [`nodes`](/cli/nodes)
- [`devices`](/cli/devices)
- [`node`](/cli/node) - [`node`](/cli/node)
- [`approvals`](/cli/approvals) - [`approvals`](/cli/approvals)
- [`sandbox`](/cli/sandbox) - [`sandbox`](/cli/sandbox)
@@ -189,7 +188,6 @@ clawdbot [--dev] [--profile <name>] <command>
runs runs
run run
nodes nodes
devices
node node
start start
daemon daemon
+1 -1
View File
@@ -12,7 +12,7 @@ We serialize inbound auto-reply runs (all channels) through a tiny in-process qu
- Serializing avoids competing for shared resources (session files, logs, CLI stdin) and reduces the chance of upstream rate limits. - Serializing avoids competing for shared resources (session files, logs, CLI stdin) and reduces the chance of upstream rate limits.
## How it works ## How it works
- A lane-aware FIFO queue drains each lane with a configurable concurrency cap (default 1 for unconfigured lanes; main defaults to 4, subagent to 8). - A lane-aware FIFO queue drains each lane with a configurable concurrency cap (default 1).
- `runEmbeddedPiAgent` enqueues by **session key** (lane `session:<key>`) to guarantee only one active run per session. - `runEmbeddedPiAgent` enqueues by **session key** (lane `session:<key>`) to guarantee only one active run per session.
- Each session run is then queued into a **global lane** (`main` by default) so overall parallelism is capped by `agents.defaults.maxConcurrent`. - Each session run is then queued into a **global lane** (`main` by default) so overall parallelism is capped by `agents.defaults.maxConcurrent`.
- When verbose logging is enabled, queued runs emit a short notice if they waited more than ~2s before starting. - When verbose logging is enabled, queued runs emit a short notice if they waited more than ~2s before starting.
+3 -29
View File
@@ -1840,7 +1840,7 @@ Example:
`agents.defaults.subagents` configures sub-agent defaults: `agents.defaults.subagents` configures sub-agent defaults:
- `model`: default model for spawned sub-agents (string or `{ primary, fallbacks }`). If omitted, sub-agents inherit the caller’s model unless overridden per agent or per call. - `model`: default model for spawned sub-agents (string or `{ primary, fallbacks }`). If omitted, sub-agents inherit the caller’s model unless overridden per agent or per call.
- `maxConcurrent`: max concurrent sub-agent runs (default 8) - `maxConcurrent`: max concurrent sub-agent runs (default 1)
- `archiveAfterMinutes`: auto-archive sub-agent sessions after N minutes (default 60; set `0` to disable) - `archiveAfterMinutes`: auto-archive sub-agent sessions after N minutes (default 60; set `0` to disable)
- Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny` (deny wins) - Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny` (deny wins)
@@ -1974,7 +1974,7 @@ Notes:
`agents.defaults.maxConcurrent` sets the maximum number of embedded agent runs that can `agents.defaults.maxConcurrent` sets the maximum number of embedded agent runs that can
execute in parallel across sessions. Each session is still serialized (one run execute in parallel across sessions. Each session is still serialized (one run
per session key at a time). Default: 4. per session key at a time). Default: 1.
### `agents.defaults.sandbox` ### `agents.defaults.sandbox`
@@ -2677,7 +2677,7 @@ Notes:
Auth and Tailscale: Auth and Tailscale:
- `gateway.auth.mode` sets the handshake requirements (`token` or `password`). - `gateway.auth.mode` sets the handshake requirements (`token` or `password`).
- `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine and as the bootstrap credential for device pairing). - `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine).
- When `gateway.auth.mode` is set, only that method is accepted (plus optional Tailscale headers). - When `gateway.auth.mode` is set, only that method is accepted (plus optional Tailscale headers).
- `gateway.auth.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended). - `gateway.auth.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended).
- `gateway.auth.allowTailscale` allows Tailscale Serve identity headers - `gateway.auth.allowTailscale` allows Tailscale Serve identity headers
@@ -2686,9 +2686,6 @@ Auth and Tailscale:
`true`, Serve requests do not need a token/password; set `false` to require `true`, Serve requests do not need a token/password; set `false` to require
explicit credentials. Defaults to `true` when `tailscale.mode = "serve"` and explicit credentials. Defaults to `true` when `tailscale.mode = "serve"` and
auth mode is not `password`. auth mode is not `password`.
- After pairing, the Gateway issues **device tokens** scoped to the device role + scopes.
These are returned in `hello-ok.auth.deviceToken`; clients should persist and reuse them
instead of the shared token. Rotate/revoke via `device.token.rotate`/`device.token.revoke`.
- `gateway.tailscale.mode: "serve"` uses Tailscale Serve (tailnet only, loopback bind). - `gateway.tailscale.mode: "serve"` uses Tailscale Serve (tailnet only, loopback bind).
- `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth. - `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth.
- `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown. - `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown.
@@ -2716,29 +2713,6 @@ macOS app behavior:
} }
``` ```
### `gateway.nodes` (Node command allowlist)
The Gateway enforces a per-platform command allowlist for `node.invoke`. Nodes must both
**declare** a command and have it **allowed** by the Gateway to run it.
Use this section to extend or deny commands:
```json5
{
gateway: {
nodes: {
allowCommands: ["custom.vendor.command"], // extra commands beyond defaults
denyCommands: ["sms.send"] // block a command even if declared
}
}
}
```
Notes:
- `allowCommands` extends the built-in per-platform defaults.
- `denyCommands` always wins (even if the node claims the command).
- `node.invoke` rejects commands that are not declared by the node.
### `gateway.reload` (Config hot reload) ### `gateway.reload` (Config hot reload)
The Gateway watches `~/.clawdbot/clawdbot.json` (or `CLAWDBOT_CONFIG_PATH`) and applies changes automatically. The Gateway watches `~/.clawdbot/clawdbot.json` (or `CLAWDBOT_CONFIG_PATH`) and applies changes automatically.
-23
View File
@@ -59,18 +59,6 @@ Gateway → Client:
} }
``` ```
When a device token is issued, `hello-ok` also includes:
```json
{
"auth": {
"deviceToken": "…",
"role": "operator",
"scopes": ["operator.read", "operator.write"]
}
}
```
### Node example ### Node example
```json ```json
@@ -135,11 +123,6 @@ Nodes declare capability claims at connect time:
The Gateway treats these as **claims** and enforces server-side allowlists. The Gateway treats these as **claims** and enforces server-side allowlists.
### Node helper methods
- Nodes may call `skills.bins` to fetch the current list of skill executables
for auto-allow checks.
## Versioning ## Versioning
- `PROTOCOL_VERSION` lives in `src/gateway/protocol/schema.ts`. - `PROTOCOL_VERSION` lives in `src/gateway/protocol/schema.ts`.
@@ -153,11 +136,6 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
- If `CLAWDBOT_GATEWAY_TOKEN` (or `--token`) is set, `connect.params.auth.token` - If `CLAWDBOT_GATEWAY_TOKEN` (or `--token`) is set, `connect.params.auth.token`
must match or the socket is closed. must match or the socket is closed.
- After pairing, the Gateway issues a **device token** scoped to the connection
role + scopes. It is returned in `hello-ok.auth.deviceToken` and should be
persisted by the client for future connects.
- Device tokens can be rotated/revoked via `device.token.rotate` and
`device.token.revoke` (requires `operator.pairing` scope).
## Device identity + pairing ## Device identity + pairing
@@ -166,7 +144,6 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
- Gateways issue tokens per device + role. - Gateways issue tokens per device + role.
- Pairing approvals are required for new device IDs unless local auto-approval - Pairing approvals are required for new device IDs unless local auto-approval
is enabled. is enabled.
- All WS clients must include `device` identity during `connect` (operator + node).
## TLS + pinning ## TLS + pinning
-6
View File
@@ -235,12 +235,6 @@ Triggered when the gateway starts:
- **`gateway:startup`**: After channels start and hooks are loaded - **`gateway:startup`**: After channels start and hooks are loaded
### Tool Result Hooks (Plugin API)
These hooks are not event-stream listeners; they let plugins synchronously adjust tool results before Clawdbot persists them.
- **`tool_result_persist`**: transform tool results before they are written to the session transcript. Must be synchronous; return the updated tool result payload or `undefined` to keep it as-is. See [Agent Loop](/concepts/agent-loop).
### Future Events ### Future Events
Planned event types: Planned event types:
-16
View File
@@ -323,22 +323,6 @@ These are “real pipeline” regressions without real providers:
- Gateway tool calling (mock OpenAI, real gateway + agent loop): `src/gateway/gateway.tool-calling.mock-openai.test.ts` - Gateway tool calling (mock OpenAI, real gateway + agent loop): `src/gateway/gateway.tool-calling.mock-openai.test.ts`
- Gateway wizard (WS `wizard.start`/`wizard.next`, writes config + auth enforced): `src/gateway/gateway.wizard.e2e.test.ts` - Gateway wizard (WS `wizard.start`/`wizard.next`, writes config + auth enforced): `src/gateway/gateway.wizard.e2e.test.ts`
## Agent reliability evals (skills)
We already have a few CI-safe tests that behave like “agent reliability evals”:
- Mock tool-calling through the real gateway + agent loop (`src/gateway/gateway.tool-calling.mock-openai.test.ts`).
- End-to-end wizard flows that validate session wiring and config effects (`src/gateway/gateway.wizard.e2e.test.ts`).
What’s still missing for skills (see [Skills](/tools/skills)):
- **Decisioning:** when skills are listed in the prompt, does the agent pick the right skill (or avoid irrelevant ones)?
- **Compliance:** does the agent read `SKILL.md` before use and follow required steps/args?
- **Workflow contracts:** multi-turn scenarios that assert tool order, session history carryover, and sandbox boundaries.
Future evals should stay deterministic first:
- A scenario runner using mock providers to assert tool calls + order, skill file reads, and session wiring.
- A small suite of skill-focused scenarios (use vs avoid, gating, prompt injection).
- Optional live evals (opt-in, env-gated) only after the CI-safe suite is in place.
## Adding regressions (guidance) ## Adding regressions (guidance)
When you fix a provider/model issue discovered in live: When you fix a provider/model issue discovered in live:
+2
View File
@@ -88,6 +88,8 @@ Notes:
The tool is invoked with params: The tool is invoked with params:
`{ command: "<raw args>", commandName: "<slash command>", skillName: "<skill name>" }`. `{ command: "<raw args>", commandName: "<slash command>", skillName: "<skill name>" }`.
Tool-dispatch commands still respect tool policies/sandbox rules (same as normal model tools).
Replies are derived from tool results; include `MEDIA:` tokens or media URLs in tool output to send attachments.
## Gating (load-time filters) ## Gating (load-time filters)
+1
View File
@@ -104,6 +104,7 @@ Notes:
- **Skill commands:** `user-invocable` skills are exposed as slash commands. Names are sanitized to `a-z0-9_` (max 32 chars); collisions get numeric suffixes (e.g. `_2`). - **Skill commands:** `user-invocable` skills are exposed as slash commands. Names are sanitized to `a-z0-9_` (max 32 chars); collisions get numeric suffixes (e.g. `_2`).
- By default, skill commands are forwarded to the model as a normal request. - By default, skill commands are forwarded to the model as a normal request.
- Skills may optionally declare `command-dispatch: tool` to route the command directly to a tool (deterministic, no model). - Skills may optionally declare `command-dispatch: tool` to route the command directly to a tool (deterministic, no model).
- Tool-dispatch still respects tool policies/sandbox rules; replies use the tool result text/media (including `MEDIA:` tokens).
- **Native command arguments:** Discord uses autocomplete for dynamic options (and button menus when you omit required args). Telegram and Slack show a button menu when a command supports choices and you omit the arg. - **Native command arguments:** Discord uses autocomplete for dynamic options (and button menus when you omit required args). Telegram and Slack show a button menu when a command supports choices and you omit the arg.
## Usage surfaces (what shows where) ## Usage surfaces (what shows where)
+1 -1
View File
@@ -118,7 +118,7 @@ Override via config:
Sub-agents use a dedicated in-process queue lane: Sub-agents use a dedicated in-process queue lane:
- Lane name: `subagent` - Lane name: `subagent`
- Concurrency: `agents.defaults.subagents.maxConcurrent` (default `8`) - Concurrency: `agents.defaults.subagents.maxConcurrent` (default `1`)
## Stopping ## Stopping
-1
View File
@@ -1,6 +1,5 @@
{ {
"ensureLogins": [ "ensureLogins": [
"odrobnik",
"alphonse-arianee", "alphonse-arianee",
"ronak-guliani", "ronak-guliani",
"cpojer", "cpojer",
@@ -3,8 +3,7 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { ensureAuthProfileStore } from "./auth-profiles.js"; import { ensureAuthProfileStore } from "./auth-profiles.js";
import { AUTH_STORE_VERSION, CODEX_CLI_PROFILE_ID } from "./auth-profiles/constants.js"; import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js";
import { withTempHome } from "../../test/helpers/temp-home.js";
describe("ensureAuthProfileStore", () => { describe("ensureAuthProfileStore", () => {
it("migrates legacy auth.json and deletes it (PR #368)", () => { it("migrates legacy auth.json and deletes it (PR #368)", () => {
@@ -123,80 +122,4 @@ describe("ensureAuthProfileStore", () => {
fs.rmSync(root, { recursive: true, force: true }); fs.rmSync(root, { recursive: true, force: true });
} }
}); });
it("drops codex-cli from merged store when a custom openai-codex profile matches", async () => {
await withTempHome(async (tempHome) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-auth-dedup-merge-"));
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
try {
const mainDir = path.join(root, "main-agent");
const agentDir = path.join(root, "agent-x");
fs.mkdirSync(mainDir, { recursive: true });
fs.mkdirSync(agentDir, { recursive: true });
process.env.CLAWDBOT_AGENT_DIR = mainDir;
process.env.PI_CODING_AGENT_DIR = mainDir;
process.env.HOME = tempHome;
fs.writeFileSync(
path.join(mainDir, "auth-profiles.json"),
`${JSON.stringify(
{
version: AUTH_STORE_VERSION,
profiles: {
[CODEX_CLI_PROFILE_ID]: {
type: "oauth",
provider: "openai-codex",
access: "shared-access-token",
refresh: "shared-refresh-token",
expires: Date.now() + 3600000,
},
},
},
null,
2,
)}\n`,
"utf8",
);
fs.writeFileSync(
path.join(agentDir, "auth-profiles.json"),
`${JSON.stringify(
{
version: AUTH_STORE_VERSION,
profiles: {
"openai-codex:my-custom-profile": {
type: "oauth",
provider: "openai-codex",
access: "shared-access-token",
refresh: "shared-refresh-token",
expires: Date.now() + 3600000,
},
},
},
null,
2,
)}\n`,
"utf8",
);
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeUndefined();
expect(store.profiles["openai-codex:my-custom-profile"]).toBeDefined();
} finally {
if (previousAgentDir === undefined) {
delete process.env.CLAWDBOT_AGENT_DIR;
} else {
process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
}
if (previousPiAgentDir === undefined) {
delete process.env.PI_CODING_AGENT_DIR;
} else {
process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
}
fs.rmSync(root, { recursive: true, force: true });
}
});
});
}); });
@@ -1,166 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { withTempHome } from "../../test/helpers/temp-home.js";
import { CODEX_CLI_PROFILE_ID, ensureAuthProfileStore } from "./auth-profiles.js";
describe("external CLI credential sync", () => {
it("skips codex-cli sync when credentials already exist in another openai-codex profile", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-codex-dedup-skip-"));
try {
await withTempHome(
async (tempHome) => {
const codexDir = path.join(tempHome, ".codex");
fs.mkdirSync(codexDir, { recursive: true });
const codexAuthPath = path.join(codexDir, "auth.json");
fs.writeFileSync(
codexAuthPath,
JSON.stringify({
tokens: {
access_token: "shared-access-token",
refresh_token: "shared-refresh-token",
},
}),
);
fs.utimesSync(codexAuthPath, new Date(), new Date());
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
"openai-codex:my-custom-profile": {
type: "oauth",
provider: "openai-codex",
access: "shared-access-token",
refresh: "shared-refresh-token",
expires: Date.now() + 3600000,
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeUndefined();
expect(store.profiles["openai-codex:my-custom-profile"]).toBeDefined();
},
{ prefix: "clawdbot-home-" },
);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
it("creates codex-cli profile when credentials differ from existing openai-codex profiles", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-codex-dedup-create-"));
try {
await withTempHome(
async (tempHome) => {
const codexDir = path.join(tempHome, ".codex");
fs.mkdirSync(codexDir, { recursive: true });
const codexAuthPath = path.join(codexDir, "auth.json");
fs.writeFileSync(
codexAuthPath,
JSON.stringify({
tokens: {
access_token: "unique-access-token",
refresh_token: "unique-refresh-token",
},
}),
);
fs.utimesSync(codexAuthPath, new Date(), new Date());
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
"openai-codex:my-custom-profile": {
type: "oauth",
provider: "openai-codex",
access: "different-access-token",
refresh: "different-refresh-token",
expires: Date.now() + 3600000,
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeDefined();
expect((store.profiles[CODEX_CLI_PROFILE_ID] as { access: string }).access).toBe(
"unique-access-token",
);
expect(store.profiles["openai-codex:my-custom-profile"]).toBeDefined();
},
{ prefix: "clawdbot-home-" },
);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
it("removes codex-cli profile when it duplicates another openai-codex profile", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-codex-dedup-remove-"));
try {
await withTempHome(
async (tempHome) => {
const codexDir = path.join(tempHome, ".codex");
fs.mkdirSync(codexDir, { recursive: true });
const codexAuthPath = path.join(codexDir, "auth.json");
fs.writeFileSync(
codexAuthPath,
JSON.stringify({
tokens: {
access_token: "shared-access-token",
refresh_token: "shared-refresh-token",
},
}),
);
fs.utimesSync(codexAuthPath, new Date(), new Date());
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
[CODEX_CLI_PROFILE_ID]: {
type: "oauth",
provider: "openai-codex",
access: "shared-access-token",
refresh: "shared-refresh-token",
expires: Date.now() + 3600000,
},
"openai-codex:my-custom-profile": {
type: "oauth",
provider: "openai-codex",
access: "shared-access-token",
refresh: "shared-refresh-token",
expires: Date.now() + 3600000,
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeUndefined();
const saved = JSON.parse(fs.readFileSync(authPath, "utf8")) as {
profiles?: Record<string, unknown>;
};
expect(saved.profiles?.[CODEX_CLI_PROFILE_ID]).toBeUndefined();
expect(saved.profiles?.["openai-codex:my-custom-profile"]).toBeDefined();
},
{ prefix: "clawdbot-home-" },
);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
});
+16 -60
View File
@@ -58,26 +58,6 @@ function isExternalProfileFresh(cred: AuthProfileCredential | undefined, now: nu
return cred.expires > now + EXTERNAL_CLI_NEAR_EXPIRY_MS; return cred.expires > now + EXTERNAL_CLI_NEAR_EXPIRY_MS;
} }
/**
* Find any existing openai-codex profile (other than codex-cli) that has the same
* access and refresh tokens. This prevents creating a duplicate codex-cli profile
* when the user has already set up a custom profile with the same credentials.
*/
export function findDuplicateCodexProfile(
store: AuthProfileStore,
creds: OAuthCredential,
): string | undefined {
for (const [profileId, profile] of Object.entries(store.profiles)) {
if (profileId === CODEX_CLI_PROFILE_ID) continue;
if (profile.type !== "oauth") continue;
if (profile.provider !== "openai-codex") continue;
if (profile.access === creds.access && profile.refresh === creds.refresh) {
return profileId;
}
}
return undefined;
}
/** /**
* Sync OAuth credentials from external CLI tools (Claude Code CLI, Codex CLI) into the store. * Sync OAuth credentials from external CLI tools (Claude Code CLI, Codex CLI) into the store.
* This allows clawdbot to use the same credentials as these tools without requiring * This allows clawdbot to use the same credentials as these tools without requiring
@@ -163,55 +143,31 @@ export function syncExternalCliCredentials(
// Sync from Codex CLI // Sync from Codex CLI
const existingCodex = store.profiles[CODEX_CLI_PROFILE_ID]; const existingCodex = store.profiles[CODEX_CLI_PROFILE_ID];
const existingCodexOAuth = existingCodex?.type === "oauth" ? existingCodex : undefined;
const duplicateExistingId = existingCodexOAuth
? findDuplicateCodexProfile(store, existingCodexOAuth)
: undefined;
if (duplicateExistingId) {
delete store.profiles[CODEX_CLI_PROFILE_ID];
mutated = true;
log.info("removed codex-cli profile: credentials already exist in another profile", {
existingProfileId: duplicateExistingId,
removedProfileId: CODEX_CLI_PROFILE_ID,
});
}
const shouldSyncCodex = const shouldSyncCodex =
!existingCodex || !existingCodex ||
existingCodex.provider !== "openai-codex" || existingCodex.provider !== "openai-codex" ||
!isExternalProfileFresh(existingCodex, now); !isExternalProfileFresh(existingCodex, now);
const codexCreds = shouldSyncCodex || duplicateExistingId const codexCreds = shouldSyncCodex
? readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }) ? readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS })
: null; : null;
if (codexCreds) { if (codexCreds) {
const duplicateProfileId = findDuplicateCodexProfile(store, codexCreds); const existing = store.profiles[CODEX_CLI_PROFILE_ID];
if (duplicateProfileId) { const existingOAuth = existing?.type === "oauth" ? existing : undefined;
if (store.profiles[CODEX_CLI_PROFILE_ID]) {
delete store.profiles[CODEX_CLI_PROFILE_ID];
mutated = true;
log.info("removed codex-cli profile: credentials already exist in another profile", {
existingProfileId: duplicateProfileId,
removedProfileId: CODEX_CLI_PROFILE_ID,
});
}
} else {
const existing = store.profiles[CODEX_CLI_PROFILE_ID];
const existingOAuth = existing?.type === "oauth" ? existing : undefined;
// Codex creds don't carry expiry; use file mtime heuristic for freshness. // Codex creds don't carry expiry; use file mtime heuristic for freshness.
const shouldUpdate = const shouldUpdate =
!existingOAuth || !existingOAuth ||
existingOAuth.provider !== "openai-codex" || existingOAuth.provider !== "openai-codex" ||
existingOAuth.expires <= now || existingOAuth.expires <= now ||
codexCreds.expires > existingOAuth.expires; codexCreds.expires > existingOAuth.expires;
if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, codexCreds)) { if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, codexCreds)) {
store.profiles[CODEX_CLI_PROFILE_ID] = codexCreds; store.profiles[CODEX_CLI_PROFILE_ID] = codexCreds;
mutated = true; mutated = true;
log.info("synced openai-codex credentials from codex cli", { log.info("synced openai-codex credentials from codex cli", {
profileId: CODEX_CLI_PROFILE_ID, profileId: CODEX_CLI_PROFILE_ID,
expires: new Date(codexCreds.expires).toISOString(), expires: new Date(codexCreds.expires).toISOString(),
}); });
}
} }
} }
+3 -19
View File
@@ -3,13 +3,8 @@ import type { OAuthCredentials } from "@mariozechner/pi-ai";
import lockfile from "proper-lockfile"; import lockfile from "proper-lockfile";
import { resolveOAuthPath } from "../../config/paths.js"; import { resolveOAuthPath } from "../../config/paths.js";
import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js"; import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js";
import { import { AUTH_STORE_LOCK_OPTIONS, AUTH_STORE_VERSION, log } from "./constants.js";
AUTH_STORE_LOCK_OPTIONS, import { syncExternalCliCredentials } from "./external-cli-sync.js";
AUTH_STORE_VERSION,
CODEX_CLI_PROFILE_ID,
log,
} from "./constants.js";
import { findDuplicateCodexProfile, syncExternalCliCredentials } from "./external-cli-sync.js";
import { ensureAuthStoreFile, resolveAuthStorePath, resolveLegacyAuthStorePath } from "./paths.js"; import { ensureAuthStoreFile, resolveAuthStorePath, resolveLegacyAuthStorePath } from "./paths.js";
import type { AuthProfileCredential, AuthProfileStore, ProfileUsageStats } from "./types.js"; import type { AuthProfileCredential, AuthProfileStore, ProfileUsageStats } from "./types.js";
@@ -335,18 +330,7 @@ export function ensureAuthProfileStore(
} }
const mainStore = loadAuthProfileStoreForAgent(undefined, options); const mainStore = loadAuthProfileStoreForAgent(undefined, options);
const merged = mergeAuthProfileStores(mainStore, store); return mergeAuthProfileStores(mainStore, store);
// Keep per-agent view clean even if the main store has codex-cli.
const codexProfile = merged.profiles[CODEX_CLI_PROFILE_ID];
if (codexProfile?.type === "oauth") {
const duplicateId = findDuplicateCodexProfile(merged, codexProfile);
if (duplicateId) {
delete merged.profiles[CODEX_CLI_PROFILE_ID];
}
}
return merged;
} }
export function saveAuthProfileStore(store: AuthProfileStore, agentDir?: string): void { export function saveAuthProfileStore(store: AuthProfileStore, agentDir?: string): void {
+2 -4
View File
@@ -1,4 +1,2 @@
import { CommandLane } from "../process/lanes.js"; export const AGENT_LANE_NESTED = "nested" as const;
export const AGENT_LANE_SUBAGENT = "subagent" as const;
export const AGENT_LANE_NESTED = CommandLane.Nested;
export const AGENT_LANE_SUBAGENT = CommandLane.Subagent;
@@ -19,15 +19,6 @@ describe("isAuthErrorMessage", () => {
expect(isAuthErrorMessage(sample)).toBe(true); expect(isAuthErrorMessage(sample)).toBe(true);
} }
}); });
it("matches OAuth refresh failures", () => {
const samples = [
"OAuth token refresh failed for anthropic: Failed to refresh OAuth token for anthropic. Please try again or re-authenticate.",
"Please re-authenticate to continue.",
];
for (const sample of samples) {
expect(isAuthErrorMessage(sample)).toBe(true);
}
});
it("ignores unrelated errors", () => { it("ignores unrelated errors", () => {
expect(isAuthErrorMessage("rate limit exceeded")).toBe(false); expect(isAuthErrorMessage("rate limit exceeded")).toBe(false);
expect(isAuthErrorMessage("billing issue detected")).toBe(false); expect(isAuthErrorMessage("billing issue detected")).toBe(false);
@@ -1,34 +0,0 @@
import { describe, expect, it } from "vitest";
import { isLikelyContextOverflowError } from "./pi-embedded-helpers.js";
import { DEFAULT_AGENTS_FILENAME } from "./workspace.js";
const _makeFile = (overrides: Partial<WorkspaceBootstrapFile>): WorkspaceBootstrapFile => ({
name: DEFAULT_AGENTS_FILENAME,
path: "/tmp/AGENTS.md",
content: "",
missing: false,
...overrides,
});
describe("isLikelyContextOverflowError", () => {
it("matches context overflow hints", () => {
const samples = [
"Model context window is 128k tokens, you requested 256k tokens",
"Context window exceeded: requested 12000 tokens",
"Prompt too large for this model",
];
for (const sample of samples) {
expect(isLikelyContextOverflowError(sample)).toBe(true);
}
});
it("excludes context window too small errors", () => {
const samples = [
"Model context window too small (minimum is 128k tokens)",
"Context window too small: minimum is 1000 tokens",
];
for (const sample of samples) {
expect(isLikelyContextOverflowError(sample)).toBe(false);
}
});
});
-1
View File
@@ -19,7 +19,6 @@ export {
isCloudCodeAssistFormatError, isCloudCodeAssistFormatError,
isCompactionFailureError, isCompactionFailureError,
isContextOverflowError, isContextOverflowError,
isLikelyContextOverflowError,
isFailoverAssistantError, isFailoverAssistantError,
isFailoverErrorMessage, isFailoverErrorMessage,
isImageDimensionErrorMessage, isImageDimensionErrorMessage,
-13
View File
@@ -18,17 +18,6 @@ export function isContextOverflowError(errorMessage?: string): boolean {
); );
} }
const CONTEXT_WINDOW_TOO_SMALL_RE = /context window.*(too small|minimum is)/i;
const CONTEXT_OVERFLOW_HINT_RE =
/context.*overflow|context window.*(too (?:large|long)|exceed|over|limit|max(?:imum)?|requested|sent|tokens)|(?:prompt|request|input).*(too (?:large|long)|exceed|over|limit|max(?:imum)?)/i;
export function isLikelyContextOverflowError(errorMessage?: string): boolean {
if (!errorMessage) return false;
if (CONTEXT_WINDOW_TOO_SMALL_RE.test(errorMessage)) return false;
if (isContextOverflowError(errorMessage)) return true;
return CONTEXT_OVERFLOW_HINT_RE.test(errorMessage);
}
export function isCompactionFailureError(errorMessage?: string): boolean { export function isCompactionFailureError(errorMessage?: string): boolean {
if (!errorMessage) return false; if (!errorMessage) return false;
if (!isContextOverflowError(errorMessage)) return false; if (!isContextOverflowError(errorMessage)) return false;
@@ -339,8 +328,6 @@ const ERROR_PATTERNS = {
"incorrect api key", "incorrect api key",
"invalid token", "invalid token",
"authentication", "authentication",
"re-authenticate",
"oauth token refresh failed",
"unauthorized", "unauthorized",
"forbidden", "forbidden",
"access denied", "access denied",
+2 -4
View File
@@ -1,13 +1,11 @@
import { CommandLane } from "../../process/lanes.js";
export function resolveSessionLane(key: string) { export function resolveSessionLane(key: string) {
const cleaned = key.trim() || CommandLane.Main; const cleaned = key.trim() || "main";
return cleaned.startsWith("session:") ? cleaned : `session:${cleaned}`; return cleaned.startsWith("session:") ? cleaned : `session:${cleaned}`;
} }
export function resolveGlobalLane(lane?: string) { export function resolveGlobalLane(lane?: string) {
const cleaned = lane?.trim(); const cleaned = lane?.trim();
return cleaned ? cleaned : CommandLane.Main; return cleaned ? cleaned : "main";
} }
export function resolveEmbeddedSessionLane(key: string) { export function resolveEmbeddedSessionLane(key: string) {
@@ -24,11 +24,6 @@ export function handleAutoCompactionStart(ctx: EmbeddedPiSubscribeContext) {
ctx.state.compactionInFlight = true; ctx.state.compactionInFlight = true;
ctx.ensureCompactionPromise(); ctx.ensureCompactionPromise();
ctx.log.debug(`embedded run compaction start: runId=${ctx.params.runId}`); ctx.log.debug(`embedded run compaction start: runId=${ctx.params.runId}`);
emitAgentEvent({
runId: ctx.params.runId,
stream: "compaction",
data: { phase: "start" },
});
void ctx.params.onAgentEvent?.({ void ctx.params.onAgentEvent?.({
stream: "compaction", stream: "compaction",
data: { phase: "start" }, data: { phase: "start" },
@@ -48,11 +43,6 @@ export function handleAutoCompactionEnd(
} else { } else {
ctx.maybeResolveCompactionWait(); ctx.maybeResolveCompactionWait();
} }
emitAgentEvent({
runId: ctx.params.runId,
stream: "compaction",
data: { phase: "end", willRetry },
});
void ctx.params.onAgentEvent?.({ void ctx.params.onAgentEvent?.({
stream: "compaction", stream: "compaction",
data: { phase: "end", willRetry }, data: { phase: "end", willRetry },
@@ -109,34 +109,34 @@ export function handleMessageUpdate(
.trim(); .trim();
if (next && next !== ctx.state.lastStreamedAssistant) { if (next && next !== ctx.state.lastStreamedAssistant) {
const previousText = ctx.state.lastStreamedAssistant ?? ""; const previousText = ctx.state.lastStreamedAssistant ?? "";
ctx.state.lastStreamedAssistant = next;
const { text: cleanedText, mediaUrls } = parseReplyDirectives(next); const { text: cleanedText, mediaUrls } = parseReplyDirectives(next);
const { text: previousCleanedText } = parseReplyDirectives(previousText); const { text: previousCleanedText } = parseReplyDirectives(previousText);
if (cleanedText.startsWith(previousCleanedText)) { const deltaText = cleanedText.startsWith(previousCleanedText)
const deltaText = cleanedText.slice(previousCleanedText.length); ? cleanedText.slice(previousCleanedText.length)
ctx.state.lastStreamedAssistant = next; : cleanedText;
emitAgentEvent({ emitAgentEvent({
runId: ctx.params.runId, runId: ctx.params.runId,
stream: "assistant", stream: "assistant",
data: { data: {
text: cleanedText, text: cleanedText,
delta: deltaText, delta: deltaText,
mediaUrls: mediaUrls?.length ? mediaUrls : undefined, mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
}, },
});
void ctx.params.onAgentEvent?.({
stream: "assistant",
data: {
text: cleanedText,
delta: deltaText,
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
},
});
if (ctx.params.onPartialReply && ctx.state.shouldEmitPartialReplies) {
void ctx.params.onPartialReply({
text: cleanedText,
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
}); });
void ctx.params.onAgentEvent?.({
stream: "assistant",
data: {
text: cleanedText,
delta: deltaText,
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
},
});
if (ctx.params.onPartialReply && ctx.state.shouldEmitPartialReplies) {
void ctx.params.onPartialReply({
text: cleanedText,
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
});
}
} }
} }
@@ -184,72 +184,4 @@ describe("subscribeEmbeddedPiSession", () => {
expect(payloads[1]?.text).toBe("Hello world"); expect(payloads[1]?.text).toBe("Hello world");
expect(payloads[1]?.delta).toBe(" world"); expect(payloads[1]?.delta).toBe(" world");
}); });
it("skips agent events when cleaned text rewinds mid-stream", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onAgentEvent = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
runId: "run",
onAgentEvent,
});
handler?.({ type: "message_start", message: { role: "assistant" } });
handler?.({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: { type: "text_delta", delta: "MEDIA:" },
});
handler?.({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: { type: "text_delta", delta: " https://example.com/a.png\nCaption" },
});
const payloads = onAgentEvent.mock.calls
.map((call) => call[0]?.data as Record<string, unknown> | undefined)
.filter((value): value is Record<string, unknown> => Boolean(value));
expect(payloads).toHaveLength(1);
expect(payloads[0]?.text).toBe("MEDIA:");
});
it("emits agent events when media arrives without text", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onAgentEvent = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
runId: "run",
onAgentEvent,
});
handler?.({ type: "message_start", message: { role: "assistant" } });
handler?.({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: { type: "text_delta", delta: "MEDIA: https://example.com/a.png" },
});
const payloads = onAgentEvent.mock.calls
.map((call) => call[0]?.data as Record<string, unknown> | undefined)
.filter((value): value is Record<string, unknown> => Boolean(value));
expect(payloads).toHaveLength(1);
expect(payloads[0]?.text).toBe("");
expect(payloads[0]?.mediaUrls).toEqual(["https://example.com/a.png"]);
});
}); });
@@ -1,5 +1,4 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { onAgentEvent } from "../infra/agent-events.js";
import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js";
type StubSession = { type StubSession = {
@@ -55,44 +54,6 @@ describe("subscribeEmbeddedPiSession", () => {
await waitPromise; await waitPromise;
expect(resolved).toBe(true); expect(resolved).toBe(true);
}); });
it("emits compaction events on the agent event bus", async () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const events: Array<{ phase: string; willRetry?: boolean }> = [];
const stop = onAgentEvent((evt) => {
if (evt.runId !== "run-compaction") return;
if (evt.stream !== "compaction") return;
const phase = typeof evt.data?.phase === "string" ? evt.data.phase : "";
events.push({
phase,
willRetry: typeof evt.data?.willRetry === "boolean" ? evt.data.willRetry : undefined,
});
});
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
runId: "run-compaction",
});
handler?.({ type: "auto_compaction_start" });
handler?.({ type: "auto_compaction_end", willRetry: true });
handler?.({ type: "auto_compaction_end", willRetry: false });
stop();
expect(events).toEqual([
{ phase: "start" },
{ phase: "end", willRetry: true },
{ phase: "end", willRetry: false },
]);
});
it("emits tool summaries at tool start when verbose is on", async () => { it("emits tool summaries at tool start when verbose is on", async () => {
let handler: ((evt: unknown) => void) | undefined; let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = { const session: StubSession = {
@@ -8,6 +8,8 @@ import { describe, expect, it, afterEach } from "vitest";
import { loadClawdbotPlugins } from "../plugins/loader.js"; import { loadClawdbotPlugins } from "../plugins/loader.js";
import { resetGlobalHookRunner } from "../plugins/hook-runner-global.js"; import { resetGlobalHookRunner } from "../plugins/hook-runner-global.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import { guardSessionManager } from "./session-tool-result-guard-wrapper.js"; import { guardSessionManager } from "./session-tool-result-guard-wrapper.js";
const EMPTY_CONFIG_SCHEMA = `configSchema: { const EMPTY_CONFIG_SCHEMA = `configSchema: {
@@ -22,7 +24,15 @@ function writeTempPlugin(params: { dir: string; id: string; body: string }): str
return file; return file;
} }
const ORIGINAL_BUNDLED_PLUGINS_DIR = process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR;
afterEach(() => { afterEach(() => {
if (ORIGINAL_BUNDLED_PLUGINS_DIR === undefined) {
delete process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR;
} else {
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = ORIGINAL_BUNDLED_PLUGINS_DIR;
}
setActivePluginRegistry(createTestRegistry([]));
resetGlobalHookRunner(); resetGlobalHookRunner();
}); });
@@ -9,7 +9,6 @@ import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
import { import {
isCompactionFailureError, isCompactionFailureError,
isContextOverflowError, isContextOverflowError,
isLikelyContextOverflowError,
sanitizeUserFacingText, sanitizeUserFacingText,
} from "../../agents/pi-embedded-helpers.js"; } from "../../agents/pi-embedded-helpers.js";
import { import {
@@ -420,7 +419,9 @@ export async function runAgentTurnWithFallback(params: {
break; break;
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
const isContextOverflow = isLikelyContextOverflowError(message); const isContextOverflow =
isContextOverflowError(message) ||
/context.*overflow|too large|context window/i.test(message);
const isCompactionFailure = isCompactionFailureError(message); const isCompactionFailure = isCompactionFailureError(message);
const isSessionCorruption = /function call turn comes immediately after/i.test(message); const isSessionCorruption = /function call turn comes immediately after/i.test(message);
const isRoleOrderingError = /incorrect role information|roles must alternate/i.test(message); const isRoleOrderingError = /incorrect role information|roles must alternate/i.test(message);
@@ -0,0 +1,177 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import type { SkillCommandSpec } from "../../agents/skills.js";
import type { AnyAgentTool } from "../../agents/pi-tools.types.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { MsgContext, TemplateContext } from "../templating.js";
import type { InlineDirectives } from "./directive-handling.js";
import { parseInlineDirectives } from "./directive-handling.js";
import type { TypingController } from "./typing.js";
import { handleInlineActions } from "./get-reply-inline-actions.js";
vi.mock("../../agents/clawdbot-tools.js", () => ({
createClawdbotTools: vi.fn(),
}));
import { createClawdbotTools } from "../../agents/clawdbot-tools.js";
const mockedCreateClawdbotTools = vi.mocked(createClawdbotTools);
const createTypingController = (): TypingController => ({
onReplyStart: vi.fn(),
startTypingLoop: vi.fn(),
startTypingOnText: vi.fn(),
refreshTypingTtl: vi.fn(),
isActive: vi.fn(() => false),
markRunComplete: vi.fn(),
markDispatchIdle: vi.fn(),
cleanup: vi.fn(),
});
const baseCommand = {
surface: "slack",
channel: "slack",
ownerList: [],
isAuthorizedSender: true,
rawBodyNormalized: "/dispatch",
commandBodyNormalized: "/dispatch",
senderId: "user-1",
};
const baseDirectives = parseInlineDirectives("/dispatch") as InlineDirectives;
function createParams(overrides: Partial<Parameters<typeof handleInlineActions>[0]> = {}) {
const cfg = (overrides.cfg ??
({
tools: {
allow: ["tool_allowed"],
},
} as ClawdbotConfig)) as ClawdbotConfig;
return {
ctx: {
Surface: "slack",
Provider: "slack",
AccountId: "default",
} satisfies MsgContext as MsgContext,
sessionCtx: {
Body: "",
BodyForAgent: "",
BodyStripped: "",
} satisfies TemplateContext as TemplateContext,
cfg,
agentId: "main",
agentDir: "/tmp",
sessionEntry: undefined,
previousSessionEntry: undefined,
sessionStore: undefined,
sessionKey: "main",
storePath: undefined,
sessionScope: "per-sender",
workspaceDir: "/tmp",
isGroup: false,
opts: undefined,
typing: createTypingController(),
allowTextCommands: true,
inlineStatusRequested: false,
command: baseCommand,
skillCommands: [],
directives: baseDirectives,
cleanedBody: "/dispatch",
elevatedEnabled: false,
elevatedAllowed: false,
elevatedFailures: [],
defaultActivation: () => "always",
resolvedThinkLevel: "off",
resolvedVerboseLevel: "off",
resolvedReasoningLevel: "off",
resolvedElevatedLevel: "off",
resolveDefaultThinkingLevel: async () => undefined,
provider: "openai",
model: "gpt-4o-mini",
contextTokens: 0,
directiveAck: undefined,
abortedLastRun: false,
skillFilter: undefined,
...overrides,
};
}
function createTool(name: string, execute: AnyAgentTool["execute"]): AnyAgentTool {
return {
name,
label: name,
description: name,
parameters: {},
execute,
} as AnyAgentTool;
}
describe("handleInlineActions tool-dispatch", () => {
beforeEach(() => {
mockedCreateClawdbotTools.mockReset();
});
it("returns media payloads from tool results", async () => {
const tool = createTool("tool_allowed", async () => ({
content: [{ type: "text", text: "Done\nMEDIA:/tmp/photo.jpg" }],
}));
mockedCreateClawdbotTools.mockReturnValue([tool]);
const skillCommands: SkillCommandSpec[] = [
{
name: "dispatch",
skillName: "dispatch",
description: "Dispatch",
dispatch: { kind: "tool", toolName: "tool_allowed", argMode: "raw" },
},
];
const result = await handleInlineActions(
createParams({
command: { ...baseCommand, commandBodyNormalized: "/dispatch hi" },
skillCommands,
cleanedBody: "/dispatch hi",
}),
);
expect(result.kind).toBe("reply");
const reply = (result as { reply?: unknown }).reply as { text?: string; mediaUrl?: string };
expect(reply.text).toBe("Done");
expect(reply.mediaUrl).toBe("file:///tmp/photo.jpg");
});
it("blocks tool dispatch when policy disallows the tool", async () => {
const allowed = createTool("tool_allowed", async () => ({ content: "ok" }));
const blocked = createTool("tool_blocked", async () => ({ content: "nope" }));
mockedCreateClawdbotTools.mockReturnValue([allowed, blocked]);
const cfg = {
tools: {
allow: ["tool_allowed"],
},
} as ClawdbotConfig;
const skillCommands: SkillCommandSpec[] = [
{
name: "dispatch",
skillName: "dispatch",
description: "Dispatch",
dispatch: { kind: "tool", toolName: "tool_blocked", argMode: "raw" },
},
];
const result = await handleInlineActions(
createParams({
cfg,
skillCommands,
command: { ...baseCommand, commandBodyNormalized: "/dispatch arg" },
cleanedBody: "/dispatch arg",
}),
);
expect(result.kind).toBe("reply");
const reply = (result as { reply?: { text?: string; isError?: boolean } }).reply;
expect(reply?.text).toContain("Tool blocked by policy");
expect(reply?.isError).toBe(true);
});
});
+263 -17
View File
@@ -1,21 +1,47 @@
import { pathToFileURL } from "node:url";
import { getChannelDock } from "../../channels/dock.js"; import { getChannelDock } from "../../channels/dock.js";
import type { SkillCommandSpec } from "../../agents/skills.js"; import type { SkillCommandSpec } from "../../agents/skills.js";
import type { AnyAgentTool } from "../../agents/pi-tools.types.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js"; import type { SessionEntry } from "../../config/sessions.js";
import type { MsgContext, TemplateContext } from "../templating.js"; import type { MsgContext, TemplateContext } from "../templating.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js"; import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js";
import {
resolveSubagentToolPolicy,
resolveEffectiveToolPolicy,
filterToolsByPolicy,
} from "../../agents/pi-tools.policy.js";
import {
buildPluginToolGroups,
collectExplicitAllowlist,
expandPolicyWithPluginGroups,
normalizeToolName,
resolveToolProfilePolicy,
} from "../../agents/tool-policy.js";
import {
resolveSandboxRuntimeStatus,
formatSandboxToolPolicyBlockedMessage,
} from "../../agents/sandbox/runtime-status.js";
import { isSubagentSessionKey } from "../../routing/session-key.js";
import { resolveUserPath } from "../../utils.js";
import { getAbortMemory } from "./abort.js"; import { getAbortMemory } from "./abort.js";
import { buildStatusReply, handleCommands } from "./commands.js"; import { buildStatusReply, handleCommands } from "./commands.js";
import type { InlineDirectives } from "./directive-handling.js"; import type { InlineDirectives } from "./directive-handling.js";
import { isDirectiveOnly } from "./directive-handling.js"; import { isDirectiveOnly } from "./directive-handling.js";
import type { createModelSelectionState } from "./model-selection.js"; import type { createModelSelectionState } from "./model-selection.js";
import { extractInlineSimpleCommand } from "./reply-inline.js"; import { extractInlineSimpleCommand } from "./reply-inline.js";
import { parseReplyDirectives } from "./reply-directives.js";
import type { TypingController } from "./typing.js"; import type { TypingController } from "./typing.js";
import { listSkillCommandsForWorkspace, resolveSkillCommandInvocation } from "../skill-commands.js"; import { listSkillCommandsForWorkspace, resolveSkillCommandInvocation } from "../skill-commands.js";
import { logVerbose } from "../../globals.js"; import { logVerbose } from "../../globals.js";
import { createClawdbotTools } from "../../agents/clawdbot-tools.js"; import { createClawdbotTools } from "../../agents/clawdbot-tools.js";
import { resolveGatewayMessageChannel } from "../../utils/message-channel.js"; import {
resolveGatewayMessageChannel,
type GatewayMessageChannel,
} from "../../utils/message-channel.js";
import { getPluginToolMeta } from "../../plugins/tools.js";
export type InlineActionResult = export type InlineActionResult =
| { kind: "reply"; reply: ReplyPayload | ReplyPayload[] | undefined } | { kind: "reply"; reply: ReplyPayload | ReplyPayload[] | undefined }
@@ -25,9 +51,20 @@ export type InlineActionResult =
abortedLastRun: boolean; abortedLastRun: boolean;
}; };
function extractTextFromToolResult(result: any): string | null { function normalizeMediaUrlCandidate(raw: string): string | null {
if (!result || typeof result !== "object") return null; const trimmed = raw.trim();
const content = (result as { content?: unknown }).content; if (!trimmed) return null;
if (/^https?:\/\//i.test(trimmed)) return trimmed;
if (trimmed.startsWith("file://")) return trimmed;
const resolved = trimmed.startsWith("~") ? resolveUserPath(trimmed) : trimmed;
if (resolved.startsWith("/")) {
return pathToFileURL(resolved).toString();
}
if (resolved.startsWith("./") || resolved.startsWith("../")) return resolved;
return trimmed;
}
function extractTextFromToolResultContent(content: unknown): string | null {
if (typeof content === "string") { if (typeof content === "string") {
const trimmed = content.trim(); const trimmed = content.trim();
return trimmed ? trimmed : null; return trimmed ? trimmed : null;
@@ -42,9 +79,202 @@ function extractTextFromToolResult(result: any): string | null {
parts.push(rec.text); parts.push(rec.text);
} }
} }
const out = parts.join(""); const out = parts.join("\n").trim();
const trimmed = out.trim(); return out ? out : null;
return trimmed ? trimmed : null; }
function extractMediaUrlsFromDetails(details: unknown): string[] {
if (!details || typeof details !== "object") return [];
const record = details as Record<string, unknown>;
const candidates: string[] = [];
const mediaUrls = record.mediaUrls;
if (Array.isArray(mediaUrls)) {
for (const entry of mediaUrls) {
if (typeof entry === "string") candidates.push(entry);
}
}
const mediaUrl = record.mediaUrl;
if (typeof mediaUrl === "string") candidates.push(mediaUrl);
const media = record.media;
if (typeof media === "string") candidates.push(media);
const path = record.path;
if (typeof path === "string") candidates.push(path);
return candidates
.map((entry) => normalizeMediaUrlCandidate(entry))
.filter((entry): entry is string => Boolean(entry));
}
function extractReplyPayloadFromToolResult(result: unknown): ReplyPayload | null {
if (!result || typeof result !== "object") return null;
const maybePayload = result as ReplyPayload & { content?: unknown; details?: unknown };
if (
typeof maybePayload.text === "string" ||
typeof maybePayload.mediaUrl === "string" ||
Array.isArray(maybePayload.mediaUrls)
) {
return {
text: maybePayload.text?.trim() ? maybePayload.text.trim() : undefined,
mediaUrl: maybePayload.mediaUrl,
mediaUrls: maybePayload.mediaUrls,
replyToId: maybePayload.replyToId,
replyToTag: maybePayload.replyToTag,
replyToCurrent: maybePayload.replyToCurrent,
audioAsVoice: maybePayload.audioAsVoice,
isError: maybePayload.isError,
};
}
const content = maybePayload.content;
const text = extractTextFromToolResultContent(content);
const parsed = text
? parseReplyDirectives(text)
: {
text: "",
mediaUrls: undefined,
mediaUrl: undefined,
replyToId: undefined,
replyToCurrent: false,
replyToTag: false,
audioAsVoice: undefined,
isSilent: false,
};
if (parsed.isSilent) return null;
const mediaFromText = parsed.mediaUrls ?? (parsed.mediaUrl ? [parsed.mediaUrl] : []);
const mediaFromDetails = extractMediaUrlsFromDetails(maybePayload.details);
const mediaUrls = Array.from(new Set([...mediaFromText, ...mediaFromDetails]))
.map((entry) => normalizeMediaUrlCandidate(entry))
.filter((entry): entry is string => Boolean(entry));
const cleanedText = parsed.text?.trim() ? parsed.text.trim() : undefined;
if (!cleanedText && mediaUrls.length === 0) return null;
return {
text: cleanedText,
mediaUrls: mediaUrls.length ? mediaUrls : undefined,
mediaUrl: mediaUrls[0],
replyToId: parsed.replyToId,
replyToTag: parsed.replyToTag,
replyToCurrent: parsed.replyToCurrent,
audioAsVoice: parsed.audioAsVoice,
isError: (result as { isError?: unknown }).isError === true,
};
}
function resolveToolDispatchTools(params: {
cfg: ClawdbotConfig;
sessionKey: string;
provider: string;
model: string;
agentDir?: string;
agentChannel?: GatewayMessageChannel;
agentAccountId?: string;
workspaceDir: string;
}): {
allTools: AnyAgentTool[];
allowedTools: AnyAgentTool[];
sandboxed: boolean;
} {
const sandboxRuntime = resolveSandboxRuntimeStatus({
cfg: params.cfg,
sessionKey: params.sessionKey,
});
const {
profile,
providerProfile,
globalPolicy,
globalProviderPolicy,
agentPolicy,
agentProviderPolicy,
} = resolveEffectiveToolPolicy({
config: params.cfg,
sessionKey: params.sessionKey,
modelProvider: params.provider,
modelId: params.model,
});
const profilePolicy = resolveToolProfilePolicy(profile);
const providerProfilePolicy = resolveToolProfilePolicy(providerProfile);
const sandboxPolicy = sandboxRuntime.sandboxed ? sandboxRuntime.toolPolicy : undefined;
const subagentPolicy =
isSubagentSessionKey(params.sessionKey) && params.sessionKey
? resolveSubagentToolPolicy(params.cfg)
: undefined;
const pluginToolAllowlist = collectExplicitAllowlist([
profilePolicy,
providerProfilePolicy,
globalPolicy,
globalProviderPolicy,
agentPolicy,
agentProviderPolicy,
sandboxPolicy,
subagentPolicy,
]);
const allTools = createClawdbotTools({
agentSessionKey: params.sessionKey,
agentChannel: params.agentChannel,
agentAccountId: params.agentAccountId,
agentDir: params.agentDir,
workspaceDir: params.workspaceDir,
config: params.cfg,
sandboxed: sandboxRuntime.sandboxed,
pluginToolAllowlist,
}) as AnyAgentTool[];
const pluginGroups = buildPluginToolGroups({
tools: allTools,
toolMeta: (tool) => getPluginToolMeta(tool),
});
const profilePolicyExpanded = expandPolicyWithPluginGroups(profilePolicy, pluginGroups);
const providerProfilePolicyExpanded = expandPolicyWithPluginGroups(
providerProfilePolicy,
pluginGroups,
);
const globalPolicyExpanded = expandPolicyWithPluginGroups(globalPolicy, pluginGroups);
const globalProviderPolicyExpanded = expandPolicyWithPluginGroups(
globalProviderPolicy,
pluginGroups,
);
const agentPolicyExpanded = expandPolicyWithPluginGroups(agentPolicy, pluginGroups);
const agentProviderPolicyExpanded = expandPolicyWithPluginGroups(
agentProviderPolicy,
pluginGroups,
);
const sandboxPolicyExpanded = expandPolicyWithPluginGroups(sandboxPolicy, pluginGroups);
const subagentPolicyExpanded = expandPolicyWithPluginGroups(subagentPolicy, pluginGroups);
const toolsFiltered = profilePolicyExpanded
? filterToolsByPolicy(allTools, profilePolicyExpanded)
: allTools;
const providerProfileFiltered = providerProfilePolicyExpanded
? filterToolsByPolicy(toolsFiltered, providerProfilePolicyExpanded)
: toolsFiltered;
const globalFiltered = globalPolicyExpanded
? filterToolsByPolicy(providerProfileFiltered, globalPolicyExpanded)
: providerProfileFiltered;
const globalProviderFiltered = globalProviderPolicyExpanded
? filterToolsByPolicy(globalFiltered, globalProviderPolicyExpanded)
: globalFiltered;
const agentFiltered = agentPolicyExpanded
? filterToolsByPolicy(globalProviderFiltered, agentPolicyExpanded)
: globalProviderFiltered;
const agentProviderFiltered = agentProviderPolicyExpanded
? filterToolsByPolicy(agentFiltered, agentProviderPolicyExpanded)
: agentFiltered;
const sandboxed = sandboxPolicyExpanded
? filterToolsByPolicy(agentProviderFiltered, sandboxPolicyExpanded)
: agentProviderFiltered;
const subagentFiltered = subagentPolicyExpanded
? filterToolsByPolicy(sandboxed, subagentPolicyExpanded)
: sandboxed;
return {
allTools,
allowedTools: subagentFiltered,
sandboxed: sandboxRuntime.sandboxed,
};
} }
export async function handleInlineActions(params: { export async function handleInlineActions(params: {
@@ -163,18 +393,34 @@ export async function handleInlineActions(params: {
resolveGatewayMessageChannel(ctx.Surface) ?? resolveGatewayMessageChannel(ctx.Surface) ??
resolveGatewayMessageChannel(ctx.Provider) ?? resolveGatewayMessageChannel(ctx.Provider) ??
undefined; undefined;
const { allTools, allowedTools } = resolveToolDispatchTools({
const tools = createClawdbotTools({ cfg,
agentSessionKey: sessionKey, sessionKey,
provider,
model,
agentDir,
agentChannel: channel, agentChannel: channel,
agentAccountId: (ctx as { AccountId?: string }).AccountId, agentAccountId: (ctx as { AccountId?: string }).AccountId,
agentDir,
workspaceDir, workspaceDir,
config: cfg,
}); });
const requestedName = normalizeToolName(dispatch.toolName);
const tool = tools.find((candidate) => candidate.name === dispatch.toolName); const findTool = (tools: AnyAgentTool[]) =>
tools.find((candidate) => normalizeToolName(candidate.name) === requestedName);
const tool = findTool(allowedTools);
if (!tool) { if (!tool) {
const allTool = findTool(allTools);
if (allTool) {
const sandboxReason = formatSandboxToolPolicyBlockedMessage({
cfg,
sessionKey,
toolName: requestedName,
});
const message = sandboxReason
? `❌ Tool blocked by policy: ${dispatch.toolName}\n${sandboxReason}`
: `❌ Tool blocked by policy: ${dispatch.toolName}`;
typing.cleanup();
return { kind: "reply", reply: { text: message, isError: true } };
}
typing.cleanup(); typing.cleanup();
return { kind: "reply", reply: { text: `❌ Tool not available: ${dispatch.toolName}` } }; return { kind: "reply", reply: { text: `❌ Tool not available: ${dispatch.toolName}` } };
} }
@@ -186,13 +432,13 @@ export async function handleInlineActions(params: {
commandName: skillInvocation.command.name, commandName: skillInvocation.command.name,
skillName: skillInvocation.command.skillName, skillName: skillInvocation.command.skillName,
} as any); } as any);
const text = extractTextFromToolResult(result) ?? "✅ Done."; const reply = extractReplyPayloadFromToolResult(result) ?? { text: "✅ Done." };
typing.cleanup(); typing.cleanup();
return { kind: "reply", reply: { text } }; return { kind: "reply", reply };
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
typing.cleanup(); typing.cleanup();
return { kind: "reply", reply: { text: `❌ ${message}` } }; return { kind: "reply", reply: { text: `❌ ${message}`, isError: true } };
} }
} }
-225
View File
@@ -1,225 +0,0 @@
import type { Command } from "commander";
import { callGateway } from "../gateway/call.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { defaultRuntime } from "../runtime.js";
import { withProgress } from "./progress.js";
type DevicesRpcOpts = {
url?: string;
token?: string;
password?: string;
timeout?: string;
json?: boolean;
device?: string;
role?: string;
scope?: string[];
};
type DeviceTokenSummary = {
role: string;
scopes?: string[];
revokedAtMs?: number;
};
type PendingDevice = {
requestId: string;
deviceId: string;
displayName?: string;
role?: string;
remoteIp?: string;
isRepair?: boolean;
ts?: number;
};
type PairedDevice = {
deviceId: string;
displayName?: string;
roles?: string[];
scopes?: string[];
remoteIp?: string;
tokens?: DeviceTokenSummary[];
createdAtMs?: number;
approvedAtMs?: number;
};
type DevicePairingList = {
pending?: PendingDevice[];
paired?: PairedDevice[];
};
function formatAge(msAgo: number) {
const s = Math.max(0, Math.floor(msAgo / 1000));
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h`;
const d = Math.floor(h / 24);
return `${d}d`;
}
const devicesCallOpts = (cmd: Command, defaults?: { timeoutMs?: number }) =>
cmd
.option("--url <url>", "Gateway WebSocket URL (defaults to gateway.remote.url when configured)")
.option("--token <token>", "Gateway token (if required)")
.option("--password <password>", "Gateway password (password auth)")
.option("--timeout <ms>", "Timeout in ms", String(defaults?.timeoutMs ?? 10_000))
.option("--json", "Output JSON", false);
const callGatewayCli = async (method: string, opts: DevicesRpcOpts, params?: unknown) =>
withProgress(
{
label: `Devices ${method}`,
indeterminate: true,
enabled: opts.json !== true,
},
async () =>
await callGateway({
url: opts.url,
token: opts.token,
password: opts.password,
method,
params,
timeoutMs: Number(opts.timeout ?? 10_000),
clientName: GATEWAY_CLIENT_NAMES.CLI,
mode: GATEWAY_CLIENT_MODES.CLI,
}),
);
function parseDevicePairingList(value: unknown): DevicePairingList {
const obj = typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
return {
pending: Array.isArray(obj.pending) ? (obj.pending as PendingDevice[]) : [],
paired: Array.isArray(obj.paired) ? (obj.paired as PairedDevice[]) : [],
};
}
function formatTokenSummary(tokens: DeviceTokenSummary[] | undefined) {
if (!tokens || tokens.length === 0) return "tokens: none";
const parts = tokens
.map((t) => `${t.role}${t.revokedAtMs ? " (revoked)" : ""}`)
.sort((a, b) => a.localeCompare(b));
return `tokens: ${parts.join(", ")}`;
}
export function registerDevicesCli(program: Command) {
const devices = program.command("devices").description("Device pairing and auth tokens");
devicesCallOpts(
devices
.command("list")
.description("List pending and paired devices")
.action(async (opts: DevicesRpcOpts) => {
const result = await callGatewayCli("device.pair.list", opts, {});
const list = parseDevicePairingList(result);
if (opts.json) {
defaultRuntime.log(JSON.stringify(list, null, 2));
return;
}
if (list.pending?.length) {
defaultRuntime.log("Pending:");
for (const req of list.pending) {
const name = req.displayName || req.deviceId;
const repair = req.isRepair ? " (repair)" : "";
const ip = req.remoteIp ? ` · ${req.remoteIp}` : "";
const age = typeof req.ts === "number" ? ` · ${formatAge(Date.now() - req.ts)} ago` : "";
const role = req.role ? ` · role: ${req.role}` : "";
defaultRuntime.log(`- ${req.requestId}: ${name}${repair}${role}${ip}${age}`);
}
}
if (list.paired?.length) {
defaultRuntime.log("Paired:");
for (const device of list.paired) {
const name = device.displayName || device.deviceId;
const roles = device.roles?.length ? `roles: ${device.roles.join(", ")}` : "roles: -";
const scopes = device.scopes?.length ? `scopes: ${device.scopes.join(", ")}` : "scopes: -";
const ip = device.remoteIp ? ` · ${device.remoteIp}` : "";
const tokens = formatTokenSummary(device.tokens);
defaultRuntime.log(`- ${name} · ${roles} · ${scopes} · ${tokens}${ip}`);
}
}
if (!list.pending?.length && !list.paired?.length) {
defaultRuntime.log("No device pairing entries.");
}
}),
);
devicesCallOpts(
devices
.command("approve")
.description("Approve a pending device pairing request")
.argument("<requestId>", "Pending request id")
.action(async (requestId: string, opts: DevicesRpcOpts) => {
const result = await callGatewayCli("device.pair.approve", opts, { requestId });
if (opts.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
const deviceId = (result as { device?: { deviceId?: string } })?.device?.deviceId;
defaultRuntime.log(`device approved: ${deviceId ?? "ok"}`);
}),
);
devicesCallOpts(
devices
.command("reject")
.description("Reject a pending device pairing request")
.argument("<requestId>", "Pending request id")
.action(async (requestId: string, opts: DevicesRpcOpts) => {
const result = await callGatewayCli("device.pair.reject", opts, { requestId });
if (opts.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
const deviceId = (result as { deviceId?: string })?.deviceId;
defaultRuntime.log(`device rejected: ${deviceId ?? "ok"}`);
}),
);
devicesCallOpts(
devices
.command("rotate")
.description("Rotate a device token for a role")
.requiredOption("--device <id>", "Device id")
.requiredOption("--role <role>", "Role name")
.option("--scope <scope...>", "Scopes to attach to the token (repeatable)")
.action(async (opts: DevicesRpcOpts) => {
const deviceId = String(opts.device ?? "").trim();
const role = String(opts.role ?? "").trim();
if (!deviceId || !role) {
defaultRuntime.error("--device and --role required");
defaultRuntime.exit(1);
return;
}
const result = await callGatewayCli("device.token.rotate", opts, {
deviceId,
role,
scopes: Array.isArray(opts.scope) ? opts.scope : undefined,
});
defaultRuntime.log(JSON.stringify(result, null, 2));
}),
);
devicesCallOpts(
devices
.command("revoke")
.description("Revoke a device token for a role")
.requiredOption("--device <id>", "Device id")
.requiredOption("--role <role>", "Role name")
.action(async (opts: DevicesRpcOpts) => {
const deviceId = String(opts.device ?? "").trim();
const role = String(opts.role ?? "").trim();
if (!deviceId || !role) {
defaultRuntime.error("--device and --role required");
defaultRuntime.exit(1);
return;
}
const result = await callGatewayCli("device.token.revoke", opts, {
deviceId,
role,
});
defaultRuntime.log(JSON.stringify(result, null, 2));
}),
);
}
+1 -8
View File
@@ -64,8 +64,7 @@ export function renderBeaconLines(beacon: GatewayBonjourBeacon, rich: boolean):
const host = pickBeaconHost(beacon); const host = pickBeaconHost(beacon);
const gatewayPort = pickGatewayPort(beacon); const gatewayPort = pickGatewayPort(beacon);
const scheme = beacon.gatewayTls ? "wss" : "ws"; const wsUrl = host ? `ws://${host}:${gatewayPort}` : null;
const wsUrl = host ? `${scheme}://${host}:${gatewayPort}` : null;
const lines = [`- ${title} ${domain}`]; const lines = [`- ${title} ${domain}`];
@@ -82,12 +81,6 @@ export function renderBeaconLines(beacon: GatewayBonjourBeacon, rich: boolean):
if (wsUrl) { if (wsUrl) {
lines.push(` ${colorize(rich, theme.muted, "ws")}: ${colorize(rich, theme.command, wsUrl)}`); lines.push(` ${colorize(rich, theme.muted, "ws")}: ${colorize(rich, theme.command, wsUrl)}`);
} }
if (beacon.gatewayTls) {
const fingerprint = beacon.gatewayTlsFingerprintSha256
? `sha256 ${beacon.gatewayTlsFingerprintSha256}`
: "enabled";
lines.push(` ${colorize(rich, theme.muted, "tls")}: ${fingerprint}`);
}
if (typeof beacon.sshPort === "number" && beacon.sshPort > 0 && host) { if (typeof beacon.sshPort === "number" && beacon.sshPort > 0 && host) {
const ssh = `ssh -N -L 18789:127.0.0.1:18789 <user>@${host} -p ${beacon.sshPort}`; const ssh = `ssh -N -L 18789:127.0.0.1:18789 <user>@${host} -p ${beacon.sshPort}`;
lines.push(` ${colorize(rich, theme.muted, "ssh")}: ${colorize(rich, theme.command, ssh)}`); lines.push(` ${colorize(rich, theme.muted, "ssh")}: ${colorize(rich, theme.command, ssh)}`);
-8
View File
@@ -94,14 +94,6 @@ const entries: SubCliEntry[] = [
mod.registerNodesCli(program); mod.registerNodesCli(program);
}, },
}, },
{
name: "devices",
description: "Device pairing + token management",
register: async (program) => {
const mod = await import("../devices-cli.js");
mod.registerDevicesCli(program);
},
},
{ {
name: "node", name: "node",
description: "Node control", description: "Node control",
-38
View File
@@ -18,7 +18,6 @@ import { loadModelCatalog } from "../agents/model-catalog.js";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import * as configModule from "../config/config.js"; import * as configModule from "../config/config.js";
import { emitAgentEvent, onAgentEvent } from "../infra/agent-events.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { setActivePluginRegistry } from "../plugins/runtime.js"; import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createPluginRuntime } from "../plugins/runtime/index.js"; import { createPluginRuntime } from "../plugins/runtime/index.js";
@@ -140,43 +139,6 @@ describe("agentCommand", () => {
}); });
}); });
it("does not duplicate agent events from embedded runs", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store);
const assistantEvents: Array<{ runId: string; text?: string }> = [];
const stop = onAgentEvent((evt) => {
if (evt.stream !== "assistant") return;
assistantEvents.push({
runId: evt.runId,
text: typeof evt.data?.text === "string" ? (evt.data.text as string) : undefined,
});
});
vi.mocked(runEmbeddedPiAgent).mockImplementationOnce(async (params) => {
const runId = (params as { runId?: string } | undefined)?.runId ?? "run";
const data = { text: "hello", delta: "hello" };
(
params as {
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
}
).onAgentEvent?.({ stream: "assistant", data });
emitAgentEvent({ runId, stream: "assistant", data });
return {
payloads: [{ text: "hello" }],
meta: { agentMeta: { provider: "p", model: "m" } },
} as never;
});
await agentCommand({ message: "hi", to: "+1555" }, runtime);
stop();
const matching = assistantEvents.filter((evt) => evt.text === "hello");
expect(matching).toHaveLength(1);
});
});
it("uses provider/model from agents.defaults.model.primary", async () => { it("uses provider/model from agents.defaults.model.primary", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const store = path.join(home, "sessions.json"); const store = path.join(home, "sessions.json");
+5 -1
View File
@@ -434,7 +434,6 @@ export async function agentCommand(
streamParams: opts.streamParams, streamParams: opts.streamParams,
agentDir, agentDir,
onAgentEvent: (evt) => { onAgentEvent: (evt) => {
// Track lifecycle end for fallback emission below.
if ( if (
evt.stream === "lifecycle" && evt.stream === "lifecycle" &&
typeof evt.data?.phase === "string" && typeof evt.data?.phase === "string" &&
@@ -442,6 +441,11 @@ export async function agentCommand(
) { ) {
lifecycleEnded = true; lifecycleEnded = true;
} }
emitAgentEvent({
runId,
stream: evt.stream,
data: evt.data,
});
}, },
}); });
}, },
@@ -6,12 +6,6 @@ import path from "node:path";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { WebSocket } from "ws"; import { WebSocket } from "ws";
import {
loadOrCreateDeviceIdentity,
publicKeyRawBase64UrlFromPem,
signDevicePayload,
} from "../infra/device-identity.js";
import { buildDeviceAuthPayload } from "../gateway/device-auth.js";
import { PROTOCOL_VERSION } from "../gateway/protocol/index.js"; import { PROTOCOL_VERSION } from "../gateway/protocol/index.js";
import { rawDataToString } from "../infra/ws.js"; import { rawDataToString } from "../infra/ws.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
@@ -64,23 +58,6 @@ async function onceMessage<T = unknown>(
async function connectReq(params: { url: string; token?: string }) { async function connectReq(params: { url: string; token?: string }) {
const ws = new WebSocket(params.url); const ws = new WebSocket(params.url);
await new Promise<void>((resolve) => ws.once("open", resolve)); await new Promise<void>((resolve) => ws.once("open", resolve));
const identity = loadOrCreateDeviceIdentity();
const signedAtMs = Date.now();
const payload = buildDeviceAuthPayload({
deviceId: identity.deviceId,
clientId: GATEWAY_CLIENT_NAMES.TEST,
clientMode: GATEWAY_CLIENT_MODES.TEST,
role: "operator",
scopes: [],
signedAtMs,
token: params.token ?? null,
});
const device = {
id: identity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
signature: signDevicePayload(identity.privateKeyPem, payload),
signedAt: signedAtMs,
};
ws.send( ws.send(
JSON.stringify({ JSON.stringify({
type: "req", type: "req",
@@ -98,7 +75,6 @@ async function connectReq(params: { url: string; token?: string }) {
}, },
caps: [], caps: [],
auth: params.token ? { token: params.token } : undefined, auth: params.token ? { token: params.token } : undefined,
device,
}, },
}), }),
); );
@@ -6,12 +6,6 @@ import path from "node:path";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { WebSocket } from "ws"; import { WebSocket } from "ws";
import {
loadOrCreateDeviceIdentity,
publicKeyRawBase64UrlFromPem,
signDevicePayload,
} from "../infra/device-identity.js";
import { buildDeviceAuthPayload } from "../gateway/device-auth.js";
import { PROTOCOL_VERSION } from "../gateway/protocol/index.js"; import { PROTOCOL_VERSION } from "../gateway/protocol/index.js";
import { getFreePort as getFreeTestPort } from "../gateway/test-helpers.js"; import { getFreePort as getFreeTestPort } from "../gateway/test-helpers.js";
import { rawDataToString } from "../infra/ws.js"; import { rawDataToString } from "../infra/ws.js";
@@ -70,23 +64,6 @@ async function onceMessage<T = unknown>(
async function connectReq(params: { url: string; token?: string }) { async function connectReq(params: { url: string; token?: string }) {
const ws = new WebSocket(params.url); const ws = new WebSocket(params.url);
await new Promise<void>((resolve) => ws.once("open", resolve)); await new Promise<void>((resolve) => ws.once("open", resolve));
const identity = loadOrCreateDeviceIdentity();
const signedAtMs = Date.now();
const payload = buildDeviceAuthPayload({
deviceId: identity.deviceId,
clientId: GATEWAY_CLIENT_NAMES.TEST,
clientMode: GATEWAY_CLIENT_MODES.TEST,
role: "operator",
scopes: [],
signedAtMs,
token: params.token ?? null,
});
const device = {
id: identity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
signature: signDevicePayload(identity.privateKeyPem, payload),
signedAt: signedAtMs,
};
ws.send( ws.send(
JSON.stringify({ JSON.stringify({
type: "req", type: "req",
@@ -104,7 +81,6 @@ async function connectReq(params: { url: string; token?: string }) {
}, },
caps: [], caps: [],
auth: params.token ? { token: params.token } : undefined, auth: params.token ? { token: params.token } : undefined,
device,
}, },
}), }),
); );
-20
View File
@@ -1,20 +0,0 @@
import type { ClawdbotConfig } from "./types.js";
export const DEFAULT_AGENT_MAX_CONCURRENT = 4;
export const DEFAULT_SUBAGENT_MAX_CONCURRENT = 8;
export function resolveAgentMaxConcurrent(cfg?: ClawdbotConfig): number {
const raw = cfg?.agents?.defaults?.maxConcurrent;
if (typeof raw === "number" && Number.isFinite(raw)) {
return Math.max(1, Math.floor(raw));
}
return DEFAULT_AGENT_MAX_CONCURRENT;
}
export function resolveSubagentMaxConcurrent(cfg?: ClawdbotConfig): number {
const raw = cfg?.agents?.defaults?.subagents?.maxConcurrent;
if (typeof raw === "number" && Number.isFinite(raw)) {
return Math.max(1, Math.floor(raw));
}
return DEFAULT_SUBAGENT_MAX_CONCURRENT;
}
@@ -1,62 +0,0 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
DEFAULT_AGENT_MAX_CONCURRENT,
DEFAULT_SUBAGENT_MAX_CONCURRENT,
resolveAgentMaxConcurrent,
resolveSubagentMaxConcurrent,
} from "./agent-limits.js";
import { withTempHome } from "./test-helpers.js";
describe("agent concurrency defaults", () => {
it("resolves defaults when unset", () => {
expect(resolveAgentMaxConcurrent({})).toBe(DEFAULT_AGENT_MAX_CONCURRENT);
expect(resolveSubagentMaxConcurrent({})).toBe(DEFAULT_SUBAGENT_MAX_CONCURRENT);
});
it("resolves configured values", () => {
const cfg = {
agents: {
defaults: {
maxConcurrent: 6,
subagents: { maxConcurrent: 9 },
},
},
};
expect(resolveAgentMaxConcurrent(cfg)).toBe(6);
expect(resolveSubagentMaxConcurrent(cfg)).toBe(9);
});
it("clamps invalid values to at least 1", () => {
const cfg = {
agents: {
defaults: {
maxConcurrent: 0,
subagents: { maxConcurrent: -3 },
},
},
};
expect(resolveAgentMaxConcurrent(cfg)).toBe(1);
expect(resolveSubagentMaxConcurrent(cfg)).toBe(1);
});
it("injects defaults on load", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
JSON.stringify({}, null, 2),
"utf-8",
);
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.agents?.defaults?.maxConcurrent).toBe(DEFAULT_AGENT_MAX_CONCURRENT);
expect(cfg.agents?.defaults?.subagents?.maxConcurrent).toBe(DEFAULT_SUBAGENT_MAX_CONCURRENT);
});
});
});
+2 -5
View File
@@ -1,7 +1,6 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_AGENT_MAX_CONCURRENT, DEFAULT_SUBAGENT_MAX_CONCURRENT } from "./agent-limits.js";
import { withTempHome } from "./test-helpers.js"; import { withTempHome } from "./test-helpers.js";
describe("config identity defaults", () => { describe("config identity defaults", () => {
@@ -285,7 +284,7 @@ describe("config identity defaults", () => {
}); });
}); });
it("does not synthesize agent list/session when absent", async () => { it("does not synthesize agent/session when absent", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot"); const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true }); await fs.mkdir(configDir, { recursive: true });
@@ -307,9 +306,7 @@ describe("config identity defaults", () => {
expect(cfg.messages?.responsePrefix).toBeUndefined(); expect(cfg.messages?.responsePrefix).toBeUndefined();
expect(cfg.messages?.groupChat?.mentionPatterns).toBeUndefined(); expect(cfg.messages?.groupChat?.mentionPatterns).toBeUndefined();
expect(cfg.agents?.list).toBeUndefined(); expect(cfg.agents).toBeUndefined();
expect(cfg.agents?.defaults?.maxConcurrent).toBe(DEFAULT_AGENT_MAX_CONCURRENT);
expect(cfg.agents?.defaults?.subagents?.maxConcurrent).toBe(DEFAULT_SUBAGENT_MAX_CONCURRENT);
expect(cfg.session).toBeUndefined(); expect(cfg.session).toBeUndefined();
}); });
}); });
-38
View File
@@ -1,6 +1,5 @@
import { resolveTalkApiKey } from "./talk.js"; import { resolveTalkApiKey } from "./talk.js";
import type { ClawdbotConfig } from "./types.js"; import type { ClawdbotConfig } from "./types.js";
import { DEFAULT_AGENT_MAX_CONCURRENT, DEFAULT_SUBAGENT_MAX_CONCURRENT } from "./agent-limits.js";
type WarnState = { warned: boolean }; type WarnState = { warned: boolean };
@@ -106,43 +105,6 @@ export function applyModelDefaults(cfg: ClawdbotConfig): ClawdbotConfig {
}; };
} }
export function applyAgentDefaults(cfg: ClawdbotConfig): ClawdbotConfig {
const agents = cfg.agents;
const defaults = agents?.defaults;
const hasMax =
typeof defaults?.maxConcurrent === "number" && Number.isFinite(defaults.maxConcurrent);
const hasSubMax =
typeof defaults?.subagents?.maxConcurrent === "number" &&
Number.isFinite(defaults.subagents.maxConcurrent);
if (hasMax && hasSubMax) return cfg;
let mutated = false;
const nextDefaults = defaults ? { ...defaults } : {};
if (!hasMax) {
nextDefaults.maxConcurrent = DEFAULT_AGENT_MAX_CONCURRENT;
mutated = true;
}
const nextSubagents = defaults?.subagents ? { ...defaults.subagents } : {};
if (!hasSubMax) {
nextSubagents.maxConcurrent = DEFAULT_SUBAGENT_MAX_CONCURRENT;
mutated = true;
}
if (!mutated) return cfg;
return {
...cfg,
agents: {
...agents,
defaults: {
...nextDefaults,
subagents: nextSubagents,
},
},
};
}
export function applyLoggingDefaults(cfg: ClawdbotConfig): ClawdbotConfig { export function applyLoggingDefaults(cfg: ClawdbotConfig): ClawdbotConfig {
const logging = cfg.logging; const logging = cfg.logging;
if (!logging) return cfg; if (!logging) return cfg;
+4 -11
View File
@@ -15,7 +15,6 @@ import { DuplicateAgentDirError, findDuplicateAgentDirs } from "./agent-dirs.js"
import { import {
applyCompactionDefaults, applyCompactionDefaults,
applyContextPruningDefaults, applyContextPruningDefaults,
applyAgentDefaults,
applyLoggingDefaults, applyLoggingDefaults,
applyMessageDefaults, applyMessageDefaults,
applyModelDefaults, applyModelDefaults,
@@ -245,10 +244,8 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
const cfg = applyModelDefaults( const cfg = applyModelDefaults(
applyCompactionDefaults( applyCompactionDefaults(
applyContextPruningDefaults( applyContextPruningDefaults(
applyAgentDefaults( applySessionDefaults(
applySessionDefaults( applyLoggingDefaults(applyMessageDefaults(validated.data as ClawdbotConfig)),
applyLoggingDefaults(applyMessageDefaults(validated.data as ClawdbotConfig)),
),
), ),
), ),
), ),
@@ -294,9 +291,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
const config = applyTalkApiKey( const config = applyTalkApiKey(
applyModelDefaults( applyModelDefaults(
applyCompactionDefaults( applyCompactionDefaults(
applyContextPruningDefaults( applyContextPruningDefaults(applySessionDefaults(applyMessageDefaults({}))),
applyAgentDefaults(applySessionDefaults(applyMessageDefaults({}))),
),
), ),
), ),
); );
@@ -407,9 +402,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
config: normalizeConfigPaths( config: normalizeConfigPaths(
applyTalkApiKey( applyTalkApiKey(
applyModelDefaults( applyModelDefaults(
applyAgentDefaults( applySessionDefaults(applyLoggingDefaults(applyMessageDefaults(validated.config))),
applySessionDefaults(applyLoggingDefaults(applyMessageDefaults(validated.config))),
),
), ),
), ),
), ),
-6
View File
@@ -169,8 +169,6 @@ const FIELD_LABELS: Record<string, string> = {
"gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint", "gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint",
"gateway.reload.mode": "Config Reload Mode", "gateway.reload.mode": "Config Reload Mode",
"gateway.reload.debounceMs": "Config Reload Debounce (ms)", "gateway.reload.debounceMs": "Config Reload Debounce (ms)",
"gateway.nodes.allowCommands": "Gateway Node Allowlist (Extra Commands)",
"gateway.nodes.denyCommands": "Gateway Node Denylist",
"skills.load.watch": "Watch Skills", "skills.load.watch": "Watch Skills",
"skills.load.watchDebounceMs": "Skills Watch Debounce (ms)", "skills.load.watchDebounceMs": "Skills Watch Debounce (ms)",
"agents.defaults.workspace": "Workspace", "agents.defaults.workspace": "Workspace",
@@ -320,10 +318,6 @@ const FIELD_HELP: Record<string, string> = {
"Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).", "Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).",
"gateway.reload.mode": 'Hot reload strategy for config changes ("hybrid" recommended).', "gateway.reload.mode": 'Hot reload strategy for config changes ("hybrid" recommended).',
"gateway.reload.debounceMs": "Debounce window (ms) before applying config changes.", "gateway.reload.debounceMs": "Debounce window (ms) before applying config changes.",
"gateway.nodes.allowCommands":
"Extra node.invoke commands to allow beyond the gateway defaults (array of command strings).",
"gateway.nodes.denyCommands":
"Commands to block even if present in node claims or default allowlist.",
"tools.exec.applyPatch.enabled": "tools.exec.applyPatch.enabled":
"Experimental. Enables apply_patch for OpenAI models when allowed by tool policy.", "Experimental. Enables apply_patch for OpenAI models when allowed by tool policy.",
"tools.exec.applyPatch.allowModels": "tools.exec.applyPatch.allowModels":
-8
View File
@@ -170,13 +170,6 @@ export type GatewayHttpConfig = {
endpoints?: GatewayHttpEndpointsConfig; endpoints?: GatewayHttpEndpointsConfig;
}; };
export type GatewayNodesConfig = {
/** Additional node.invoke commands to allow on the gateway. */
allowCommands?: string[];
/** Commands to deny even if they appear in the defaults or node claims. */
denyCommands?: string[];
};
export type GatewayConfig = { export type GatewayConfig = {
/** Single multiplexed port for Gateway WS + HTTP (default: 18789). */ /** Single multiplexed port for Gateway WS + HTTP (default: 18789). */
port?: number; port?: number;
@@ -203,5 +196,4 @@ export type GatewayConfig = {
reload?: GatewayReloadConfig; reload?: GatewayReloadConfig;
tls?: GatewayTlsConfig; tls?: GatewayTlsConfig;
http?: GatewayHttpConfig; http?: GatewayHttpConfig;
nodes?: GatewayNodesConfig;
}; };
+2 -4
View File
@@ -1,5 +1,5 @@
import { findDuplicateAgentDirs, formatDuplicateAgentDirError } from "./agent-dirs.js"; import { findDuplicateAgentDirs, formatDuplicateAgentDirError } from "./agent-dirs.js";
import { applyAgentDefaults, applyModelDefaults, applySessionDefaults } from "./defaults.js"; import { applyModelDefaults, applySessionDefaults } from "./defaults.js";
import { findLegacyConfigIssues } from "./legacy.js"; import { findLegacyConfigIssues } from "./legacy.js";
import type { ClawdbotConfig, ConfigValidationIssue } from "./types.js"; import type { ClawdbotConfig, ConfigValidationIssue } from "./types.js";
import { ClawdbotSchema } from "./zod-schema.js"; import { ClawdbotSchema } from "./zod-schema.js";
@@ -41,8 +41,6 @@ export function validateConfigObject(
} }
return { return {
ok: true, ok: true,
config: applyModelDefaults( config: applyModelDefaults(applySessionDefaults(validated.data as ClawdbotConfig)),
applyAgentDefaults(applySessionDefaults(validated.data as ClawdbotConfig)),
),
}; };
} }
-7
View File
@@ -341,13 +341,6 @@ export const ClawdbotSchema = z
}) })
.strict() .strict()
.optional(), .optional(),
nodes: z
.object({
allowCommands: z.array(z.string()).optional(),
denyCommands: z.array(z.string()).optional(),
})
.strict()
.optional(),
}) })
.strict() .strict()
.optional(), .optional(),
@@ -1,89 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CronService } from "./service.js";
const noopLogger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
async function makeStorePath() {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-cron-"));
return {
storePath: path.join(dir, "cron", "jobs.json"),
cleanup: async () => {
await fs.rm(dir, { recursive: true, force: true });
},
};
}
describe("CronService", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-12-13T00:00:00.000Z"));
noopLogger.debug.mockClear();
noopLogger.info.mockClear();
noopLogger.warn.mockClear();
noopLogger.error.mockClear();
});
afterEach(() => {
vi.useRealTimers();
});
it("avoids duplicate runs when two services share a store", async () => {
const store = await makeStorePath();
const enqueueSystemEvent = vi.fn();
const requestHeartbeatNow = vi.fn();
const runIsolatedAgentJob = vi.fn(async () => ({ status: "ok" }));
const cronA = new CronService({
storePath: store.storePath,
cronEnabled: true,
log: noopLogger,
enqueueSystemEvent,
requestHeartbeatNow,
runIsolatedAgentJob,
});
await cronA.start();
const atMs = Date.parse("2025-12-13T00:00:01.000Z");
await cronA.add({
name: "shared store job",
enabled: true,
schedule: { kind: "at", atMs },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "hello" },
});
const cronB = new CronService({
storePath: store.storePath,
cronEnabled: true,
log: noopLogger,
enqueueSystemEvent,
requestHeartbeatNow,
runIsolatedAgentJob,
});
await cronB.start();
vi.setSystemTime(new Date("2025-12-13T00:00:01.000Z"));
await vi.runOnlyPendingTimersAsync();
await cronA.status();
await cronB.status();
expect(enqueueSystemEvent).toHaveBeenCalledTimes(1);
expect(requestHeartbeatNow).toHaveBeenCalledTimes(1);
cronA.stop();
cronB.stop();
await store.cleanup();
});
});
+4 -15
View File
@@ -1,22 +1,11 @@
import type { CronServiceState } from "./state.js"; import type { CronServiceState } from "./state.js";
const storeLocks = new Map<string, Promise<void>>(); export async function locked<T>(state: CronServiceState, fn: () => Promise<T>): Promise<T> {
const next = state.op.then(fn, fn);
const resolveChain = (promise: Promise<unknown>) => // Keep the chain alive even when the operation fails.
promise.then( state.op = next.then(
() => undefined, () => undefined,
() => undefined, () => undefined,
); );
export async function locked<T>(state: CronServiceState, fn: () => Promise<T>): Promise<T> {
const storePath = state.deps.storePath;
const storeOp = storeLocks.get(storePath) ?? Promise.resolve();
const next = Promise.all([resolveChain(state.op), resolveChain(storeOp)]).then(fn);
// Keep the chain alive even when the operation fails.
const keepAlive = resolveChain(next);
state.op = keepAlive;
storeLocks.set(storePath, keepAlive);
return (await next) as T; return (await next) as T;
} }
-8
View File
@@ -4,15 +4,8 @@ import type { CronJob } from "../types.js";
import { inferLegacyName, normalizeOptionalText } from "./normalize.js"; import { inferLegacyName, normalizeOptionalText } from "./normalize.js";
import type { CronServiceState } from "./state.js"; import type { CronServiceState } from "./state.js";
const storeCache = new Map<string, { version: 1; jobs: CronJob[] }>();
export async function ensureLoaded(state: CronServiceState) { export async function ensureLoaded(state: CronServiceState) {
if (state.store) return; if (state.store) return;
const cached = storeCache.get(state.deps.storePath);
if (cached) {
state.store = cached;
return;
}
const loaded = await loadCronStore(state.deps.storePath); const loaded = await loadCronStore(state.deps.storePath);
const jobs = (loaded.jobs ?? []) as unknown as Array<Record<string, unknown>>; const jobs = (loaded.jobs ?? []) as unknown as Array<Record<string, unknown>>;
let mutated = false; let mutated = false;
@@ -42,7 +35,6 @@ export async function ensureLoaded(state: CronServiceState) {
} }
} }
state.store = { version: 1, jobs: jobs as unknown as CronJob[] }; state.store = { version: 1, jobs: jobs as unknown as CronJob[] };
storeCache.set(state.deps.storePath, state.store);
if (mutated) await persist(state); if (mutated) await persist(state);
} }
+1 -1
View File
@@ -12,7 +12,7 @@ export type ResolvedGatewayAuth = {
export type GatewayAuthResult = { export type GatewayAuthResult = {
ok: boolean; ok: boolean;
method?: "none" | "token" | "password" | "tailscale" | "device-token"; method?: "none" | "token" | "password" | "tailscale";
user?: string; user?: string;
reason?: string; reason?: string;
}; };
+6 -27
View File
@@ -3,12 +3,7 @@ import { WebSocket, type ClientOptions, type CertMeta } from "ws";
import { rawDataToString } from "../infra/ws.js"; import { rawDataToString } from "../infra/ws.js";
import { logDebug, logError } from "../logger.js"; import { logDebug, logError } from "../logger.js";
import type { DeviceIdentity } from "../infra/device-identity.js"; import type { DeviceIdentity } from "../infra/device-identity.js";
import { import { publicKeyRawBase64UrlFromPem, signDevicePayload } from "../infra/device-identity.js";
loadOrCreateDeviceIdentity,
publicKeyRawBase64UrlFromPem,
signDevicePayload,
} from "../infra/device-identity.js";
import { loadDeviceAuthToken, storeDeviceAuthToken } from "../infra/device-auth-store.js";
import { import {
GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES, GATEWAY_CLIENT_NAMES,
@@ -83,10 +78,7 @@ export class GatewayClient {
private tickTimer: NodeJS.Timeout | null = null; private tickTimer: NodeJS.Timeout | null = null;
constructor(opts: GatewayClientOptions) { constructor(opts: GatewayClientOptions) {
this.opts = { this.opts = opts;
...opts,
deviceIdentity: opts.deviceIdentity ?? loadOrCreateDeviceIdentity(),
};
} }
start() { start() {
@@ -147,19 +139,15 @@ export class GatewayClient {
} }
private sendConnect() { private sendConnect() {
const role = this.opts.role ?? "operator";
const storedToken = this.opts.deviceIdentity
? loadDeviceAuthToken({ deviceId: this.opts.deviceIdentity.deviceId, role })?.token
: null;
const authToken = this.opts.token ?? storedToken ?? undefined;
const auth = const auth =
authToken || this.opts.password this.opts.token || this.opts.password
? { ? {
token: authToken, token: this.opts.token,
password: this.opts.password, password: this.opts.password,
} }
: undefined; : undefined;
const signedAtMs = Date.now(); const signedAtMs = Date.now();
const role = this.opts.role ?? "operator";
const scopes = this.opts.scopes ?? ["operator.admin"]; const scopes = this.opts.scopes ?? ["operator.admin"];
const device = (() => { const device = (() => {
if (!this.opts.deviceIdentity) return undefined; if (!this.opts.deviceIdentity) return undefined;
@@ -170,7 +158,7 @@ export class GatewayClient {
role, role,
scopes, scopes,
signedAtMs, signedAtMs,
token: authToken ?? null, token: this.opts.token ?? null,
}); });
const signature = signDevicePayload(this.opts.deviceIdentity.privateKeyPem, payload); const signature = signDevicePayload(this.opts.deviceIdentity.privateKeyPem, payload);
return { return {
@@ -205,15 +193,6 @@ export class GatewayClient {
void this.request<HelloOk>("connect", params) void this.request<HelloOk>("connect", params)
.then((helloOk) => { .then((helloOk) => {
const authInfo = helloOk?.auth;
if (authInfo?.deviceToken && this.opts.deviceIdentity) {
storeDeviceAuthToken({
deviceId: this.opts.deviceIdentity.deviceId,
role: authInfo.role ?? role,
token: authInfo.deviceToken,
scopes: authInfo.scopes ?? [],
});
}
this.backoffMs = 1000; this.backoffMs = 1000;
this.tickIntervalMs = this.tickIntervalMs =
typeof helloOk.policy?.tickIntervalMs === "number" typeof helloOk.policy?.tickIntervalMs === "number"
-24
View File
@@ -6,15 +6,9 @@ import path from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { WebSocket } from "ws"; import { WebSocket } from "ws";
import {
loadOrCreateDeviceIdentity,
publicKeyRawBase64UrlFromPem,
signDevicePayload,
} from "../infra/device-identity.js";
import { rawDataToString } from "../infra/ws.js"; import { rawDataToString } from "../infra/ws.js";
import { getDeterministicFreePortBlock } from "../test-utils/ports.js"; import { getDeterministicFreePortBlock } from "../test-utils/ports.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { buildDeviceAuthPayload } from "./device-auth.js";
import { PROTOCOL_VERSION } from "./protocol/index.js"; import { PROTOCOL_VERSION } from "./protocol/index.js";
async function getFreeGatewayPort(): Promise<number> { async function getFreeGatewayPort(): Promise<number> {
@@ -49,23 +43,6 @@ async function onceMessage<T = unknown>(
async function connectReq(params: { url: string; token?: string }) { async function connectReq(params: { url: string; token?: string }) {
const ws = new WebSocket(params.url); const ws = new WebSocket(params.url);
await new Promise<void>((resolve) => ws.once("open", resolve)); await new Promise<void>((resolve) => ws.once("open", resolve));
const identity = loadOrCreateDeviceIdentity();
const signedAtMs = Date.now();
const payload = buildDeviceAuthPayload({
deviceId: identity.deviceId,
clientId: GATEWAY_CLIENT_NAMES.TEST,
clientMode: GATEWAY_CLIENT_MODES.TEST,
role: "operator",
scopes: [],
signedAtMs,
token: params.token ?? null,
});
const device = {
id: identity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
signature: signDevicePayload(identity.privateKeyPem, payload),
signedAt: signedAtMs,
};
ws.send( ws.send(
JSON.stringify({ JSON.stringify({
type: "req", type: "req",
@@ -83,7 +60,6 @@ async function connectReq(params: { url: string; token?: string }) {
}, },
caps: [], caps: [],
auth: params.token ? { token: params.token } : undefined, auth: params.token ? { token: params.token } : undefined,
device,
}, },
}), }),
); );
-110
View File
@@ -1,110 +0,0 @@
import type { ClawdbotConfig } from "../config/config.js";
import type { NodeSession } from "./node-registry.js";
const CANVAS_COMMANDS = [
"canvas.present",
"canvas.hide",
"canvas.navigate",
"canvas.eval",
"canvas.snapshot",
"canvas.a2ui.push",
"canvas.a2ui.pushJSONL",
"canvas.a2ui.reset",
];
const CAMERA_COMMANDS = ["camera.list", "camera.snap", "camera.clip"];
const SCREEN_COMMANDS = ["screen.record"];
const LOCATION_COMMANDS = ["location.get"];
const SMS_COMMANDS = ["sms.send"];
const SYSTEM_COMMANDS = [
"system.run",
"system.which",
"system.notify",
"system.execApprovals.get",
"system.execApprovals.set",
];
const PLATFORM_DEFAULTS: Record<string, string[]> = {
ios: [...CANVAS_COMMANDS, ...CAMERA_COMMANDS, ...SCREEN_COMMANDS, ...LOCATION_COMMANDS],
android: [
...CANVAS_COMMANDS,
...CAMERA_COMMANDS,
...SCREEN_COMMANDS,
...LOCATION_COMMANDS,
...SMS_COMMANDS,
],
macos: [
...CANVAS_COMMANDS,
...CAMERA_COMMANDS,
...SCREEN_COMMANDS,
...LOCATION_COMMANDS,
...SYSTEM_COMMANDS,
],
linux: [...SYSTEM_COMMANDS],
windows: [...SYSTEM_COMMANDS],
unknown: [
...CANVAS_COMMANDS,
...CAMERA_COMMANDS,
...SCREEN_COMMANDS,
...LOCATION_COMMANDS,
...SMS_COMMANDS,
...SYSTEM_COMMANDS,
],
};
function normalizePlatformId(platform?: string, deviceFamily?: string): string {
const raw = (platform ?? "").trim().toLowerCase();
if (raw.startsWith("ios")) return "ios";
if (raw.startsWith("android")) return "android";
if (raw.startsWith("mac")) return "macos";
if (raw.startsWith("darwin")) return "macos";
if (raw.startsWith("win")) return "windows";
if (raw.startsWith("linux")) return "linux";
const family = (deviceFamily ?? "").trim().toLowerCase();
if (family.includes("iphone") || family.includes("ipad") || family.includes("ios")) return "ios";
if (family.includes("android")) return "android";
if (family.includes("mac")) return "macos";
if (family.includes("windows")) return "windows";
if (family.includes("linux")) return "linux";
return "unknown";
}
export function resolveNodeCommandAllowlist(
cfg: ClawdbotConfig,
node?: Pick<NodeSession, "platform" | "deviceFamily">,
): Set<string> {
const platformId = normalizePlatformId(node?.platform, node?.deviceFamily);
const base = PLATFORM_DEFAULTS[platformId] ?? PLATFORM_DEFAULTS.unknown;
const extra = cfg.gateway?.nodes?.allowCommands ?? [];
const deny = new Set(cfg.gateway?.nodes?.denyCommands ?? []);
const allow = new Set([...base, ...extra].map((cmd) => cmd.trim()).filter(Boolean));
for (const blocked of deny) {
const trimmed = blocked.trim();
if (trimmed) allow.delete(trimmed);
}
return allow;
}
export function isNodeCommandAllowed(params: {
command: string;
declaredCommands?: string[];
allowlist: Set<string>;
}): { ok: true } | { ok: false; reason: string } {
const command = params.command.trim();
if (!command) return { ok: false, reason: "command required" };
if (!params.allowlist.has(command)) {
return { ok: false, reason: "command not allowlisted" };
}
if (Array.isArray(params.declaredCommands) && params.declaredCommands.length > 0) {
if (!params.declaredCommands.includes(command)) {
return { ok: false, reason: "command not declared by node" };
}
} else {
return { ok: false, reason: "node did not declare commands" };
}
return { ok: true };
}
-1
View File
@@ -155,7 +155,6 @@ export class NodeRegistry {
}): boolean { }): boolean {
const pending = this.pendingInvokes.get(params.id); const pending = this.pendingInvokes.get(params.id);
if (!pending) return false; if (!pending) return false;
if (pending.nodeId !== params.nodeId) return false;
clearTimeout(pending.timer); clearTimeout(pending.timer);
this.pendingInvokes.delete(params.id); this.pendingInvokes.delete(params.id);
pending.resolve({ pending.resolve({
-33
View File
@@ -462,39 +462,6 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
} }
}); });
it("preserves repeated identical deltas when streaming SSE", async () => {
agentCommand.mockImplementationOnce(async (opts: unknown) => {
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
emitAgentEvent({ runId, stream: "assistant", data: { delta: "hi" } });
emitAgentEvent({ runId, stream: "assistant", data: { delta: "hi" } });
return { payloads: [{ text: "hihi" }] } as never;
});
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
stream: true,
model: "clawdbot",
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(200);
const text = await res.text();
const data = parseSseDataLines(text);
const jsonChunks = data
.filter((d) => d !== "[DONE]")
.map((d) => JSON.parse(d) as Record<string, unknown>);
const allContent = jsonChunks
.flatMap((c) => (c.choices as Array<Record<string, unknown>> | undefined) ?? [])
.map((choice) => (choice.delta as Record<string, unknown> | undefined)?.content)
.filter((v): v is string => typeof v === "string")
.join("");
expect(allContent).toBe("hihi");
} finally {
await server.close({ reason: "test done" });
}
});
it("streams SSE chunks when stream=true (fallback when no deltas)", async () => { it("streams SSE chunks when stream=true (fallback when no deltas)", async () => {
agentCommand.mockResolvedValueOnce({ agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }], payloads: [{ text: "hello" }],
-16
View File
@@ -62,10 +62,6 @@ import {
DevicePairListParamsSchema, DevicePairListParamsSchema,
type DevicePairRejectParams, type DevicePairRejectParams,
DevicePairRejectParamsSchema, DevicePairRejectParamsSchema,
type DeviceTokenRevokeParams,
DeviceTokenRevokeParamsSchema,
type DeviceTokenRotateParams,
DeviceTokenRotateParamsSchema,
type ExecApprovalsGetParams, type ExecApprovalsGetParams,
ExecApprovalsGetParamsSchema, ExecApprovalsGetParamsSchema,
type ExecApprovalsNodeGetParams, type ExecApprovalsNodeGetParams,
@@ -142,9 +138,6 @@ import {
SessionsResolveParamsSchema, SessionsResolveParamsSchema,
type ShutdownEvent, type ShutdownEvent,
ShutdownEventSchema, ShutdownEventSchema,
type SkillsBinsParams,
SkillsBinsParamsSchema,
type SkillsBinsResult,
type SkillsInstallParams, type SkillsInstallParams,
SkillsInstallParamsSchema, SkillsInstallParamsSchema,
type SkillsStatusParams, type SkillsStatusParams,
@@ -254,7 +247,6 @@ export const validateChannelsLogoutParams = ajv.compile<ChannelsLogoutParams>(
); );
export const validateModelsListParams = ajv.compile<ModelsListParams>(ModelsListParamsSchema); export const validateModelsListParams = ajv.compile<ModelsListParams>(ModelsListParamsSchema);
export const validateSkillsStatusParams = ajv.compile<SkillsStatusParams>(SkillsStatusParamsSchema); export const validateSkillsStatusParams = ajv.compile<SkillsStatusParams>(SkillsStatusParamsSchema);
export const validateSkillsBinsParams = ajv.compile<SkillsBinsParams>(SkillsBinsParamsSchema);
export const validateSkillsInstallParams = export const validateSkillsInstallParams =
ajv.compile<SkillsInstallParams>(SkillsInstallParamsSchema); ajv.compile<SkillsInstallParams>(SkillsInstallParamsSchema);
export const validateSkillsUpdateParams = ajv.compile<SkillsUpdateParams>(SkillsUpdateParamsSchema); export const validateSkillsUpdateParams = ajv.compile<SkillsUpdateParams>(SkillsUpdateParamsSchema);
@@ -274,12 +266,6 @@ export const validateDevicePairApproveParams = ajv.compile<DevicePairApprovePara
export const validateDevicePairRejectParams = ajv.compile<DevicePairRejectParams>( export const validateDevicePairRejectParams = ajv.compile<DevicePairRejectParams>(
DevicePairRejectParamsSchema, DevicePairRejectParamsSchema,
); );
export const validateDeviceTokenRotateParams = ajv.compile<DeviceTokenRotateParams>(
DeviceTokenRotateParamsSchema,
);
export const validateDeviceTokenRevokeParams = ajv.compile<DeviceTokenRevokeParams>(
DeviceTokenRevokeParamsSchema,
);
export const validateExecApprovalsGetParams = ajv.compile<ExecApprovalsGetParams>( export const validateExecApprovalsGetParams = ajv.compile<ExecApprovalsGetParams>(
ExecApprovalsGetParamsSchema, ExecApprovalsGetParamsSchema,
); );
@@ -438,8 +424,6 @@ export type {
AgentsListParams, AgentsListParams,
AgentsListResult, AgentsListResult,
SkillsStatusParams, SkillsStatusParams,
SkillsBinsParams,
SkillsBinsResult,
SkillsInstallParams, SkillsInstallParams,
SkillsUpdateParams, SkillsUpdateParams,
NodePairRejectParams, NodePairRejectParams,
@@ -44,15 +44,6 @@ export const ModelsListResultSchema = Type.Object(
export const SkillsStatusParamsSchema = Type.Object({}, { additionalProperties: false }); export const SkillsStatusParamsSchema = Type.Object({}, { additionalProperties: false });
export const SkillsBinsParamsSchema = Type.Object({}, { additionalProperties: false });
export const SkillsBinsResultSchema = Type.Object(
{
bins: Type.Array(NonEmptyString),
},
{ additionalProperties: false },
);
export const SkillsInstallParamsSchema = Type.Object( export const SkillsInstallParamsSchema = Type.Object(
{ {
name: NonEmptyString, name: NonEmptyString,
-17
View File
@@ -14,23 +14,6 @@ export const DevicePairRejectParamsSchema = Type.Object(
{ additionalProperties: false }, { additionalProperties: false },
); );
export const DeviceTokenRotateParamsSchema = Type.Object(
{
deviceId: NonEmptyString,
role: NonEmptyString,
scopes: Type.Optional(Type.Array(NonEmptyString)),
},
{ additionalProperties: false },
);
export const DeviceTokenRevokeParamsSchema = Type.Object(
{
deviceId: NonEmptyString,
role: NonEmptyString,
},
{ additionalProperties: false },
);
export const DevicePairRequestedEventSchema = Type.Object( export const DevicePairRequestedEventSchema = Type.Object(
{ {
requestId: NonEmptyString, requestId: NonEmptyString,
-11
View File
@@ -87,17 +87,6 @@ export const HelloOkSchema = Type.Object(
), ),
snapshot: SnapshotSchema, snapshot: SnapshotSchema,
canvasHostUrl: Type.Optional(NonEmptyString), canvasHostUrl: Type.Optional(NonEmptyString),
auth: Type.Optional(
Type.Object(
{
deviceToken: NonEmptyString,
role: NonEmptyString,
scopes: Type.Array(NonEmptyString),
issuedAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
},
{ additionalProperties: false },
),
),
policy: Type.Object( policy: Type.Object(
{ {
maxPayload: Type.Integer({ minimum: 1 }), maxPayload: Type.Integer({ minimum: 1 }),
@@ -15,8 +15,6 @@ import {
ModelChoiceSchema, ModelChoiceSchema,
ModelsListParamsSchema, ModelsListParamsSchema,
ModelsListResultSchema, ModelsListResultSchema,
SkillsBinsParamsSchema,
SkillsBinsResultSchema,
SkillsInstallParamsSchema, SkillsInstallParamsSchema,
SkillsStatusParamsSchema, SkillsStatusParamsSchema,
SkillsUpdateParamsSchema, SkillsUpdateParamsSchema,
@@ -64,8 +62,6 @@ import {
DevicePairRejectParamsSchema, DevicePairRejectParamsSchema,
DevicePairRequestedEventSchema, DevicePairRequestedEventSchema,
DevicePairResolvedEventSchema, DevicePairResolvedEventSchema,
DeviceTokenRevokeParamsSchema,
DeviceTokenRotateParamsSchema,
} from "./devices.js"; } from "./devices.js";
import { import {
ConnectParamsSchema, ConnectParamsSchema,
@@ -183,8 +179,6 @@ export const ProtocolSchemas: Record<string, TSchema> = {
ModelsListParams: ModelsListParamsSchema, ModelsListParams: ModelsListParamsSchema,
ModelsListResult: ModelsListResultSchema, ModelsListResult: ModelsListResultSchema,
SkillsStatusParams: SkillsStatusParamsSchema, SkillsStatusParams: SkillsStatusParamsSchema,
SkillsBinsParams: SkillsBinsParamsSchema,
SkillsBinsResult: SkillsBinsResultSchema,
SkillsInstallParams: SkillsInstallParamsSchema, SkillsInstallParams: SkillsInstallParamsSchema,
SkillsUpdateParams: SkillsUpdateParamsSchema, SkillsUpdateParams: SkillsUpdateParamsSchema,
CronJob: CronJobSchema, CronJob: CronJobSchema,
@@ -208,8 +202,6 @@ export const ProtocolSchemas: Record<string, TSchema> = {
DevicePairListParams: DevicePairListParamsSchema, DevicePairListParams: DevicePairListParamsSchema,
DevicePairApproveParams: DevicePairApproveParamsSchema, DevicePairApproveParams: DevicePairApproveParamsSchema,
DevicePairRejectParams: DevicePairRejectParamsSchema, DevicePairRejectParams: DevicePairRejectParamsSchema,
DeviceTokenRotateParams: DeviceTokenRotateParamsSchema,
DeviceTokenRevokeParams: DeviceTokenRevokeParamsSchema,
DevicePairRequestedEvent: DevicePairRequestedEventSchema, DevicePairRequestedEvent: DevicePairRequestedEventSchema,
DevicePairResolvedEvent: DevicePairResolvedEventSchema, DevicePairResolvedEvent: DevicePairResolvedEventSchema,
ChatHistoryParams: ChatHistoryParamsSchema, ChatHistoryParams: ChatHistoryParamsSchema,
-8
View File
@@ -13,8 +13,6 @@ import type {
ModelChoiceSchema, ModelChoiceSchema,
ModelsListParamsSchema, ModelsListParamsSchema,
ModelsListResultSchema, ModelsListResultSchema,
SkillsBinsParamsSchema,
SkillsBinsResultSchema,
SkillsInstallParamsSchema, SkillsInstallParamsSchema,
SkillsStatusParamsSchema, SkillsStatusParamsSchema,
SkillsUpdateParamsSchema, SkillsUpdateParamsSchema,
@@ -60,8 +58,6 @@ import type {
DevicePairApproveParamsSchema, DevicePairApproveParamsSchema,
DevicePairListParamsSchema, DevicePairListParamsSchema,
DevicePairRejectParamsSchema, DevicePairRejectParamsSchema,
DeviceTokenRevokeParamsSchema,
DeviceTokenRotateParamsSchema,
} from "./devices.js"; } from "./devices.js";
import type { import type {
ConnectParamsSchema, ConnectParamsSchema,
@@ -172,8 +168,6 @@ export type ModelChoice = Static<typeof ModelChoiceSchema>;
export type ModelsListParams = Static<typeof ModelsListParamsSchema>; export type ModelsListParams = Static<typeof ModelsListParamsSchema>;
export type ModelsListResult = Static<typeof ModelsListResultSchema>; export type ModelsListResult = Static<typeof ModelsListResultSchema>;
export type SkillsStatusParams = Static<typeof SkillsStatusParamsSchema>; export type SkillsStatusParams = Static<typeof SkillsStatusParamsSchema>;
export type SkillsBinsParams = Static<typeof SkillsBinsParamsSchema>;
export type SkillsBinsResult = Static<typeof SkillsBinsResultSchema>;
export type SkillsInstallParams = Static<typeof SkillsInstallParamsSchema>; export type SkillsInstallParams = Static<typeof SkillsInstallParamsSchema>;
export type SkillsUpdateParams = Static<typeof SkillsUpdateParamsSchema>; export type SkillsUpdateParams = Static<typeof SkillsUpdateParamsSchema>;
export type CronJob = Static<typeof CronJobSchema>; export type CronJob = Static<typeof CronJobSchema>;
@@ -197,8 +191,6 @@ export type ExecApprovalResolveParams = Static<typeof ExecApprovalResolveParamsS
export type DevicePairListParams = Static<typeof DevicePairListParamsSchema>; export type DevicePairListParams = Static<typeof DevicePairListParamsSchema>;
export type DevicePairApproveParams = Static<typeof DevicePairApproveParamsSchema>; export type DevicePairApproveParams = Static<typeof DevicePairApproveParamsSchema>;
export type DevicePairRejectParams = Static<typeof DevicePairRejectParamsSchema>; export type DevicePairRejectParams = Static<typeof DevicePairRejectParamsSchema>;
export type DeviceTokenRotateParams = Static<typeof DeviceTokenRotateParamsSchema>;
export type DeviceTokenRevokeParams = Static<typeof DeviceTokenRevokeParamsSchema>;
export type ChatAbortParams = Static<typeof ChatAbortParamsSchema>; export type ChatAbortParams = Static<typeof ChatAbortParamsSchema>;
export type ChatInjectParams = Static<typeof ChatInjectParamsSchema>; export type ChatInjectParams = Static<typeof ChatInjectParamsSchema>;
export type ChatEvent = Static<typeof ChatEventSchema>; export type ChatEvent = Static<typeof ChatEventSchema>;
+3 -5
View File
@@ -1,10 +1,8 @@
import type { loadConfig } from "../config/config.js"; import type { loadConfig } from "../config/config.js";
import { resolveAgentMaxConcurrent, resolveSubagentMaxConcurrent } from "../config/agent-limits.js";
import { setCommandLaneConcurrency } from "../process/command-queue.js"; import { setCommandLaneConcurrency } from "../process/command-queue.js";
import { CommandLane } from "../process/lanes.js";
export function applyGatewayLaneConcurrency(cfg: ReturnType<typeof loadConfig>) { export function applyGatewayLaneConcurrency(cfg: ReturnType<typeof loadConfig>) {
setCommandLaneConcurrency(CommandLane.Cron, cfg.cron?.maxConcurrentRuns ?? 1); setCommandLaneConcurrency("cron", cfg.cron?.maxConcurrentRuns ?? 1);
setCommandLaneConcurrency(CommandLane.Main, resolveAgentMaxConcurrent(cfg)); setCommandLaneConcurrency("main", cfg.agents?.defaults?.maxConcurrent ?? 1);
setCommandLaneConcurrency(CommandLane.Subagent, resolveSubagentMaxConcurrent(cfg)); setCommandLaneConcurrency("subagent", cfg.agents?.defaults?.subagents?.maxConcurrent ?? 1);
} }
-3
View File
@@ -27,7 +27,6 @@ const BASE_METHODS = [
"models.list", "models.list",
"agents.list", "agents.list",
"skills.status", "skills.status",
"skills.bins",
"skills.install", "skills.install",
"skills.update", "skills.update",
"update.run", "update.run",
@@ -49,8 +48,6 @@ const BASE_METHODS = [
"device.pair.list", "device.pair.list",
"device.pair.approve", "device.pair.approve",
"device.pair.reject", "device.pair.reject",
"device.token.rotate",
"device.token.revoke",
"node.rename", "node.rename",
"node.list", "node.list",
"node.describe", "node.describe",
+3 -71
View File
@@ -25,13 +25,11 @@ import { webHandlers } from "./server-methods/web.js";
import { wizardHandlers } from "./server-methods/wizard.js"; import { wizardHandlers } from "./server-methods/wizard.js";
const ADMIN_SCOPE = "operator.admin"; const ADMIN_SCOPE = "operator.admin";
const READ_SCOPE = "operator.read";
const WRITE_SCOPE = "operator.write";
const APPROVALS_SCOPE = "operator.approvals"; const APPROVALS_SCOPE = "operator.approvals";
const PAIRING_SCOPE = "operator.pairing"; const PAIRING_SCOPE = "operator.pairing";
const APPROVAL_METHODS = new Set(["exec.approval.request", "exec.approval.resolve"]); const APPROVAL_METHODS = new Set(["exec.approval.request", "exec.approval.resolve"]);
const NODE_ROLE_METHODS = new Set(["node.invoke.result", "node.event", "skills.bins"]); const NODE_ROLE_METHODS = new Set(["node.invoke.result", "node.event"]);
const PAIRING_METHODS = new Set([ const PAIRING_METHODS = new Set([
"node.pair.request", "node.pair.request",
"node.pair.list", "node.pair.list",
@@ -41,53 +39,15 @@ const PAIRING_METHODS = new Set([
"device.pair.list", "device.pair.list",
"device.pair.approve", "device.pair.approve",
"device.pair.reject", "device.pair.reject",
"device.token.rotate",
"device.token.revoke",
"node.rename",
]); ]);
const ADMIN_METHOD_PREFIXES = ["exec.approvals."]; const ADMIN_METHOD_PREFIXES = ["exec.approvals."];
const READ_METHODS = new Set([
"health",
"logs.tail",
"channels.status",
"status",
"usage.status",
"usage.cost",
"models.list",
"agents.list",
"skills.status",
"voicewake.get",
"sessions.list",
"cron.list",
"cron.status",
"cron.runs",
"system-presence",
"last-heartbeat",
"node.list",
"node.describe",
"chat.history",
]);
const WRITE_METHODS = new Set([
"send",
"agent",
"agent.wait",
"wake",
"talk.mode",
"voicewake.set",
"node.invoke",
"chat.send",
"chat.abort",
]);
function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["client"]) { function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["client"]) {
if (!client?.connect) return null; if (!client?.connect) return null;
const role = client.connect.role ?? "operator"; const role = client.connect.role ?? "operator";
const scopes = client.connect.scopes ?? []; const scopes = client.connect.scopes ?? [];
if (NODE_ROLE_METHODS.has(method)) {
if (role === "node") return null;
return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`);
}
if (role === "node") { if (role === "node") {
if (NODE_ROLE_METHODS.has(method)) return null;
return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`); return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`);
} }
if (role !== "operator") { if (role !== "operator") {
@@ -100,38 +60,10 @@ function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["c
if (PAIRING_METHODS.has(method) && !scopes.includes(PAIRING_SCOPE)) { if (PAIRING_METHODS.has(method) && !scopes.includes(PAIRING_SCOPE)) {
return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.pairing"); return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.pairing");
} }
if (READ_METHODS.has(method) && !(scopes.includes(READ_SCOPE) || scopes.includes(WRITE_SCOPE))) {
return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.read");
}
if (WRITE_METHODS.has(method) && !scopes.includes(WRITE_SCOPE)) {
return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.write");
}
if (APPROVAL_METHODS.has(method)) return null;
if (PAIRING_METHODS.has(method)) return null;
if (READ_METHODS.has(method)) return null;
if (WRITE_METHODS.has(method)) return null;
if (ADMIN_METHOD_PREFIXES.some((prefix) => method.startsWith(prefix))) { if (ADMIN_METHOD_PREFIXES.some((prefix) => method.startsWith(prefix))) {
return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.admin"); return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.admin");
} }
if ( return null;
method.startsWith("config.") ||
method.startsWith("wizard.") ||
method.startsWith("update.") ||
method === "channels.logout" ||
method === "skills.install" ||
method === "skills.update" ||
method === "cron.add" ||
method === "cron.update" ||
method === "cron.remove" ||
method === "cron.run" ||
method === "sessions.patch" ||
method === "sessions.reset" ||
method === "sessions.delete" ||
method === "sessions.compact"
) {
return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.admin");
}
return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.admin");
} }
export const coreGatewayHandlers: GatewayRequestHandlers = { export const coreGatewayHandlers: GatewayRequestHandlers = {
+3 -86
View File
@@ -1,11 +1,7 @@
import { import {
approveDevicePairing, approveDevicePairing,
listDevicePairing, listDevicePairing,
type DeviceAuthToken,
rejectDevicePairing, rejectDevicePairing,
revokeDeviceToken,
rotateDeviceToken,
summarizeDeviceTokens,
} from "../../infra/device-pairing.js"; } from "../../infra/device-pairing.js";
import { import {
ErrorCodes, ErrorCodes,
@@ -14,19 +10,9 @@ import {
validateDevicePairApproveParams, validateDevicePairApproveParams,
validateDevicePairListParams, validateDevicePairListParams,
validateDevicePairRejectParams, validateDevicePairRejectParams,
validateDeviceTokenRevokeParams,
validateDeviceTokenRotateParams,
} from "../protocol/index.js"; } from "../protocol/index.js";
import type { GatewayRequestHandlers } from "./types.js"; import type { GatewayRequestHandlers } from "./types.js";
function redactPairedDevice(device: { tokens?: Record<string, DeviceAuthToken> } & Record<string, unknown>) {
const { tokens, ...rest } = device;
return {
...rest,
tokens: summarizeDeviceTokens(tokens),
};
}
export const deviceHandlers: GatewayRequestHandlers = { export const deviceHandlers: GatewayRequestHandlers = {
"device.pair.list": async ({ params, respond }) => { "device.pair.list": async ({ params, respond }) => {
if (!validateDevicePairListParams(params)) { if (!validateDevicePairListParams(params)) {
@@ -43,14 +29,7 @@ export const deviceHandlers: GatewayRequestHandlers = {
return; return;
} }
const list = await listDevicePairing(); const list = await listDevicePairing();
respond( respond(true, list, undefined);
true,
{
pending: list.pending,
paired: list.paired.map((device) => redactPairedDevice(device)),
},
undefined,
);
}, },
"device.pair.approve": async ({ params, respond, context }) => { "device.pair.approve": async ({ params, respond, context }) => {
if (!validateDevicePairApproveParams(params)) { if (!validateDevicePairApproveParams(params)) {
@@ -79,10 +58,10 @@ export const deviceHandlers: GatewayRequestHandlers = {
deviceId: approved.device.deviceId, deviceId: approved.device.deviceId,
decision: "approved", decision: "approved",
ts: Date.now(), ts: Date.now(),
}, },
{ dropIfSlow: true }, { dropIfSlow: true },
); );
respond(true, { requestId, device: redactPairedDevice(approved.device) }, undefined); respond(true, approved, undefined);
}, },
"device.pair.reject": async ({ params, respond, context }) => { "device.pair.reject": async ({ params, respond, context }) => {
if (!validateDevicePairRejectParams(params)) { if (!validateDevicePairRejectParams(params)) {
@@ -116,66 +95,4 @@ export const deviceHandlers: GatewayRequestHandlers = {
); );
respond(true, rejected, undefined); respond(true, rejected, undefined);
}, },
"device.token.rotate": async ({ params, respond }) => {
if (!validateDeviceTokenRotateParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid device.token.rotate params: ${formatValidationErrors(
validateDeviceTokenRotateParams.errors,
)}`,
),
);
return;
}
const { deviceId, role, scopes } = params as {
deviceId: string;
role: string;
scopes?: string[];
};
const entry = await rotateDeviceToken({ deviceId, role, scopes });
if (!entry) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown deviceId/role"));
return;
}
respond(
true,
{
deviceId,
role: entry.role,
token: entry.token,
scopes: entry.scopes,
rotatedAtMs: entry.rotatedAtMs ?? entry.createdAtMs,
},
undefined,
);
},
"device.token.revoke": async ({ params, respond }) => {
if (!validateDeviceTokenRevokeParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid device.token.revoke params: ${formatValidationErrors(
validateDeviceTokenRevokeParams.errors,
)}`,
),
);
return;
}
const { deviceId, role } = params as { deviceId: string; role: string };
const entry = await revokeDeviceToken({ deviceId, role });
if (!entry) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown deviceId/role"));
return;
}
respond(
true,
{ deviceId, role: entry.role, revokedAtMs: entry.revokedAtMs ?? Date.now() },
undefined,
);
},
}; };
+3 -42
View File
@@ -28,11 +28,6 @@ import {
safeParseJson, safeParseJson,
uniqueSortedStrings, uniqueSortedStrings,
} from "./nodes.helpers.js"; } from "./nodes.helpers.js";
import { loadConfig } from "../../config/config.js";
import {
isNodeCommandAllowed,
resolveNodeCommandAllowlist,
} from "../node-command-policy.js";
import type { GatewayRequestHandlers } from "./types.js"; import type { GatewayRequestHandlers } from "./types.js";
function isNodeEntry(entry: { role?: string; roles?: string[] }) { function isNodeEntry(entry: { role?: string; roles?: string[] }) {
@@ -358,34 +353,6 @@ export const nodeHandlers: GatewayRequestHandlers = {
} }
await respondUnavailableOnThrow(respond, async () => { await respondUnavailableOnThrow(respond, async () => {
const nodeSession = context.nodeRegistry.get(nodeId);
if (!nodeSession) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, "node not connected", {
details: { code: "NOT_CONNECTED" },
}),
);
return;
}
const cfg = loadConfig();
const allowlist = resolveNodeCommandAllowlist(cfg, nodeSession);
const allowed = isNodeCommandAllowed({
command,
declaredCommands: nodeSession.commands,
allowlist,
});
if (!allowed.ok) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "node command not allowed", {
details: { reason: allowed.reason, command },
}),
);
return;
}
const res = await context.nodeRegistry.invoke({ const res = await context.nodeRegistry.invoke({
nodeId, nodeId,
command, command,
@@ -417,7 +384,7 @@ export const nodeHandlers: GatewayRequestHandlers = {
); );
}); });
}, },
"node.invoke.result": async ({ params, respond, context, client }) => { "node.invoke.result": async ({ params, respond, context }) => {
if (!validateNodeInvokeResultParams(params)) { if (!validateNodeInvokeResultParams(params)) {
respondInvalidParams({ respondInvalidParams({
respond, respond,
@@ -434,11 +401,6 @@ export const nodeHandlers: GatewayRequestHandlers = {
payloadJSON?: string | null; payloadJSON?: string | null;
error?: { code?: string; message?: string } | null; error?: { code?: string; message?: string } | null;
}; };
const callerNodeId = client?.connect?.device?.id ?? client?.connect?.client?.id;
if (callerNodeId && callerNodeId !== p.nodeId) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId mismatch"));
return;
}
const ok = context.nodeRegistry.handleInvokeResult({ const ok = context.nodeRegistry.handleInvokeResult({
id: p.id, id: p.id,
nodeId: p.nodeId, nodeId: p.nodeId,
@@ -453,7 +415,7 @@ export const nodeHandlers: GatewayRequestHandlers = {
} }
respond(true, { ok: true }, undefined); respond(true, { ok: true }, undefined);
}, },
"node.event": async ({ params, respond, context, client }) => { "node.event": async ({ params, respond, context }) => {
if (!validateNodeEventParams(params)) { if (!validateNodeEventParams(params)) {
respondInvalidParams({ respondInvalidParams({
respond, respond,
@@ -471,7 +433,6 @@ export const nodeHandlers: GatewayRequestHandlers = {
: null; : null;
await respondUnavailableOnThrow(respond, async () => { await respondUnavailableOnThrow(respond, async () => {
const { handleNodeEvent } = await import("../server-node-events.js"); const { handleNodeEvent } = await import("../server-node-events.js");
const nodeId = client?.connect?.device?.id ?? client?.connect?.client?.id ?? "node";
const nodeContext = { const nodeContext = {
deps: context.deps, deps: context.deps,
broadcast: context.broadcast, broadcast: context.broadcast,
@@ -492,7 +453,7 @@ export const nodeHandlers: GatewayRequestHandlers = {
loadGatewayModelCatalog: context.loadGatewayModelCatalog, loadGatewayModelCatalog: context.loadGatewayModelCatalog,
logGateway: { warn: context.logGateway.warn }, logGateway: { warn: context.logGateway.warn },
}; };
await handleNodeEvent(nodeContext, nodeId, { await handleNodeEvent(nodeContext, "node", {
event: p.event, event: p.event,
payloadJSON, payloadJSON,
}); });
-62
View File
@@ -1,7 +1,6 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { installSkill } from "../../agents/skills-install.js"; import { installSkill } from "../../agents/skills-install.js";
import { buildWorkspaceSkillStatus } from "../../agents/skills-status.js"; import { buildWorkspaceSkillStatus } from "../../agents/skills-status.js";
import { loadWorkspaceSkillEntries, type SkillEntry } from "../../agents/skills.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { loadConfig, writeConfigFile } from "../../config/config.js"; import { loadConfig, writeConfigFile } from "../../config/config.js";
import { getRemoteSkillEligibility } from "../../infra/skills-remote.js"; import { getRemoteSkillEligibility } from "../../infra/skills-remote.js";
@@ -9,52 +8,12 @@ import {
ErrorCodes, ErrorCodes,
errorShape, errorShape,
formatValidationErrors, formatValidationErrors,
validateSkillsBinsParams,
validateSkillsInstallParams, validateSkillsInstallParams,
validateSkillsStatusParams, validateSkillsStatusParams,
validateSkillsUpdateParams, validateSkillsUpdateParams,
} from "../protocol/index.js"; } from "../protocol/index.js";
import type { GatewayRequestHandlers } from "./types.js"; import type { GatewayRequestHandlers } from "./types.js";
function listWorkspaceDirs(cfg: ClawdbotConfig): string[] {
const dirs = new Set<string>();
const list = cfg.agents?.list;
if (Array.isArray(list)) {
for (const entry of list) {
if (entry && typeof entry === "object" && typeof entry.id === "string") {
dirs.add(resolveAgentWorkspaceDir(cfg, entry.id));
}
}
}
dirs.add(resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)));
return [...dirs];
}
function collectSkillBins(entries: SkillEntry[]): string[] {
const bins = new Set<string>();
for (const entry of entries) {
const required = entry.clawdbot?.requires?.bins ?? [];
const anyBins = entry.clawdbot?.requires?.anyBins ?? [];
const install = entry.clawdbot?.install ?? [];
for (const bin of required) {
const trimmed = bin.trim();
if (trimmed) bins.add(trimmed);
}
for (const bin of anyBins) {
const trimmed = bin.trim();
if (trimmed) bins.add(trimmed);
}
for (const spec of install) {
const specBins = spec?.bins ?? [];
for (const bin of specBins) {
const trimmed = String(bin).trim();
if (trimmed) bins.add(trimmed);
}
}
}
return [...bins].sort();
}
export const skillsHandlers: GatewayRequestHandlers = { export const skillsHandlers: GatewayRequestHandlers = {
"skills.status": ({ params, respond }) => { "skills.status": ({ params, respond }) => {
if (!validateSkillsStatusParams(params)) { if (!validateSkillsStatusParams(params)) {
@@ -76,27 +35,6 @@ export const skillsHandlers: GatewayRequestHandlers = {
}); });
respond(true, report, undefined); respond(true, report, undefined);
}, },
"skills.bins": ({ params, respond }) => {
if (!validateSkillsBinsParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid skills.bins params: ${formatValidationErrors(validateSkillsBinsParams.errors)}`,
),
);
return;
}
const cfg = loadConfig();
const workspaceDirs = listWorkspaceDirs(cfg);
const bins = new Set<string>();
for (const workspaceDir of workspaceDirs) {
const entries = loadWorkspaceSkillEntries(workspaceDir, { config: cfg });
for (const bin of collectSkillBins(entries)) bins.add(bin);
}
respond(true, { bins: [...bins].sort() }, undefined);
},
"skills.install": async ({ params, respond }) => { "skills.install": async ({ params, respond }) => {
if (!validateSkillsInstallParams(params)) { if (!validateSkillsInstallParams(params)) {
respond( respond(
+6 -5
View File
@@ -8,8 +8,6 @@ import {
setGatewaySigusr1RestartPolicy, setGatewaySigusr1RestartPolicy,
} from "../infra/restart.js"; } from "../infra/restart.js";
import { setCommandLaneConcurrency } from "../process/command-queue.js"; import { setCommandLaneConcurrency } from "../process/command-queue.js";
import { resolveAgentMaxConcurrent, resolveSubagentMaxConcurrent } from "../config/agent-limits.js";
import { CommandLane } from "../process/lanes.js";
import { isTruthyEnvValue } from "../infra/env.js"; import { isTruthyEnvValue } from "../infra/env.js";
import type { ChannelKind, GatewayReloadPlan } from "./config-reload.js"; import type { ChannelKind, GatewayReloadPlan } from "./config-reload.js";
import { resolveHooksConfig } from "./hooks.js"; import { resolveHooksConfig } from "./hooks.js";
@@ -128,9 +126,12 @@ export function createGatewayReloadHandlers(params: {
} }
} }
setCommandLaneConcurrency(CommandLane.Cron, nextConfig.cron?.maxConcurrentRuns ?? 1); setCommandLaneConcurrency("cron", nextConfig.cron?.maxConcurrentRuns ?? 1);
setCommandLaneConcurrency(CommandLane.Main, resolveAgentMaxConcurrent(nextConfig)); setCommandLaneConcurrency("main", nextConfig.agents?.defaults?.maxConcurrent ?? 1);
setCommandLaneConcurrency(CommandLane.Subagent, resolveSubagentMaxConcurrent(nextConfig)); setCommandLaneConcurrency(
"subagent",
nextConfig.agents?.defaults?.subagents?.maxConcurrent ?? 1,
);
if (plan.hotReasons.length > 0) { if (plan.hotReasons.length > 0) {
params.logReload.info(`config hot reload applied (${plan.hotReasons.join(", ")})`); params.logReload.info(`config hot reload applied (${plan.hotReasons.join(", ")})`);
-81
View File
@@ -100,81 +100,6 @@ describe("gateway server auth/connect", () => {
await server.close(); await server.close();
}); });
test("accepts device token auth for paired device", async () => {
const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
const { approveDevicePairing, getPairedDevice, listDevicePairing } = await import(
"../infra/device-pairing.js"
);
const { server, ws, port, prevToken } = await startServerWithClient("secret");
const res = await connectReq(ws, { token: "secret" });
if (!res.ok) {
const list = await listDevicePairing();
const pending = list.pending.at(0);
expect(pending?.requestId).toBeDefined();
if (pending?.requestId) {
await approveDevicePairing(pending.requestId);
}
}
const identity = loadOrCreateDeviceIdentity();
const paired = await getPairedDevice(identity.deviceId);
const deviceToken = paired?.tokens?.operator?.token;
expect(deviceToken).toBeDefined();
ws.close();
const ws2 = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => ws2.once("open", resolve));
const res2 = await connectReq(ws2, { token: deviceToken });
expect(res2.ok).toBe(true);
ws2.close();
await server.close();
if (prevToken === undefined) {
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
} else {
process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
}
});
test("rejects revoked device token", async () => {
const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
const { approveDevicePairing, getPairedDevice, listDevicePairing, revokeDeviceToken } =
await import("../infra/device-pairing.js");
const { server, ws, port, prevToken } = await startServerWithClient("secret");
const res = await connectReq(ws, { token: "secret" });
if (!res.ok) {
const list = await listDevicePairing();
const pending = list.pending.at(0);
expect(pending?.requestId).toBeDefined();
if (pending?.requestId) {
await approveDevicePairing(pending.requestId);
}
}
const identity = loadOrCreateDeviceIdentity();
const paired = await getPairedDevice(identity.deviceId);
const deviceToken = paired?.tokens?.operator?.token;
expect(deviceToken).toBeDefined();
await revokeDeviceToken({ deviceId: identity.deviceId, role: "operator" });
ws.close();
const ws2 = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => ws2.once("open", resolve));
const res2 = await connectReq(ws2, { token: deviceToken });
expect(res2.ok).toBe(false);
ws2.close();
await server.close();
if (prevToken === undefined) {
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
} else {
process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
}
});
test("rejects invalid password", async () => { test("rejects invalid password", async () => {
testState.gatewayAuth = { mode: "password", password: "secret" }; testState.gatewayAuth = { mode: "password", password: "secret" };
const port = await getFreePort(); const port = await getFreePort();
@@ -225,12 +150,6 @@ describe("gateway server auth/connect", () => {
platform: "web", platform: "web",
mode: "webchat", mode: "webchat",
}, },
device: {
id: 123,
publicKey: "bad",
signature: "bad",
signedAt: "bad",
},
}, },
}), }),
); );
-2
View File
@@ -185,7 +185,6 @@ describe("gateway server cron", () => {
test("accepts jobId for cron.update", async () => { test("accepts jobId for cron.update", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-"));
testState.cronStorePath = path.join(dir, "cron", "jobs.json"); testState.cronStorePath = path.join(dir, "cron", "jobs.json");
testState.cronEnabled = false;
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true }); await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] })); await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] }));
@@ -219,7 +218,6 @@ describe("gateway server cron", () => {
await server.close(); await server.close();
await rmTempDir(dir); await rmTempDir(dir);
testState.cronStorePath = undefined; testState.cronStorePath = undefined;
testState.cronEnabled = undefined;
}); });
test("disables cron jobs via enabled:false patches", async () => { test("disables cron jobs via enabled:false patches", async () => {
+1 -33
View File
@@ -1,14 +1,7 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import os from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { WebSocket } from "ws"; import { WebSocket } from "ws";
import { emitAgentEvent } from "../infra/agent-events.js"; import { emitAgentEvent } from "../infra/agent-events.js";
import {
loadOrCreateDeviceIdentity,
publicKeyRawBase64UrlFromPem,
signDevicePayload,
} from "../infra/device-identity.js";
import { emitHeartbeatEvent } from "../infra/heartbeat-events.js"; import { emitHeartbeatEvent } from "../infra/heartbeat-events.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { import {
@@ -19,7 +12,6 @@ import {
startGatewayServer, startGatewayServer,
startServerWithClient, startServerWithClient,
} from "./test-helpers.js"; } from "./test-helpers.js";
import { buildDeviceAuthPayload } from "./device-auth.js";
installGatewayTestHooks(); installGatewayTestHooks();
@@ -208,24 +200,8 @@ describe("gateway server health/presence", () => {
}); });
test("presence includes client fingerprint", async () => { test("presence includes client fingerprint", async () => {
const identityPath = path.join(os.tmpdir(), `clawdbot-device-${randomUUID()}.json`);
const identity = loadOrCreateDeviceIdentity(identityPath);
const role = "operator";
const scopes: string[] = [];
const signedAtMs = Date.now();
const payload = buildDeviceAuthPayload({
deviceId: identity.deviceId,
clientId: GATEWAY_CLIENT_NAMES.FINGERPRINT,
clientMode: GATEWAY_CLIENT_MODES.UI,
role,
scopes,
signedAtMs,
token: null,
});
const { server, ws } = await startServerWithClient(); const { server, ws } = await startServerWithClient();
await connectOk(ws, { await connectOk(ws, {
role,
scopes,
client: { client: {
id: GATEWAY_CLIENT_NAMES.FINGERPRINT, id: GATEWAY_CLIENT_NAMES.FINGERPRINT,
version: "9.9.9", version: "9.9.9",
@@ -235,12 +211,6 @@ describe("gateway server health/presence", () => {
mode: GATEWAY_CLIENT_MODES.UI, mode: GATEWAY_CLIENT_MODES.UI,
instanceId: "abc", instanceId: "abc",
}, },
device: {
id: identity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
signature: signDevicePayload(identity.privateKeyPem, payload),
signedAt: signedAtMs,
},
}); });
const presenceP = onceMessage(ws, (o) => o.type === "res" && o.id === "fingerprint", 4000); const presenceP = onceMessage(ws, (o) => o.type === "res" && o.id === "fingerprint", 4000);
@@ -254,9 +224,7 @@ describe("gateway server health/presence", () => {
const presenceRes = await presenceP; const presenceRes = await presenceP;
const entries = presenceRes.payload as Array<Record<string, unknown>>; const entries = presenceRes.payload as Array<Record<string, unknown>>;
const clientEntry = entries.find( const clientEntry = entries.find((e) => e.instanceId === "abc");
(e) => e.host === GATEWAY_CLIENT_NAMES.FINGERPRINT && e.version === "9.9.9",
);
expect(clientEntry?.host).toBe(GATEWAY_CLIENT_NAMES.FINGERPRINT); expect(clientEntry?.host).toBe(GATEWAY_CLIENT_NAMES.FINGERPRINT);
expect(clientEntry?.version).toBe("9.9.9"); expect(clientEntry?.version).toBe("9.9.9");
expect(clientEntry?.mode).toBe("ui"); expect(clientEntry?.mode).toBe("ui");
-175
View File
@@ -1,175 +0,0 @@
import { describe, expect, test } from "vitest";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import {
connectOk,
installGatewayTestHooks,
rpcReq,
startServerWithClient,
} from "./test-helpers.js";
import { GatewayClient } from "./client.js";
installGatewayTestHooks();
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const connectNodeClient = async (params: {
port: number;
commands: string[];
instanceId?: string;
displayName?: string;
onEvent?: (evt: { event?: string; payload?: unknown }) => void;
}) => {
let settled = false;
let resolveReady: (() => void) | null = null;
let rejectReady: ((err: Error) => void) | null = null;
const ready = new Promise<void>((resolve, reject) => {
resolveReady = resolve;
rejectReady = reject;
});
const client = new GatewayClient({
url: `ws://127.0.0.1:${params.port}`,
role: "node",
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientVersion: "1.0.0",
clientDisplayName: params.displayName,
platform: "ios",
mode: GATEWAY_CLIENT_MODES.NODE,
instanceId: params.instanceId,
scopes: [],
commands: params.commands,
onEvent: params.onEvent,
onHelloOk: () => {
if (settled) return;
settled = true;
resolveReady?.();
},
onConnectError: (err) => {
if (settled) return;
settled = true;
rejectReady?.(err);
},
onClose: (code, reason) => {
if (settled) return;
settled = true;
rejectReady?.(new Error(`gateway closed (${code}): ${reason}`));
},
});
client.start();
await Promise.race([
ready,
sleep(10_000).then(() => {
throw new Error("timeout waiting for node to connect");
}),
]);
return client;
};
describe("gateway node command allowlist", () => {
test("rejects commands outside platform allowlist", async () => {
const { server, ws, port } = await startServerWithClient();
await connectOk(ws);
const nodeClient = await connectNodeClient({
port,
commands: ["system.run"],
});
const listRes = await rpcReq<{ nodes?: Array<{ nodeId: string }> }>(ws, "node.list", {});
const nodeId = listRes.payload?.nodes?.[0]?.nodeId ?? "";
expect(nodeId).toBeTruthy();
const res = await rpcReq(ws, "node.invoke", {
nodeId,
command: "system.run",
params: { command: "echo hi" },
idempotencyKey: "allowlist-1",
});
expect(res.ok).toBe(false);
expect(res.error?.message).toContain("node command not allowed");
nodeClient.stop();
ws.close();
await server.close();
});
test("rejects commands not declared by node", async () => {
const { server, ws, port } = await startServerWithClient();
await connectOk(ws);
const nodeClient = await connectNodeClient({
port,
commands: [],
instanceId: "node-empty",
displayName: "node-empty",
});
const listRes = await rpcReq<{ nodes?: Array<{ nodeId: string }> }>(ws, "node.list", {});
const nodeId = listRes.payload?.nodes?.find((entry) => entry.nodeId)?.nodeId ?? "";
expect(nodeId).toBeTruthy();
const res = await rpcReq(ws, "node.invoke", {
nodeId,
command: "canvas.snapshot",
params: {},
idempotencyKey: "allowlist-2",
});
expect(res.ok).toBe(false);
expect(res.error?.message).toContain("node command not allowed");
nodeClient.stop();
ws.close();
await server.close();
});
test("allows declared command within allowlist", async () => {
const { server, ws, port } = await startServerWithClient();
await connectOk(ws);
let resolveInvoke: ((payload: { id?: string; nodeId?: string }) => void) | null = null;
const invokeReqP = new Promise<{ id?: string; nodeId?: string }>((resolve) => {
resolveInvoke = resolve;
});
const nodeClient = await connectNodeClient({
port,
commands: ["canvas.snapshot"],
instanceId: "node-allowed",
displayName: "node-allowed",
onEvent: (evt) => {
if (evt.event === "node.invoke.request") {
const payload = evt.payload as { id?: string; nodeId?: string };
resolveInvoke?.(payload);
}
},
});
const listRes = await rpcReq<{ nodes?: Array<{ nodeId: string }> }>(ws, "node.list", {});
const nodeId = listRes.payload?.nodes?.[0]?.nodeId ?? "";
expect(nodeId).toBeTruthy();
const invokeResP = rpcReq(ws, "node.invoke", {
nodeId,
command: "canvas.snapshot",
params: { format: "png" },
idempotencyKey: "allowlist-3",
});
const payload = await invokeReqP;
const requestId = payload?.id ?? "";
const nodeIdFromReq = payload?.nodeId ?? "node-allowed";
await nodeClient.request("node.invoke.result", {
id: requestId,
nodeId: nodeIdFromReq,
ok: true,
payloadJSON: JSON.stringify({ ok: true }),
});
const invokeRes = await invokeResP;
expect(invokeRes.ok).toBe(true);
nodeClient.stop();
ws.close();
await server.close();
});
});
-61
View File
@@ -1,61 +0,0 @@
import { describe, expect, test } from "vitest";
import { WebSocket } from "ws";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import {
connectOk,
installGatewayTestHooks,
rpcReq,
startServerWithClient,
} from "./test-helpers.js";
installGatewayTestHooks();
describe("gateway role enforcement", () => {
test("operator cannot send node events or invoke results", async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const eventRes = await rpcReq(ws, "node.event", { event: "test", payload: { ok: true } });
expect(eventRes.ok).toBe(false);
expect(eventRes.error?.message ?? "").toContain("unauthorized role");
const invokeRes = await rpcReq(ws, "node.invoke.result", {
id: "invoke-1",
nodeId: "node-1",
ok: true,
});
expect(invokeRes.ok).toBe(false);
expect(invokeRes.error?.message ?? "").toContain("unauthorized role");
ws.close();
await server.close();
});
test("node can fetch skills bins but not control plane methods", async () => {
const { server, port } = await startServerWithClient();
const nodeWs = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => nodeWs.once("open", resolve));
await connectOk(nodeWs, {
role: "node",
client: {
id: GATEWAY_CLIENT_NAMES.NODE_HOST,
version: "1.0.0",
platform: "ios",
mode: GATEWAY_CLIENT_MODES.NODE,
},
commands: [],
});
const binsRes = await rpcReq<{ bins?: unknown[] }>(nodeWs, "skills.bins", {});
expect(binsRes.ok).toBe(true);
expect(Array.isArray(binsRes.payload?.bins)).toBe(true);
const statusRes = await rpcReq(nodeWs, "status", {});
expect(statusRes.ok).toBe(false);
expect(statusRes.error?.message ?? "").toContain("unauthorized role");
nodeWs.close();
await server.close();
});
});
@@ -9,11 +9,9 @@ import {
} from "../../../infra/device-identity.js"; } from "../../../infra/device-identity.js";
import { import {
approveDevicePairing, approveDevicePairing,
ensureDeviceToken,
getPairedDevice, getPairedDevice,
requestDevicePairing, requestDevicePairing,
updatePairedDeviceMetadata, updatePairedDeviceMetadata,
verifyDeviceToken,
} from "../../../infra/device-pairing.js"; } from "../../../infra/device-pairing.js";
import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../../../infra/skills-remote.js"; import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../../../infra/skills-remote.js";
import { loadVoiceWakeConfig } from "../../../infra/voicewake.js"; import { loadVoiceWakeConfig } from "../../../infra/voicewake.js";
@@ -26,7 +24,6 @@ import { authorizeGatewayConnect } from "../../auth.js";
import { loadConfig } from "../../../config/config.js"; import { loadConfig } from "../../../config/config.js";
import { buildDeviceAuthPayload } from "../../device-auth.js"; import { buildDeviceAuthPayload } from "../../device-auth.js";
import { isLoopbackAddress } from "../../net.js"; import { isLoopbackAddress } from "../../net.js";
import { resolveNodeCommandAllowlist } from "../../node-command-policy.js";
import { import {
type ConnectParams, type ConnectParams,
ErrorCodes, ErrorCodes,
@@ -220,12 +217,26 @@ export function attachGatewayWsMessageHandler(params: {
return; return;
} }
const roleRaw = connectParams.role ?? "operator"; const authResult = await authorizeGatewayConnect({
const role = roleRaw === "operator" || roleRaw === "node" ? roleRaw : null; auth: resolvedAuth,
if (!role) { connectAuth: connectParams.auth,
req: upgradeReq,
});
if (!authResult.ok) {
setHandshakeState("failed"); setHandshakeState("failed");
setCloseCause("invalid-role", { logWsControl.warn(
role: roleRaw, `unauthorized conn=${connId} remote=${remoteAddr ?? "?"} client=${clientLabel} ${connectParams.client.mode} v${connectParams.client.version}`,
);
const authProvided = connectParams.auth?.token
? "token"
: connectParams.auth?.password
? "password"
: "none";
setCloseCause("unauthorized", {
authMode: resolvedAuth.mode,
authProvided,
authReason: authResult.reason,
allowTailscale: resolvedAuth.allowTailscale,
client: connectParams.client.id, client: connectParams.client.id,
clientDisplayName: connectParams.client.displayName, clientDisplayName: connectParams.client.displayName,
mode: connectParams.client.mode, mode: connectParams.client.mode,
@@ -235,40 +246,24 @@ export function attachGatewayWsMessageHandler(params: {
type: "res", type: "res",
id: frame.id, id: frame.id,
ok: false, ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, "invalid role"), error: errorShape(ErrorCodes.INVALID_REQUEST, "unauthorized"),
}); });
close(1008, "invalid role"); close(1008, "unauthorized");
return; return;
} }
const requestedScopes = Array.isArray(connectParams.scopes) ? connectParams.scopes : []; const authMethod = authResult.method ?? "none";
const scopes =
requestedScopes.length > 0 const role = connectParams.role ?? "operator";
? requestedScopes const scopes = Array.isArray(connectParams.scopes)
: role === "operator" ? connectParams.scopes
? ["operator.admin"] : role === "operator"
: []; ? ["operator.admin"]
: [];
connectParams.role = role; connectParams.role = role;
connectParams.scopes = scopes; connectParams.scopes = scopes;
const device = connectParams.device; const device = connectParams.device;
let devicePublicKey: string | null = null; let devicePublicKey: string | null = null;
if (!device) {
setHandshakeState("failed");
setCloseCause("device-required", {
client: connectParams.client.id,
clientDisplayName: connectParams.client.displayName,
mode: connectParams.client.mode,
version: connectParams.client.version,
});
send({
type: "res",
id: frame.id,
ok: false,
error: errorShape(ErrorCodes.NOT_PAIRED, "device identity required"),
});
close(1008, "device identity required");
return;
}
if (device) { if (device) {
const derivedId = deriveDeviceIdFromPublicKey(device.publicKey); const derivedId = deriveDeviceIdFromPublicKey(device.publicKey);
if (!derivedId || derivedId !== device.id) { if (!derivedId || derivedId !== device.id) {
@@ -312,7 +307,7 @@ export function attachGatewayWsMessageHandler(params: {
clientId: connectParams.client.id, clientId: connectParams.client.id,
clientMode: connectParams.client.mode, clientMode: connectParams.client.mode,
role, role,
scopes: requestedScopes, scopes,
signedAtMs: signedAt, signedAtMs: signedAt,
token: connectParams.auth?.token ?? null, token: connectParams.auth?.token ?? null,
}); });
@@ -351,57 +346,10 @@ 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) {
const tokenCheck = await verifyDeviceToken({
deviceId: device.id,
token: connectParams.auth.token,
role,
scopes,
});
if (tokenCheck.ok) {
authOk = true;
authMethod = "device-token";
}
}
if (!authOk) {
setHandshakeState("failed");
logWsControl.warn(
`unauthorized conn=${connId} remote=${remoteAddr ?? "?"} client=${clientLabel} ${connectParams.client.mode} v${connectParams.client.version}`,
);
const authProvided = connectParams.auth?.token
? "token"
: connectParams.auth?.password
? "password"
: "none";
setCloseCause("unauthorized", {
authMode: resolvedAuth.mode,
authProvided,
authReason: authResult.reason,
allowTailscale: resolvedAuth.allowTailscale,
client: connectParams.client.id,
clientDisplayName: connectParams.client.displayName,
mode: connectParams.client.mode,
version: connectParams.client.version,
});
send({
type: "res",
id: frame.id,
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, "unauthorized"),
});
close(1008, "unauthorized");
return;
}
if (device && devicePublicKey) { if (device && devicePublicKey) {
const requirePairing = async (reason: string, _paired?: { deviceId: string }) => { const paired = await getPairedDevice(device.id);
const isPaired = paired?.publicKey === devicePublicKey;
if (!isPaired) {
const pairing = await requestDevicePairing({ const pairing = await requestDevicePairing({
deviceId: device.id, deviceId: device.id,
publicKey: devicePublicKey, publicKey: devicePublicKey,
@@ -412,7 +360,7 @@ export function attachGatewayWsMessageHandler(params: {
role, role,
scopes, scopes,
remoteIp: remoteAddr, remoteIp: remoteAddr,
silent: isLoopbackAddress(remoteAddr), silent: isLoopbackAddress(remoteAddr) && authMethod !== "none",
}); });
const context = buildRequestContext(); const context = buildRequestContext();
if (pairing.request.silent === true) { if (pairing.request.silent === true) {
@@ -437,7 +385,6 @@ export function attachGatewayWsMessageHandler(params: {
setCloseCause("pairing-required", { setCloseCause("pairing-required", {
deviceId: device.id, deviceId: device.id,
requestId: pairing.request.requestId, requestId: pairing.request.requestId,
reason,
}); });
send({ send({
type: "res", type: "res",
@@ -448,47 +395,9 @@ export function attachGatewayWsMessageHandler(params: {
}), }),
}); });
close(1008, "pairing required"); close(1008, "pairing required");
return false; return;
} }
return true;
};
const paired = await getPairedDevice(device.id);
const isPaired = paired?.publicKey === devicePublicKey;
if (!isPaired) {
const ok = await requirePairing("not-paired");
if (!ok) return;
} else { } else {
const allowedRoles = new Set(
Array.isArray(paired.roles)
? paired.roles
: paired.role
? [paired.role]
: [],
);
if (allowedRoles.size === 0) {
const ok = await requirePairing("role-upgrade", paired);
if (!ok) return;
} else if (!allowedRoles.has(role)) {
const ok = await requirePairing("role-upgrade", paired);
if (!ok) return;
}
const pairedScopes = Array.isArray(paired.scopes) ? paired.scopes : [];
if (scopes.length > 0) {
if (pairedScopes.length === 0) {
const ok = await requirePairing("scope-upgrade", paired);
if (!ok) return;
} else {
const allowedScopes = new Set(pairedScopes);
const missingScope = scopes.find((scope) => !allowedScopes.has(scope));
if (missingScope) {
const ok = await requirePairing("scope-upgrade", paired);
if (!ok) return;
}
}
}
await updatePairedDeviceMetadata(device.id, { await updatePairedDeviceMetadata(device.id, {
displayName: connectParams.client.displayName, displayName: connectParams.client.displayName,
platform: connectParams.client.platform, platform: connectParams.client.platform,
@@ -501,29 +410,10 @@ export function attachGatewayWsMessageHandler(params: {
} }
} }
const deviceToken = device
? await ensureDeviceToken({ deviceId: device.id, role, scopes })
: null;
if (role === "node") {
const cfg = loadConfig();
const allowlist = resolveNodeCommandAllowlist(cfg, {
platform: connectParams.client.platform,
deviceFamily: connectParams.client.deviceFamily,
});
const declared = Array.isArray(connectParams.commands) ? connectParams.commands : [];
const filtered = declared
.map((cmd) => cmd.trim())
.filter((cmd) => cmd.length > 0 && allowlist.has(cmd));
connectParams.commands = filtered;
}
const shouldTrackPresence = !isGatewayCliClient(connectParams.client); const shouldTrackPresence = !isGatewayCliClient(connectParams.client);
const clientId = connectParams.client.id; const clientId = connectParams.client.id;
const instanceId = connectParams.client.instanceId; const instanceId = connectParams.client.instanceId;
const presenceKey = shouldTrackPresence const presenceKey = shouldTrackPresence ? (instanceId ?? connId) : undefined;
? (connectParams.device?.id ?? instanceId ?? connId)
: undefined;
logWs("in", "connect", { logWs("in", "connect", {
connId, connId,
@@ -551,7 +441,7 @@ export function attachGatewayWsMessageHandler(params: {
deviceFamily: connectParams.client.deviceFamily, deviceFamily: connectParams.client.deviceFamily,
modelIdentifier: connectParams.client.modelIdentifier, modelIdentifier: connectParams.client.modelIdentifier,
mode: connectParams.client.mode, mode: connectParams.client.mode,
instanceId: connectParams.device?.id ?? instanceId, instanceId,
reason: "connect", reason: "connect",
}); });
incrementPresenceVersion(); incrementPresenceVersion();
@@ -575,14 +465,6 @@ export function attachGatewayWsMessageHandler(params: {
features: { methods: gatewayMethods, events }, features: { methods: gatewayMethods, events },
snapshot, snapshot,
canvasHostUrl, canvasHostUrl,
auth: deviceToken
? {
deviceToken: deviceToken.token,
role: deviceToken.role,
scopes: deviceToken.scopes,
issuedAtMs: deviceToken.rotatedAtMs ?? deviceToken.createdAtMs,
}
: undefined,
policy: { policy: {
maxPayload: MAX_PAYLOAD_BYTES, maxPayload: MAX_PAYLOAD_BYTES,
maxBufferedBytes: MAX_BUFFERED_BYTES, maxBufferedBytes: MAX_BUFFERED_BYTES,
+7 -43
View File
@@ -8,11 +8,6 @@ import { WebSocket } from "ws";
import { resolveMainSessionKeyFromConfig, type SessionEntry } from "../config/sessions.js"; import { resolveMainSessionKeyFromConfig, type SessionEntry } from "../config/sessions.js";
import { resetAgentRunContextForTest } from "../infra/agent-events.js"; import { resetAgentRunContextForTest } from "../infra/agent-events.js";
import {
loadOrCreateDeviceIdentity,
publicKeyRawBase64UrlFromPem,
signDevicePayload,
} from "../infra/device-identity.js";
import { drainSystemEvents, peekSystemEvents } from "../infra/system-events.js"; import { drainSystemEvents, peekSystemEvents } from "../infra/system-events.js";
import { rawDataToString } from "../infra/ws.js"; import { rawDataToString } from "../infra/ws.js";
import { resetLogger, setLoggerOverride } from "../logging.js"; import { resetLogger, setLoggerOverride } from "../logging.js";
@@ -21,7 +16,6 @@ import { getDeterministicFreePortBlock } from "../test-utils/ports.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { PROTOCOL_VERSION } from "./protocol/index.js"; import { PROTOCOL_VERSION } from "./protocol/index.js";
import { buildDeviceAuthPayload } from "./device-auth.js";
import type { GatewayServerOptions } from "./server.js"; import type { GatewayServerOptions } from "./server.js";
import { import {
agentCommand, agentCommand,
@@ -274,44 +268,10 @@ export async function connectReq(
caps?: string[]; caps?: string[];
commands?: string[]; commands?: string[];
permissions?: Record<string, boolean>; permissions?: Record<string, boolean>;
device?: {
id: string;
publicKey: string;
signature: string;
signedAt: number;
};
}, },
): Promise<ConnectResponse> { ): Promise<ConnectResponse> {
const { randomUUID } = await import("node:crypto"); const { randomUUID } = await import("node:crypto");
const id = randomUUID(); const id = randomUUID();
const client = opts?.client ?? {
id: GATEWAY_CLIENT_NAMES.TEST,
version: "1.0.0",
platform: "test",
mode: GATEWAY_CLIENT_MODES.TEST,
};
const role = opts?.role ?? "operator";
const requestedScopes = Array.isArray(opts?.scopes) ? opts?.scopes : [];
const device = (() => {
if (opts?.device) return opts.device;
const identity = loadOrCreateDeviceIdentity();
const signedAtMs = Date.now();
const payload = buildDeviceAuthPayload({
deviceId: identity.deviceId,
clientId: client.id,
clientMode: client.mode,
role,
scopes: requestedScopes,
signedAtMs,
token: opts?.token ?? null,
});
return {
id: identity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
signature: signDevicePayload(identity.privateKeyPem, payload),
signedAt: signedAtMs,
};
})();
ws.send( ws.send(
JSON.stringify({ JSON.stringify({
type: "req", type: "req",
@@ -320,11 +280,16 @@ export async function connectReq(
params: { params: {
minProtocol: opts?.minProtocol ?? PROTOCOL_VERSION, minProtocol: opts?.minProtocol ?? PROTOCOL_VERSION,
maxProtocol: opts?.maxProtocol ?? PROTOCOL_VERSION, maxProtocol: opts?.maxProtocol ?? PROTOCOL_VERSION,
client, client: opts?.client ?? {
id: GATEWAY_CLIENT_NAMES.TEST,
version: "1.0.0",
platform: "test",
mode: GATEWAY_CLIENT_MODES.TEST,
},
caps: opts?.caps ?? [], caps: opts?.caps ?? [],
commands: opts?.commands ?? [], commands: opts?.commands ?? [],
permissions: opts?.permissions ?? undefined, permissions: opts?.permissions ?? undefined,
role, role: opts?.role,
scopes: opts?.scopes, scopes: opts?.scopes,
auth: auth:
opts?.token || opts?.password opts?.token || opts?.password
@@ -333,7 +298,6 @@ export async function connectReq(
password: opts?.password, password: opts?.password,
} }
: undefined, : undefined,
device,
}, },
}), }),
); );
-20
View File
@@ -35,24 +35,4 @@ describe("agent-events sequencing", () => {
expect(seen["run-1"]).toEqual([1, 2, 3]); expect(seen["run-1"]).toEqual([1, 2, 3]);
expect(seen["run-2"]).toEqual([1]); expect(seen["run-2"]).toEqual([1]);
}); });
test("preserves compaction ordering on the event bus", async () => {
const phases: Array<string> = [];
const stop = onAgentEvent((evt) => {
if (evt.runId !== "run-1") return;
if (evt.stream !== "compaction") return;
if (typeof evt.data?.phase === "string") phases.push(evt.data.phase);
});
emitAgentEvent({ runId: "run-1", stream: "compaction", data: { phase: "start" } });
emitAgentEvent({
runId: "run-1",
stream: "compaction",
data: { phase: "end", willRetry: false },
});
stop();
expect(phases).toEqual(["start", "end"]);
});
}); });
-104
View File
@@ -1,104 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { resolveStateDir } from "../config/paths.js";
export type DeviceAuthEntry = {
token: string;
role: string;
scopes: string[];
updatedAtMs: number;
};
type DeviceAuthStore = {
version: 1;
deviceId: string;
tokens: Record<string, DeviceAuthEntry>;
};
const DEVICE_AUTH_FILE = "device-auth.json";
function resolveDeviceAuthPath(env: NodeJS.ProcessEnv = process.env): string {
return path.join(resolveStateDir(env), "identity", DEVICE_AUTH_FILE);
}
function normalizeRole(role: string): string {
return role.trim();
}
function normalizeScopes(scopes: string[] | undefined): string[] {
if (!Array.isArray(scopes)) return [];
const out = new Set<string>();
for (const scope of scopes) {
const trimmed = scope.trim();
if (trimmed) out.add(trimmed);
}
return [...out].sort();
}
function readStore(filePath: string): DeviceAuthStore | null {
try {
if (!fs.existsSync(filePath)) return null;
const raw = fs.readFileSync(filePath, "utf8");
const parsed = JSON.parse(raw) as DeviceAuthStore;
if (parsed?.version !== 1 || typeof parsed.deviceId !== "string") return null;
if (!parsed.tokens || typeof parsed.tokens !== "object") return null;
return parsed;
} catch {
return null;
}
}
function writeStore(filePath: string, store: DeviceAuthStore): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
try {
fs.chmodSync(filePath, 0o600);
} catch {
// best-effort
}
}
export function loadDeviceAuthToken(params: {
deviceId: string;
role: string;
env?: NodeJS.ProcessEnv;
}): DeviceAuthEntry | null {
const filePath = resolveDeviceAuthPath(params.env);
const store = readStore(filePath);
if (!store) return null;
if (store.deviceId !== params.deviceId) return null;
const role = normalizeRole(params.role);
const entry = store.tokens[role];
if (!entry || typeof entry.token !== "string") return null;
return entry;
}
export function storeDeviceAuthToken(params: {
deviceId: string;
role: string;
token: string;
scopes?: string[];
env?: NodeJS.ProcessEnv;
}): DeviceAuthEntry {
const filePath = resolveDeviceAuthPath(params.env);
const existing = readStore(filePath);
const role = normalizeRole(params.role);
const next: DeviceAuthStore = {
version: 1,
deviceId: params.deviceId,
tokens:
existing && existing.deviceId === params.deviceId && existing.tokens
? { ...existing.tokens }
: {},
};
const entry: DeviceAuthEntry = {
token: params.token,
role,
scopes: normalizeScopes(params.scopes),
updatedAtMs: Date.now(),
};
next.tokens[role] = entry;
writeStore(filePath, next);
return entry;
}
-44
View File
@@ -1,44 +0,0 @@
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
import {
approveDevicePairing,
getPairedDevice,
requestDevicePairing,
rotateDeviceToken,
} from "./device-pairing.js";
describe("device pairing tokens", () => {
test("preserves existing token scopes when rotating without scopes", async () => {
const baseDir = await mkdtemp(join(tmpdir(), "clawdbot-device-pairing-"));
const request = await requestDevicePairing(
{
deviceId: "device-1",
publicKey: "public-key-1",
role: "operator",
scopes: ["operator.admin"],
},
baseDir,
);
await approveDevicePairing(request.request.requestId, baseDir);
await rotateDeviceToken({
deviceId: "device-1",
role: "operator",
scopes: ["operator.read"],
baseDir,
});
let paired = await getPairedDevice("device-1", baseDir);
expect(paired?.tokens?.operator?.scopes).toEqual(["operator.read"]);
expect(paired?.scopes).toEqual(["operator.read"]);
await rotateDeviceToken({
deviceId: "device-1",
role: "operator",
baseDir,
});
paired = await getPairedDevice("device-1", baseDir);
expect(paired?.tokens?.operator?.scopes).toEqual(["operator.read"]);
});
});
+1 -223
View File
@@ -20,25 +20,6 @@ export type DevicePairingPendingRequest = {
ts: number; ts: number;
}; };
export type DeviceAuthToken = {
token: string;
role: string;
scopes: string[];
createdAtMs: number;
rotatedAtMs?: number;
revokedAtMs?: number;
lastUsedAtMs?: number;
};
export type DeviceAuthTokenSummary = {
role: string;
scopes: string[];
createdAtMs: number;
rotatedAtMs?: number;
revokedAtMs?: number;
lastUsedAtMs?: number;
};
export type PairedDevice = { export type PairedDevice = {
deviceId: string; deviceId: string;
publicKey: string; publicKey: string;
@@ -50,7 +31,6 @@ export type PairedDevice = {
roles?: string[]; roles?: string[];
scopes?: string[]; scopes?: string[];
remoteIp?: string; remoteIp?: string;
tokens?: Record<string, DeviceAuthToken>;
createdAtMs: number; createdAtMs: number;
approvedAtMs: number; approvedAtMs: number;
}; };
@@ -156,11 +136,6 @@ function normalizeDeviceId(deviceId: string) {
return deviceId.trim(); return deviceId.trim();
} }
function normalizeRole(role: string | undefined): string | null {
const trimmed = role?.trim();
return trimmed ? trimmed : null;
}
function mergeRoles(...items: Array<string | string[] | undefined>): string[] | undefined { function mergeRoles(...items: Array<string | string[] | undefined>): string[] | undefined {
const roles = new Set<string>(); const roles = new Set<string>();
for (const item of items) { for (const item of items) {
@@ -179,40 +154,6 @@ function mergeRoles(...items: Array<string | string[] | undefined>): string[] |
return [...roles]; return [...roles];
} }
function mergeScopes(...items: Array<string[] | undefined>): string[] | undefined {
const scopes = new Set<string>();
for (const item of items) {
if (!item) continue;
for (const scope of item) {
const trimmed = scope.trim();
if (trimmed) scopes.add(trimmed);
}
}
if (scopes.size === 0) return undefined;
return [...scopes];
}
function normalizeScopes(scopes: string[] | undefined): string[] {
if (!Array.isArray(scopes)) return [];
const out = new Set<string>();
for (const scope of scopes) {
const trimmed = scope.trim();
if (trimmed) out.add(trimmed);
}
return [...out].sort();
}
function scopesAllow(requested: string[], allowed: string[]): boolean {
if (requested.length === 0) return true;
if (allowed.length === 0) return false;
const allowedSet = new Set(allowed);
return requested.every((scope) => allowedSet.has(scope));
}
function newToken() {
return randomUUID().replaceAll("-", "");
}
export async function listDevicePairing(baseDir?: string): Promise<DevicePairingList> { export async function listDevicePairing(baseDir?: string): Promise<DevicePairingList> {
const state = await loadState(baseDir); const state = await loadState(baseDir);
const pending = Object.values(state.pendingById).sort((a, b) => b.ts - a.ts); const pending = Object.values(state.pendingById).sort((a, b) => b.ts - a.ts);
@@ -282,23 +223,6 @@ export async function approveDevicePairing(
const now = Date.now(); const now = Date.now();
const existing = state.pairedByDeviceId[pending.deviceId]; const existing = state.pairedByDeviceId[pending.deviceId];
const roles = mergeRoles(existing?.roles, existing?.role, pending.roles, pending.role); const roles = mergeRoles(existing?.roles, existing?.role, pending.roles, pending.role);
const scopes = mergeScopes(existing?.scopes, pending.scopes);
const tokens = existing?.tokens ? { ...existing.tokens } : {};
const roleForToken = normalizeRole(pending.role);
if (roleForToken) {
const nextScopes = normalizeScopes(pending.scopes);
const existingToken = tokens[roleForToken];
const now = Date.now();
tokens[roleForToken] = {
token: newToken(),
role: roleForToken,
scopes: nextScopes,
createdAtMs: existingToken?.createdAtMs ?? now,
rotatedAtMs: existingToken ? now : undefined,
revokedAtMs: undefined,
lastUsedAtMs: existingToken?.lastUsedAtMs,
};
}
const device: PairedDevice = { const device: PairedDevice = {
deviceId: pending.deviceId, deviceId: pending.deviceId,
publicKey: pending.publicKey, publicKey: pending.publicKey,
@@ -308,9 +232,8 @@ export async function approveDevicePairing(
clientMode: pending.clientMode, clientMode: pending.clientMode,
role: pending.role, role: pending.role,
roles, roles,
scopes, scopes: pending.scopes,
remoteIp: pending.remoteIp, remoteIp: pending.remoteIp,
tokens,
createdAtMs: existing?.createdAtMs ?? now, createdAtMs: existing?.createdAtMs ?? now,
approvedAtMs: now, approvedAtMs: now,
}; };
@@ -345,7 +268,6 @@ export async function updatePairedDeviceMetadata(
const existing = state.pairedByDeviceId[normalizeDeviceId(deviceId)]; const existing = state.pairedByDeviceId[normalizeDeviceId(deviceId)];
if (!existing) return; if (!existing) return;
const roles = mergeRoles(existing.roles, existing.role, patch.role); const roles = mergeRoles(existing.roles, existing.role, patch.role);
const scopes = mergeScopes(existing.scopes, patch.scopes);
state.pairedByDeviceId[deviceId] = { state.pairedByDeviceId[deviceId] = {
...existing, ...existing,
...patch, ...patch,
@@ -354,151 +276,7 @@ export async function updatePairedDeviceMetadata(
approvedAtMs: existing.approvedAtMs, approvedAtMs: existing.approvedAtMs,
role: patch.role ?? existing.role, role: patch.role ?? existing.role,
roles, roles,
scopes,
}; };
await persistState(state, baseDir); await persistState(state, baseDir);
}); });
} }
export function summarizeDeviceTokens(
tokens: Record<string, DeviceAuthToken> | undefined,
): DeviceAuthTokenSummary[] | undefined {
if (!tokens) return undefined;
const summaries = Object.values(tokens)
.map((token) => ({
role: token.role,
scopes: token.scopes,
createdAtMs: token.createdAtMs,
rotatedAtMs: token.rotatedAtMs,
revokedAtMs: token.revokedAtMs,
lastUsedAtMs: token.lastUsedAtMs,
}))
.sort((a, b) => a.role.localeCompare(b.role));
return summaries.length > 0 ? summaries : undefined;
}
export async function verifyDeviceToken(params: {
deviceId: string;
token: string;
role: string;
scopes: string[];
baseDir?: string;
}): Promise<{ ok: boolean; reason?: string }> {
return await withLock(async () => {
const state = await loadState(params.baseDir);
const device = state.pairedByDeviceId[normalizeDeviceId(params.deviceId)];
if (!device) return { ok: false, reason: "device-not-paired" };
const role = normalizeRole(params.role);
if (!role) return { ok: false, reason: "role-missing" };
const entry = device.tokens?.[role];
if (!entry) return { ok: false, reason: "token-missing" };
if (entry.revokedAtMs) return { ok: false, reason: "token-revoked" };
if (entry.token !== params.token) return { ok: false, reason: "token-mismatch" };
const requestedScopes = normalizeScopes(params.scopes);
if (!scopesAllow(requestedScopes, entry.scopes)) {
return { ok: false, reason: "scope-mismatch" };
}
entry.lastUsedAtMs = Date.now();
device.tokens ??= {};
device.tokens[role] = entry;
state.pairedByDeviceId[device.deviceId] = device;
await persistState(state, params.baseDir);
return { ok: true };
});
}
export async function ensureDeviceToken(params: {
deviceId: string;
role: string;
scopes: string[];
baseDir?: string;
}): Promise<DeviceAuthToken | null> {
return await withLock(async () => {
const state = await loadState(params.baseDir);
const device = state.pairedByDeviceId[normalizeDeviceId(params.deviceId)];
if (!device) return null;
const role = normalizeRole(params.role);
if (!role) return null;
const requestedScopes = normalizeScopes(params.scopes);
const tokens = device.tokens ? { ...device.tokens } : {};
const existing = tokens[role];
if (existing && !existing.revokedAtMs) {
if (scopesAllow(requestedScopes, existing.scopes)) {
return existing;
}
}
const now = Date.now();
const next: DeviceAuthToken = {
token: newToken(),
role,
scopes: requestedScopes,
createdAtMs: existing?.createdAtMs ?? now,
rotatedAtMs: existing ? now : undefined,
revokedAtMs: undefined,
lastUsedAtMs: existing?.lastUsedAtMs,
};
tokens[role] = next;
device.tokens = tokens;
state.pairedByDeviceId[device.deviceId] = device;
await persistState(state, params.baseDir);
return next;
});
}
export async function rotateDeviceToken(params: {
deviceId: string;
role: string;
scopes?: string[];
baseDir?: string;
}): Promise<DeviceAuthToken | null> {
return await withLock(async () => {
const state = await loadState(params.baseDir);
const device = state.pairedByDeviceId[normalizeDeviceId(params.deviceId)];
if (!device) return null;
const role = normalizeRole(params.role);
if (!role) return null;
const tokens = device.tokens ? { ...device.tokens } : {};
const existing = tokens[role];
const requestedScopes = normalizeScopes(params.scopes ?? existing?.scopes ?? device.scopes);
const now = Date.now();
const next: DeviceAuthToken = {
token: newToken(),
role,
scopes: requestedScopes,
createdAtMs: existing?.createdAtMs ?? now,
rotatedAtMs: now,
revokedAtMs: undefined,
lastUsedAtMs: existing?.lastUsedAtMs,
};
tokens[role] = next;
device.tokens = tokens;
if (params.scopes !== undefined) {
device.scopes = requestedScopes;
}
state.pairedByDeviceId[device.deviceId] = device;
await persistState(state, params.baseDir);
return next;
});
}
export async function revokeDeviceToken(params: {
deviceId: string;
role: string;
baseDir?: string;
}): Promise<DeviceAuthToken | null> {
return await withLock(async () => {
const state = await loadState(params.baseDir);
const device = state.pairedByDeviceId[normalizeDeviceId(params.deviceId)];
if (!device) return null;
const role = normalizeRole(params.role);
if (!role) return null;
if (!device.tokens?.[role]) return null;
const tokens = { ...device.tokens };
const entry = { ...tokens[role], revokedAtMs: Date.now() };
tokens[role] = entry;
device.tokens = tokens;
state.pairedByDeviceId[device.deviceId] = device;
await persistState(state, params.baseDir);
return entry;
});
}
+1 -2
View File
@@ -24,7 +24,6 @@ import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js";
import { formatErrorMessage } from "../infra/errors.js"; import { formatErrorMessage } from "../infra/errors.js";
import { createSubsystemLogger } from "../logging/subsystem.js"; import { createSubsystemLogger } from "../logging/subsystem.js";
import { getQueueSize } from "../process/command-queue.js"; import { getQueueSize } from "../process/command-queue.js";
import { CommandLane } from "../process/lanes.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { normalizeAgentId } from "../routing/session-key.js"; import { normalizeAgentId } from "../routing/session-key.js";
import { emitHeartbeatEvent } from "./heartbeat-events.js"; import { emitHeartbeatEvent } from "./heartbeat-events.js";
@@ -328,7 +327,7 @@ export async function runHeartbeatOnce(opts: {
return { status: "skipped", reason: "disabled" }; return { status: "skipped", reason: "disabled" };
} }
const queueSize = (opts.deps?.getQueueSize ?? getQueueSize)(CommandLane.Main); const queueSize = (opts.deps?.getQueueSize ?? getQueueSize)("main");
if (queueSize > 0) { if (queueSize > 0) {
return { status: "skipped", reason: "requests-in-flight" }; return { status: "skipped", reason: "requests-in-flight" };
} }
+2 -40
View File
@@ -1,36 +1,7 @@
import { lookup } from "node:dns/promises"; import { lookup } from "node:dns/promises";
import { logWarn } from "../logger.js";
type CanvasModule = typeof import("@napi-rs/canvas"); import { createCanvas } from "@napi-rs/canvas";
type PdfJsModule = typeof import("pdfjs-dist/legacy/build/pdf.mjs"); import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
let canvasModulePromise: Promise<CanvasModule> | null = null;
let pdfJsModulePromise: Promise<PdfJsModule> | null = null;
// Lazy-load optional PDF/image deps so non-PDF paths don't require native installs.
async function loadCanvasModule(): Promise<CanvasModule> {
if (!canvasModulePromise) {
canvasModulePromise = import("@napi-rs/canvas").catch((err) => {
canvasModulePromise = null;
throw new Error(
`Optional dependency @napi-rs/canvas is required for PDF image extraction: ${String(err)}`,
);
});
}
return canvasModulePromise;
}
async function loadPdfJsModule(): Promise<PdfJsModule> {
if (!pdfJsModulePromise) {
pdfJsModulePromise = import("pdfjs-dist/legacy/build/pdf.mjs").catch((err) => {
pdfJsModulePromise = null;
throw new Error(
`Optional dependency pdfjs-dist is required for PDF extraction: ${String(err)}`,
);
});
}
return pdfJsModulePromise;
}
export type InputImageContent = { export type InputImageContent = {
type: "image"; type: "image";
@@ -267,7 +238,6 @@ async function extractPdfContent(params: {
limits: InputFileLimits; limits: InputFileLimits;
}): Promise<{ text: string; images: InputImageContent[] }> { }): Promise<{ text: string; images: InputImageContent[] }> {
const { buffer, limits } = params; const { buffer, limits } = params;
const { getDocument } = await loadPdfJsModule();
const pdf = await getDocument({ const pdf = await getDocument({
data: new Uint8Array(buffer), data: new Uint8Array(buffer),
disableWorker: true, disableWorker: true,
@@ -290,14 +260,6 @@ async function extractPdfContent(params: {
return { text, images: [] }; return { text, images: [] };
} }
let canvasModule: CanvasModule;
try {
canvasModule = await loadCanvasModule();
} catch (err) {
logWarn(`media: PDF image extraction skipped; ${String(err)}`);
return { text, images: [] };
}
const { createCanvas } = canvasModule;
const images: InputImageContent[] = []; const images: InputImageContent[] = [];
for (let pageNum = 1; pageNum <= maxPages; pageNum += 1) { for (let pageNum = 1; pageNum <= maxPages; pageNum += 1) {
const page = await pdf.getPage(pageNum); const page = await pdf.getPage(pageNum);
-22
View File
@@ -335,12 +335,6 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
...(pluginSdkAlias ? { alias: { "clawdbot/plugin-sdk": pluginSdkAlias } } : {}), ...(pluginSdkAlias ? { alias: { "clawdbot/plugin-sdk": pluginSdkAlias } } : {}),
}); });
const bundledIds = new Set(
discovery.candidates
.filter((candidate) => candidate.origin === "bundled")
.map((candidate) => candidate.idHint),
);
const seenIds = new Map<string, PluginRecord["origin"]>(); const seenIds = new Map<string, PluginRecord["origin"]>();
const memorySlot = normalized.slots.memory; const memorySlot = normalized.slots.memory;
let selectedMemoryPluginId: string | null = null; let selectedMemoryPluginId: string | null = null;
@@ -448,22 +442,6 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
: undefined; : undefined;
if (!definition?.configSchema) { if (!definition?.configSchema) {
const hasBundledFallback =
candidate.origin !== "bundled" && bundledIds.has(candidate.idHint);
if (hasBundledFallback) {
record.enabled = false;
record.status = "disabled";
record.error = "missing config schema (using bundled plugin)";
registry.plugins.push(record);
registry.diagnostics.push({
level: "warn",
pluginId: record.id,
source: record.source,
message: record.error,
});
continue;
}
logger.error(`[plugins] ${record.id} missing config schema`); logger.error(`[plugins] ${record.id} missing config schema`);
record.status = "error"; record.status = "error";
record.error = "missing config schema"; record.error = "missing config schema";
+6 -8
View File
@@ -1,5 +1,3 @@
import { CommandLane } from "./lanes.js";
// Minimal in-process queue to serialize command executions. // Minimal in-process queue to serialize command executions.
// Default lane ("main") preserves the existing behavior. Additional lanes allow // Default lane ("main") preserves the existing behavior. Additional lanes allow
// low-risk parallelism (e.g. cron jobs) without interleaving stdin / logs for // low-risk parallelism (e.g. cron jobs) without interleaving stdin / logs for
@@ -71,7 +69,7 @@ function drainLane(lane: string) {
} }
export function setCommandLaneConcurrency(lane: string, maxConcurrent: number) { export function setCommandLaneConcurrency(lane: string, maxConcurrent: number) {
const cleaned = lane.trim() || CommandLane.Main; const cleaned = lane.trim() || "main";
const state = getLaneState(cleaned); const state = getLaneState(cleaned);
state.maxConcurrent = Math.max(1, Math.floor(maxConcurrent)); state.maxConcurrent = Math.max(1, Math.floor(maxConcurrent));
drainLane(cleaned); drainLane(cleaned);
@@ -85,7 +83,7 @@ export function enqueueCommandInLane<T>(
onWait?: (waitMs: number, queuedAhead: number) => void; onWait?: (waitMs: number, queuedAhead: number) => void;
}, },
): Promise<T> { ): Promise<T> {
const cleaned = lane.trim() || CommandLane.Main; const cleaned = lane.trim() || "main";
const warnAfterMs = opts?.warnAfterMs ?? 2_000; const warnAfterMs = opts?.warnAfterMs ?? 2_000;
const state = getLaneState(cleaned); const state = getLaneState(cleaned);
return new Promise<T>((resolve, reject) => { return new Promise<T>((resolve, reject) => {
@@ -108,10 +106,10 @@ export function enqueueCommand<T>(
onWait?: (waitMs: number, queuedAhead: number) => void; onWait?: (waitMs: number, queuedAhead: number) => void;
}, },
): Promise<T> { ): Promise<T> {
return enqueueCommandInLane(CommandLane.Main, task, opts); return enqueueCommandInLane("main", task, opts);
} }
export function getQueueSize(lane: string = CommandLane.Main) { export function getQueueSize(lane = "main") {
const state = lanes.get(lane); const state = lanes.get(lane);
if (!state) return 0; if (!state) return 0;
return state.queue.length + state.active; return state.queue.length + state.active;
@@ -125,8 +123,8 @@ export function getTotalQueueSize() {
return total; return total;
} }
export function clearCommandLane(lane: string = CommandLane.Main) { export function clearCommandLane(lane = "main") {
const cleaned = lane.trim() || CommandLane.Main; const cleaned = lane.trim() || "main";
const state = lanes.get(cleaned); const state = lanes.get(cleaned);
if (!state) return 0; if (!state) return 0;
const removed = state.queue.length; const removed = state.queue.length;
-6
View File
@@ -1,6 +0,0 @@
export const enum CommandLane {
Main = "main",
Cron = "cron",
Subagent = "subagent",
Nested = "nested",
}
@@ -95,10 +95,7 @@ vi.mock("@slack/bolt", () => {
start = vi.fn().mockResolvedValue(undefined); start = vi.fn().mockResolvedValue(undefined);
stop = vi.fn().mockResolvedValue(undefined); stop = vi.fn().mockResolvedValue(undefined);
} }
class HTTPReceiver { return { App, default: { App } };
requestListener = vi.fn();
}
return { App, HTTPReceiver, default: { App, HTTPReceiver } };
}); });
const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
@@ -97,10 +97,7 @@ vi.mock("@slack/bolt", () => {
start = vi.fn().mockResolvedValue(undefined); start = vi.fn().mockResolvedValue(undefined);
stop = vi.fn().mockResolvedValue(undefined); stop = vi.fn().mockResolvedValue(undefined);
} }
class HTTPReceiver { return { App, default: { App } };
requestListener = vi.fn();
}
return { App, HTTPReceiver, default: { App, HTTPReceiver } };
}); });
const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
@@ -95,10 +95,7 @@ vi.mock("@slack/bolt", () => {
start = vi.fn().mockResolvedValue(undefined); start = vi.fn().mockResolvedValue(undefined);
stop = vi.fn().mockResolvedValue(undefined); stop = vi.fn().mockResolvedValue(undefined);
} }
class HTTPReceiver { return { App, default: { App } };
requestListener = vi.fn();
}
return { App, HTTPReceiver, default: { App, HTTPReceiver } };
}); });
const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
+7 -26
View File
@@ -1,6 +1,6 @@
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import SlackBoltDefault, * as SlackBoltModule from "@slack/bolt"; import SlackBolt from "@slack/bolt";
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js"; import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
import { DEFAULT_GROUP_HISTORY_LIMIT } from "../../auto-reply/reply/history.js"; import { DEFAULT_GROUP_HISTORY_LIMIT } from "../../auto-reply/reply/history.js";
@@ -26,24 +26,12 @@ import { normalizeAllowList } from "./allow-list.js";
import type { MonitorSlackOpts } from "./types.js"; import type { MonitorSlackOpts } from "./types.js";
type SlackBoltNamespace = typeof import("@slack/bolt"); const slackBoltModule = SlackBolt as typeof import("@slack/bolt") & {
type SlackBoltDefault = SlackBoltNamespace | SlackBoltNamespace["App"]; default?: typeof import("@slack/bolt");
};
const slackBoltDefaultImport = SlackBoltDefault as SlackBoltDefault | undefined; // Bun allows named imports from CJS; Node ESM doesn't. Use default+fallback for compatibility.
const slackBoltModuleDefault = (SlackBoltModule as { default?: SlackBoltDefault }).default; const slackBolt = slackBoltModule.default ?? slackBoltModule;
const slackBoltDefault = slackBoltDefaultImport ?? slackBoltModuleDefault; const { App, HTTPReceiver } = slackBolt;
const slackBoltNamespace =
typeof slackBoltDefault === "object" && slackBoltDefault
? (slackBoltDefault as SlackBoltNamespace)
: typeof slackBoltModuleDefault === "object" && slackBoltModuleDefault
? (slackBoltModuleDefault as SlackBoltNamespace)
: undefined;
// Bun allows named imports from CJS; Node ESM doesn't. Resolve default/module shapes for compatibility.
const App = ((typeof slackBoltDefault === "function" ? slackBoltDefault : undefined) ??
slackBoltNamespace?.App ??
SlackBoltModule.App) as SlackBoltNamespace["App"];
const HTTPReceiver = (slackBoltNamespace?.HTTPReceiver ??
SlackBoltModule.HTTPReceiver) as SlackBoltNamespace["HTTPReceiver"];
function parseApiAppIdFromAppToken(raw?: string) { function parseApiAppIdFromAppToken(raw?: string) {
const token = raw?.trim(); const token = raw?.trim();
if (!token) return undefined; if (!token) return undefined;
@@ -133,13 +121,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const mediaMaxBytes = (opts.mediaMaxMb ?? slackCfg.mediaMaxMb ?? 20) * 1024 * 1024; const mediaMaxBytes = (opts.mediaMaxMb ?? slackCfg.mediaMaxMb ?? 20) * 1024 * 1024;
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false; const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
if (!App) {
throw new Error("Slack Bolt App export missing; check @slack/bolt installation.");
}
if (slackMode === "http" && !HTTPReceiver) {
throw new Error("Slack Bolt HTTPReceiver export missing; check @slack/bolt installation.");
}
const receiver = const receiver =
slackMode === "http" slackMode === "http"
? new HTTPReceiver({ ? new HTTPReceiver({
+1 -2
View File
@@ -1,7 +1,6 @@
import { type RunOptions, run } from "@grammyjs/runner"; import { type RunOptions, run } from "@grammyjs/runner";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
import { resolveAgentMaxConcurrent } from "../config/agent-limits.js";
import { computeBackoff, sleepWithAbort } from "../infra/backoff.js"; import { computeBackoff, sleepWithAbort } from "../infra/backoff.js";
import { formatDurationMs } from "../infra/format-duration.js"; import { formatDurationMs } from "../infra/format-duration.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
@@ -29,7 +28,7 @@ export type MonitorTelegramOpts = {
export function createTelegramRunnerOptions(cfg: ClawdbotConfig): RunOptions<unknown> { export function createTelegramRunnerOptions(cfg: ClawdbotConfig): RunOptions<unknown> {
return { return {
sink: { sink: {
concurrency: resolveAgentMaxConcurrent(cfg), concurrency: cfg.agents?.defaults?.maxConcurrent ?? 1,
}, },
runner: { runner: {
fetch: { fetch: {
-10
View File
@@ -87,14 +87,4 @@ describe("extractContentFromMessage", () => {
expect(text).toBe("hello"); expect(text).toBe("hello");
}); });
it("renders error text when stopReason is error and content is not an array", () => {
const text = extractContentFromMessage({
role: "assistant",
stopReason: "error",
errorMessage: '429 {"error":{"message":"rate limit"}}',
});
expect(text).toContain("HTTP 429");
});
}); });
-25
View File
@@ -65,29 +65,4 @@ describe("TuiStreamAssembler", () => {
expect(finalText).toBe("Streamed"); expect(finalText).toBe("Streamed");
}); });
it("returns null when delta text is unchanged", () => {
const assembler = new TuiStreamAssembler();
const first = assembler.ingestDelta(
"run-4",
{
role: "assistant",
content: [{ type: "text", text: "Repeat" }],
},
false,
);
expect(first).toBe("Repeat");
const second = assembler.ingestDelta(
"run-4",
{
role: "assistant",
content: [{ type: "text", text: "Repeat" }],
},
false,
);
expect(second).toBeNull();
});
}); });
+20 -13
View File
@@ -27,9 +27,10 @@ export class TuiStreamAssembler {
return state; return state;
} }
private updateRunState(state: RunStreamState, message: unknown, showThinking: boolean) { ingestDelta(runId: string, message: unknown, showThinking: boolean): string | null {
const thinkingText = extractThinkingFromMessage(message); const thinkingText = extractThinkingFromMessage(message);
const contentText = extractContentFromMessage(message); const contentText = extractContentFromMessage(message);
const state = this.getOrCreateRun(runId);
if (thinkingText) { if (thinkingText) {
state.thinkingText = thinkingText; state.thinkingText = thinkingText;
@@ -44,23 +45,29 @@ export class TuiStreamAssembler {
showThinking, showThinking,
}); });
if (!displayText || displayText === state.displayText) return null;
state.displayText = displayText; state.displayText = displayText;
} return displayText;
ingestDelta(runId: string, message: unknown, showThinking: boolean): string | null {
const state = this.getOrCreateRun(runId);
const previousDisplayText = state.displayText;
this.updateRunState(state, message, showThinking);
if (!state.displayText || state.displayText === previousDisplayText) return null;
return state.displayText;
} }
finalize(runId: string, message: unknown, showThinking: boolean): string { finalize(runId: string, message: unknown, showThinking: boolean): string {
const state = this.getOrCreateRun(runId); const state = this.getOrCreateRun(runId);
this.updateRunState(state, message, showThinking); const thinkingText = extractThinkingFromMessage(message);
const finalComposed = state.displayText; const contentText = extractContentFromMessage(message);
if (thinkingText) {
state.thinkingText = thinkingText;
}
if (contentText) {
state.contentText = contentText;
}
const finalComposed = composeThinkingAndContent({
thinkingText: state.thinkingText,
contentText: state.contentText,
showThinking,
});
const finalText = resolveFinalAssistantText({ const finalText = resolveFinalAssistantText({
finalText: finalComposed, finalText: finalComposed,
streamedText: state.displayText, streamedText: state.displayText,
+121 -80
View File
@@ -6,13 +6,12 @@ import net from "node:net";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterAll, describe, expect, it } from "vitest"; import { afterAll, describe, expect, it } from "vitest";
import { loadOrCreateDeviceIdentity } from "../src/infra/device-identity.js"; import { approveNodePairing, listNodePairing } from "../src/infra/node-pairing.js";
import { GatewayClient } from "../src/gateway/client.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../src/utils/message-channel.js";
type GatewayInstance = { type GatewayInstance = {
name: string; name: string;
port: number; port: number;
bridgePort: number;
hookToken: string; hookToken: string;
gatewayToken: string; gatewayToken: string;
homeDir: string; homeDir: string;
@@ -29,6 +28,10 @@ type NodeListPayload = {
type HealthPayload = { ok?: boolean }; type HealthPayload = { ok?: boolean };
type PairingList = {
pending: Array<{ requestId: string; nodeId: string }>;
};
const GATEWAY_START_TIMEOUT_MS = 45_000; const GATEWAY_START_TIMEOUT_MS = 45_000;
const E2E_TIMEOUT_MS = 120_000; const E2E_TIMEOUT_MS = 120_000;
@@ -93,6 +96,7 @@ const waitForPortOpen = async (
const spawnGatewayInstance = async (name: string): Promise<GatewayInstance> => { const spawnGatewayInstance = async (name: string): Promise<GatewayInstance> => {
const port = await getFreePort(); const port = await getFreePort();
const bridgePort = await getFreePort();
const hookToken = `token-${name}-${randomUUID()}`; const hookToken = `token-${name}-${randomUUID()}`;
const gatewayToken = `gateway-${name}-${randomUUID()}`; const gatewayToken = `gateway-${name}-${randomUUID()}`;
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), `clawdbot-e2e-${name}-`)); const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), `clawdbot-e2e-${name}-`));
@@ -103,6 +107,7 @@ const spawnGatewayInstance = async (name: string): Promise<GatewayInstance> => {
const config = { const config = {
gateway: { port, auth: { mode: "token", token: gatewayToken } }, gateway: { port, auth: { mode: "token", token: gatewayToken } },
hooks: { enabled: true, token: hookToken, path: "/hooks" }, hooks: { enabled: true, token: hookToken, path: "/hooks" },
bridge: { bind: "loopback", port: bridgePort },
}; };
await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf8"); await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf8");
@@ -134,6 +139,9 @@ const spawnGatewayInstance = async (name: string): Promise<GatewayInstance> => {
CLAWDBOT_SKIP_CHANNELS: "1", CLAWDBOT_SKIP_CHANNELS: "1",
CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER: "1", CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER: "1",
CLAWDBOT_SKIP_CANVAS_HOST: "1", CLAWDBOT_SKIP_CANVAS_HOST: "1",
CLAWDBOT_ENABLE_BRIDGE_IN_TESTS: "1",
CLAWDBOT_BRIDGE_HOST: "127.0.0.1",
CLAWDBOT_BRIDGE_PORT: String(bridgePort),
}, },
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
}, },
@@ -149,6 +157,7 @@ const spawnGatewayInstance = async (name: string): Promise<GatewayInstance> => {
return { return {
name, name,
port, port,
bridgePort,
hookToken, hookToken,
gatewayToken, gatewayToken,
homeDir, homeDir,
@@ -269,91 +278,105 @@ const postJson = async (url: string, body: unknown) => {
}); });
}; };
const connectNode = async ( const createLineReader = (socket: net.Socket) => {
inst: GatewayInstance, let buffer = "";
label: string, const pending: Array<(line: string) => void> = [];
): Promise<{ client: GatewayClient; nodeId: string }> => {
const identityPath = path.join(inst.homeDir, `${label}-device.json`); const flush = () => {
const deviceIdentity = loadOrCreateDeviceIdentity(identityPath); while (pending.length > 0) {
const nodeId = deviceIdentity.deviceId; const idx = buffer.indexOf("\n");
let settled = false; if (idx === -1) return;
let resolveReady: (() => void) | null = null; const line = buffer.slice(0, idx);
let rejectReady: ((err: Error) => void) | null = null; buffer = buffer.slice(idx + 1);
const ready = new Promise<void>((resolve, reject) => { const resolve = pending.shift();
resolveReady = resolve; resolve?.(line);
rejectReady = reject; }
};
socket.on("data", (chunk) => {
buffer += chunk.toString("utf8");
flush();
}); });
const client = new GatewayClient({ const readLine = async () => {
url: `ws://127.0.0.1:${inst.port}`, flush();
token: inst.gatewayToken, const idx = buffer.indexOf("\n");
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST, if (idx !== -1) {
clientDisplayName: label, const line = buffer.slice(0, idx);
clientVersion: "1.0.0", buffer = buffer.slice(idx + 1);
platform: "ios", return line;
mode: GATEWAY_CLIENT_MODES.NODE, }
role: "node", return await new Promise<string>((resolve) => pending.push(resolve));
scopes: [], };
caps: ["system"],
commands: ["system.run"],
deviceIdentity,
onHelloOk: () => {
if (settled) return;
settled = true;
resolveReady?.();
},
onConnectError: (err) => {
if (settled) return;
settled = true;
rejectReady?.(err);
},
onClose: (code, reason) => {
if (settled) return;
settled = true;
rejectReady?.(new Error(`gateway closed (${code}): ${reason}`));
},
});
client.start(); return readLine;
try {
await Promise.race([
ready,
sleep(10_000).then(() => {
throw new Error(`timeout waiting for ${label} to connect`);
}),
]);
} catch (err) {
client.stop();
throw err;
}
return { client, nodeId };
}; };
const waitForNodeStatus = async (inst: GatewayInstance, nodeId: string, timeoutMs = 10_000) => { const sendLine = (socket: net.Socket, obj: unknown) => {
socket.write(`${JSON.stringify(obj)}\n`);
};
const readLineWithTimeout = async (
readLine: () => Promise<string>,
label: string,
timeoutMs = 10_000,
) => {
const timer = sleep(timeoutMs).then(() => {
throw new Error(`timeout waiting for ${label}`);
});
return await Promise.race([readLine(), timer]);
};
const waitForPairRequest = async (baseDir: string, nodeId: string, timeoutMs = 10_000) => {
const deadline = Date.now() + timeoutMs; const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) { while (Date.now() < deadline) {
const list = (await runCliJson( const list = (await listNodePairing(baseDir)) as PairingList;
["nodes", "status", "--json", "--url", `ws://127.0.0.1:${inst.port}`], const match = list.pending.find((p) => p.nodeId === nodeId);
{ if (match?.requestId) return match.requestId;
CLAWDBOT_GATEWAY_TOKEN: inst.gatewayToken,
CLAWDBOT_GATEWAY_PASSWORD: "",
},
)) as NodeListPayload;
const match = list.nodes?.find((n) => n.nodeId === nodeId);
if (match?.connected && match?.paired) return;
await sleep(50); await sleep(50);
} }
throw new Error(`timeout waiting for node status for ${nodeId}`); throw new Error(`timeout waiting for pairing request for ${nodeId}`);
};
const pairNode = async (inst: GatewayInstance, nodeId: string) => {
const socket = net.connect({ host: "127.0.0.1", port: inst.bridgePort });
await new Promise<void>((resolve, reject) => {
socket.once("connect", resolve);
socket.once("error", reject);
});
const readLine = createLineReader(socket);
sendLine(socket, {
type: "pair-request",
nodeId,
platform: "ios",
version: "1.0.0",
});
const baseDir = inst.stateDir;
const requestId = await waitForPairRequest(baseDir, nodeId);
const approved = await approveNodePairing(requestId, baseDir);
expect(approved).toBeTruthy();
const pairLine = JSON.parse(await readLineWithTimeout(readLine, `pair-ok (${nodeId})`)) as {
type?: string;
token?: string;
};
expect(pairLine.type).toBe("pair-ok");
expect(pairLine.token).toBeTruthy();
const helloLine = JSON.parse(await readLineWithTimeout(readLine, `hello-ok (${nodeId})`)) as {
type?: string;
};
expect(helloLine.type).toBe("hello-ok");
return socket;
}; };
describe("gateway multi-instance e2e", () => { describe("gateway multi-instance e2e", () => {
const instances: GatewayInstance[] = []; const instances: GatewayInstance[] = [];
const nodeClients: GatewayClient[] = [];
afterAll(async () => { afterAll(async () => {
for (const client of nodeClients) {
client.stop();
}
for (const inst of instances) { for (const inst of instances) {
await stopGatewayInstance(inst); await stopGatewayInstance(inst);
} }
@@ -398,14 +421,32 @@ describe("gateway multi-instance e2e", () => {
expect(hookResB.status).toBe(200); expect(hookResB.status).toBe(200);
expect((hookResB.json as { ok?: boolean } | undefined)?.ok).toBe(true); expect((hookResB.json as { ok?: boolean } | undefined)?.ok).toBe(true);
const nodeA = await connectNode(gwA, "node-a"); const nodeASocket = await pairNode(gwA, "node-a");
const nodeB = await connectNode(gwB, "node-b"); const nodeBSocket = await pairNode(gwB, "node-b");
nodeClients.push(nodeA.client, nodeB.client);
await Promise.all([ const [nodeListA, nodeListB] = (await Promise.all([
waitForNodeStatus(gwA, nodeA.nodeId), runCliJson(["nodes", "status", "--json", "--url", `ws://127.0.0.1:${gwA.port}`], {
waitForNodeStatus(gwB, nodeB.nodeId), CLAWDBOT_GATEWAY_TOKEN: gwA.gatewayToken,
]); CLAWDBOT_GATEWAY_PASSWORD: "",
}),
runCliJson(["nodes", "status", "--json", "--url", `ws://127.0.0.1:${gwB.port}`], {
CLAWDBOT_GATEWAY_TOKEN: gwB.gatewayToken,
CLAWDBOT_GATEWAY_PASSWORD: "",
}),
])) as [NodeListPayload, NodeListPayload];
expect(
nodeListA.nodes?.some(
(n) => n.nodeId === "node-a" && n.connected === true && n.paired === true,
),
).toBe(true);
expect(
nodeListB.nodes?.some(
(n) => n.nodeId === "node-b" && n.connected === true && n.paired === true,
),
).toBe(true);
nodeASocket.destroy();
nodeBSocket.destroy();
}, },
); );
}); });
-2
View File
@@ -43,7 +43,6 @@ export async function patchSession(
state: SessionsState, state: SessionsState,
key: string, key: string,
patch: { patch: {
label?: string | null;
thinkingLevel?: string | null; thinkingLevel?: string | null;
verboseLevel?: string | null; verboseLevel?: string | null;
reasoningLevel?: string | null; reasoningLevel?: string | null;
@@ -51,7 +50,6 @@ export async function patchSession(
) { ) {
if (!state.client || !state.connected) return; if (!state.client || !state.connected) return;
const params: Record<string, unknown> = { key }; const params: Record<string, unknown> = { key };
if ("label" in patch) params.label = patch.label;
if ("thinkingLevel" in patch) params.thinkingLevel = patch.thinkingLevel; if ("thinkingLevel" in patch) params.thinkingLevel = patch.thinkingLevel;
if ("verboseLevel" in patch) params.verboseLevel = patch.verboseLevel; if ("verboseLevel" in patch) params.verboseLevel = patch.verboseLevel;
if ("reasoningLevel" in patch) params.reasoningLevel = patch.reasoningLevel; if ("reasoningLevel" in patch) params.reasoningLevel = patch.reasoningLevel;
-93
View File
@@ -1,93 +0,0 @@
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import type { GatewaySessionRow, SessionsListResult } from "../types";
import { renderSessions, type SessionsProps } from "./sessions";
function createRow(overrides: Partial<GatewaySessionRow> = {}): GatewaySessionRow {
return {
key: "session-1",
kind: "direct",
updatedAt: 0,
...overrides,
};
}
function createResult(rows: GatewaySessionRow[]): SessionsListResult {
return {
ts: 0,
path: "/sessions",
count: rows.length,
defaults: {
model: null,
contextTokens: null,
},
sessions: rows,
};
}
function createProps(overrides: Partial<SessionsProps> = {}): SessionsProps {
return {
loading: false,
result: createResult([]),
error: null,
activeMinutes: "",
limit: "",
includeGlobal: true,
includeUnknown: true,
basePath: "/",
onFiltersChange: () => undefined,
onRefresh: () => undefined,
onPatch: () => undefined,
onDelete: () => undefined,
...overrides,
};
}
describe("sessions view", () => {
it("skips patching when the label is unchanged", () => {
const container = document.createElement("div");
const onPatch = vi.fn();
const row = createRow({ label: "Alpha" });
render(
renderSessions(
createProps({
result: createResult([row]),
onPatch,
}),
),
container,
);
const input = container.querySelector("input") as HTMLInputElement | null;
expect(input).not.toBeNull();
input?.dispatchEvent(new Event("change", { bubbles: true }));
expect(onPatch).not.toHaveBeenCalled();
});
it("clears labels when the input is empty", () => {
const container = document.createElement("div");
const onPatch = vi.fn();
const row = createRow({ label: "Alpha" });
render(
renderSessions(
createProps({
result: createResult([row]),
onPatch,
}),
),
container,
);
const input = container.querySelector("input") as HTMLInputElement | null;
expect(input).not.toBeNull();
if (!input) return;
input.value = " ";
input.dispatchEvent(new Event("change", { bubbles: true }));
expect(onPatch).toHaveBeenCalledWith("session-1", { label: null });
});
});
+1 -14
View File
@@ -24,7 +24,6 @@ export type SessionsProps = {
onPatch: ( onPatch: (
key: string, key: string,
patch: { patch: {
label?: string | null;
thinkingLevel?: string | null; thinkingLevel?: string | null;
verboseLevel?: string | null; verboseLevel?: string | null;
reasoningLevel?: string | null; reasoningLevel?: string | null;
@@ -196,19 +195,7 @@ function renderRow(
<div class="mono">${canLink <div class="mono">${canLink
? html`<a href=${chatUrl} class="session-link">${displayName}</a>` ? html`<a href=${chatUrl} class="session-link">${displayName}</a>`
: displayName}</div> : displayName}</div>
<div> <div>${row.label ?? ""}</div>
<input
.value=${row.label ?? ""}
?disabled=${disabled}
placeholder="(optional)"
@change=${(e: Event) => {
const value = (e.target as HTMLInputElement).value.trim();
const current = (row.label ?? "").trim();
if (value === current) return;
onPatch(row.key, { label: value || null });
}}
/>
</div>
<div>${row.kind}</div> <div>${row.kind}</div>
<div>${updated}</div> <div>${updated}</div>
<div>${formatSessionTokens(row)}</div> <div>${formatSessionTokens(row)}</div>
+1 -2
View File
@@ -4,7 +4,6 @@ import { defineConfig } from "vitest/config";
const repoRoot = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.dirname(fileURLToPath(import.meta.url));
const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true"; const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true";
const localWorkers = 4;
export default defineConfig({ export default defineConfig({
resolve: { resolve: {
@@ -16,7 +15,7 @@ export default defineConfig({
testTimeout: 60_000, testTimeout: 60_000,
hookTimeout: 120_000, hookTimeout: 120_000,
pool: "forks", pool: "forks",
maxWorkers: isCI ? 3 : localWorkers, maxWorkers: isCI ? 3 : 4,
include: [ include: [
"src/**/*.test.ts", "src/**/*.test.ts",
"extensions/**/*.test.ts", "extensions/**/*.test.ts",
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff. Show more