Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99546ca3d9 | ||
|
|
26a5d02e69 | ||
|
|
48f733e4b3 | ||
|
|
e5f7435d9f | ||
|
|
322c8dc4fc | ||
|
|
cf04b0e3bf | ||
|
|
0f9f510dd9 | ||
|
|
e110cf4fb1 | ||
|
|
6942ceb7a9 | ||
|
|
47cf28f6b6 | ||
|
|
115b4379bf | ||
|
|
a6db1edee3 | ||
|
|
4ed1b7c7ed | ||
|
|
7ef7b94bc0 | ||
|
|
da7da30b22 | ||
|
|
213d9b47b0 | ||
|
|
d88b239d3c | ||
|
|
1c02de1309 | ||
|
|
d3c2b83f88 | ||
|
|
93f80894a3 | ||
|
|
c440cc2f84 | ||
|
|
2439c31844 | ||
|
|
292f21ae78 | ||
|
|
9c2c4b1138 | ||
|
|
e0e33e12d1 | ||
|
|
eb5145c5d1 | ||
|
|
aedf4ce328 | ||
|
|
94af5a72fc | ||
|
|
9dbc1435a6 | ||
|
|
32a668e4d9 | ||
|
|
c17c7b4e24 | ||
|
|
c5e732951b | ||
|
|
069b50635b | ||
|
|
c3a34408f3 | ||
|
|
9f280454ba |
No files matched your search
+8
-1
@@ -6,12 +6,19 @@ Docs: https://docs.clawd.bot
|
||||
|
||||
### Changes
|
||||
- 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
|
||||
- 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).
|
||||
- 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.
|
||||
- CLI: avoid duplicating --profile/--dev flags when formatting commands.
|
||||
- Agents: enforce tool policy/sandbox rules for tool-dispatched skill commands and forward tool media outputs. (#1235) — thanks @dougvk.
|
||||
- Auth: dedupe codex-cli profiles when tokens match custom openai-codex entries. (#1264) — thanks @odrobnik.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -471,24 +471,25 @@ Thanks to all clawtributors:
|
||||
|
||||
<p align="left">
|
||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a>
|
||||
<a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a>
|
||||
<a href="https://github.com/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/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a>
|
||||
<a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a>
|
||||
<a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a>
|
||||
<a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a>
|
||||
<a href="https://github.com/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/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/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/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/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/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a>
|
||||
<a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/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/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/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/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/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/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/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/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/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a>
|
||||
<a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/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/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a>
|
||||
<a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/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/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/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/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/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/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a>
|
||||
<a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a>
|
||||
<a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/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/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/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a>
|
||||
<a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a>
|
||||
<a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a>
|
||||
<a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a>
|
||||
<a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a>
|
||||
<a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a>
|
||||
<a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a>
|
||||
<a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
</p>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,66 @@
|
||||
---
|
||||
summary: "CLI reference for `clawdbot devices` (device pairing + token rotation/revocation)"
|
||||
read_when:
|
||||
- You are approving device pairing requests
|
||||
- You need to rotate or revoke device tokens
|
||||
---
|
||||
|
||||
# `clawdbot devices`
|
||||
|
||||
Manage device pairing requests and device-scoped tokens.
|
||||
|
||||
## Commands
|
||||
|
||||
### `clawdbot devices list`
|
||||
|
||||
List pending pairing requests and paired devices.
|
||||
|
||||
```
|
||||
clawdbot devices list
|
||||
clawdbot devices list --json
|
||||
```
|
||||
|
||||
### `clawdbot devices approve <requestId>`
|
||||
|
||||
Approve a pending device pairing request.
|
||||
|
||||
```
|
||||
clawdbot devices approve <requestId>
|
||||
```
|
||||
|
||||
### `clawdbot devices reject <requestId>`
|
||||
|
||||
Reject a pending device pairing request.
|
||||
|
||||
```
|
||||
clawdbot devices reject <requestId>
|
||||
```
|
||||
|
||||
### `clawdbot devices rotate --device <id> --role <role> [--scope <scope...>]`
|
||||
|
||||
Rotate a device token for a specific role (optionally updating scopes).
|
||||
|
||||
```
|
||||
clawdbot devices rotate --device <deviceId> --role operator --scope operator.read --scope operator.write
|
||||
```
|
||||
|
||||
### `clawdbot devices revoke --device <id> --role <role>`
|
||||
|
||||
Revoke a device token for a specific role.
|
||||
|
||||
```
|
||||
clawdbot devices revoke --device <deviceId> --role node
|
||||
```
|
||||
|
||||
## Common options
|
||||
|
||||
- `--url <url>`: Gateway WebSocket URL (defaults to `gateway.remote.url` when configured).
|
||||
- `--token <token>`: Gateway token (if required).
|
||||
- `--password <password>`: Gateway password (password auth).
|
||||
- `--timeout <ms>`: RPC timeout.
|
||||
- `--json`: JSON output (recommended for scripting).
|
||||
|
||||
## Notes
|
||||
|
||||
- Token rotation returns a new token (sensitive). Treat it like a secret.
|
||||
- These commands require `operator.pairing` (or `operator.admin`) scope.
|
||||
+3
-2
@@ -116,17 +116,18 @@ clawdbot gateway call logs.tail --params '{"sinceMs": 60000}'
|
||||
|
||||
## Discover gateways (Bonjour)
|
||||
|
||||
`gateway discover` scans for Gateway bridge beacons (`_clawdbot-bridge._tcp`).
|
||||
`gateway discover` scans for Gateway beacons (`_clawdbot-gateway._tcp`).
|
||||
|
||||
- Multicast DNS-SD: `local.`
|
||||
- Unicast DNS-SD (Wide-Area Bonjour): `clawdbot.internal.` (requires split DNS + DNS server; see [/gateway/bonjour](/gateway/bonjour))
|
||||
|
||||
Only gateways with the **bridge enabled** will advertise the discovery beacon.
|
||||
Only gateways with Bonjour discovery enabled (default) advertise the beacon.
|
||||
|
||||
Wide-Area discovery records include (TXT):
|
||||
- `gatewayPort` (WebSocket port, usually `18789`)
|
||||
- `sshPort` (SSH port; defaults to `22` if not present)
|
||||
- `tailnetDns` (MagicDNS hostname, when available)
|
||||
- `gatewayTls` / `gatewayTlsSha256` (TLS enabled + cert fingerprint)
|
||||
- `cliPath` (optional hint for remote installs)
|
||||
|
||||
### `gateway discover`
|
||||
|
||||
@@ -34,6 +34,7 @@ This page describes the current CLI behavior. If commands change, update this do
|
||||
- [`models`](/cli/models)
|
||||
- [`memory`](/cli/memory)
|
||||
- [`nodes`](/cli/nodes)
|
||||
- [`devices`](/cli/devices)
|
||||
- [`node`](/cli/node)
|
||||
- [`approvals`](/cli/approvals)
|
||||
- [`sandbox`](/cli/sandbox)
|
||||
@@ -188,6 +189,7 @@ clawdbot [--dev] [--profile <name>] <command>
|
||||
runs
|
||||
run
|
||||
nodes
|
||||
devices
|
||||
node
|
||||
start
|
||||
daemon
|
||||
|
||||
@@ -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.
|
||||
|
||||
## How it works
|
||||
- A lane-aware FIFO queue drains each lane with a configurable concurrency cap (default 1).
|
||||
- A lane-aware FIFO queue drains each lane with a configurable concurrency cap (default 1 for unconfigured lanes; main defaults to 4, subagent to 8).
|
||||
- `runEmbeddedPiAgent` enqueues by **session key** (lane `session:<key>`) to guarantee only one active run per session.
|
||||
- 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.
|
||||
|
||||
@@ -1840,7 +1840,7 @@ Example:
|
||||
|
||||
`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.
|
||||
- `maxConcurrent`: max concurrent sub-agent runs (default 1)
|
||||
- `maxConcurrent`: max concurrent sub-agent runs (default 8)
|
||||
- `archiveAfterMinutes`: auto-archive sub-agent sessions after N minutes (default 60; set `0` to disable)
|
||||
- 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
|
||||
execute in parallel across sessions. Each session is still serialized (one run
|
||||
per session key at a time). Default: 1.
|
||||
per session key at a time). Default: 4.
|
||||
|
||||
### `agents.defaults.sandbox`
|
||||
|
||||
@@ -2677,7 +2677,7 @@ Notes:
|
||||
|
||||
Auth and Tailscale:
|
||||
- `gateway.auth.mode` sets the handshake requirements (`token` or `password`).
|
||||
- `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine).
|
||||
- `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine and as the bootstrap credential for device pairing).
|
||||
- When `gateway.auth.mode` is set, only that method is accepted (plus optional Tailscale headers).
|
||||
- `gateway.auth.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended).
|
||||
- `gateway.auth.allowTailscale` allows Tailscale Serve identity headers
|
||||
@@ -2686,6 +2686,9 @@ Auth and Tailscale:
|
||||
`true`, Serve requests do not need a token/password; set `false` to require
|
||||
explicit credentials. Defaults to `true` when `tailscale.mode = "serve"` and
|
||||
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: "funnel"` exposes the dashboard publicly; requires auth.
|
||||
- `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown.
|
||||
@@ -2713,6 +2716,29 @@ macOS app behavior:
|
||||
}
|
||||
```
|
||||
|
||||
### `gateway.nodes` (Node command allowlist)
|
||||
|
||||
The Gateway enforces a per-platform command allowlist for `node.invoke`. Nodes must both
|
||||
**declare** a command and have it **allowed** by the Gateway to run it.
|
||||
|
||||
Use this section to extend or deny commands:
|
||||
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
nodes: {
|
||||
allowCommands: ["custom.vendor.command"], // extra commands beyond defaults
|
||||
denyCommands: ["sms.send"] // block a command even if declared
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `allowCommands` extends the built-in per-platform defaults.
|
||||
- `denyCommands` always wins (even if the node claims the command).
|
||||
- `node.invoke` rejects commands that are not declared by the node.
|
||||
|
||||
### `gateway.reload` (Config hot reload)
|
||||
|
||||
The Gateway watches `~/.clawdbot/clawdbot.json` (or `CLAWDBOT_CONFIG_PATH`) and applies changes automatically.
|
||||
|
||||
@@ -59,6 +59,18 @@ Gateway → Client:
|
||||
}
|
||||
```
|
||||
|
||||
When a device token is issued, `hello-ok` also includes:
|
||||
|
||||
```json
|
||||
{
|
||||
"auth": {
|
||||
"deviceToken": "…",
|
||||
"role": "operator",
|
||||
"scopes": ["operator.read", "operator.write"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Node example
|
||||
|
||||
```json
|
||||
@@ -123,6 +135,11 @@ Nodes declare capability claims at connect time:
|
||||
|
||||
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
|
||||
|
||||
- `PROTOCOL_VERSION` lives in `src/gateway/protocol/schema.ts`.
|
||||
@@ -136,6 +153,11 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
|
||||
|
||||
- If `CLAWDBOT_GATEWAY_TOKEN` (or `--token`) is set, `connect.params.auth.token`
|
||||
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
|
||||
|
||||
@@ -144,6 +166,7 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
|
||||
- Gateways issue tokens per device + role.
|
||||
- Pairing approvals are required for new device IDs unless local auto-approval
|
||||
is enabled.
|
||||
- All WS clients must include `device` identity during `connect` (operator + node).
|
||||
|
||||
## TLS + pinning
|
||||
|
||||
|
||||
@@ -235,6 +235,12 @@ Triggered when the gateway starts:
|
||||
|
||||
- **`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
|
||||
|
||||
Planned event types:
|
||||
|
||||
@@ -323,6 +323,22 @@ These are “real pipeline” regressions without real providers:
|
||||
- Gateway tool calling (mock OpenAI, real gateway + agent loop): `src/gateway/gateway.tool-calling.mock-openai.test.ts`
|
||||
- Gateway 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)
|
||||
|
||||
When you fix a provider/model issue discovered in live:
|
||||
|
||||
@@ -88,8 +88,6 @@ Notes:
|
||||
|
||||
The tool is invoked with params:
|
||||
`{ 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)
|
||||
|
||||
|
||||
@@ -104,7 +104,6 @@ Notes:
|
||||
- **Skill commands:** `user-invocable` skills are exposed as slash commands. Names are sanitized to `a-z0-9_` (max 32 chars); collisions get numeric suffixes (e.g. `_2`).
|
||||
- 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).
|
||||
- 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.
|
||||
|
||||
## Usage surfaces (what shows where)
|
||||
|
||||
@@ -118,7 +118,7 @@ Override via config:
|
||||
|
||||
Sub-agents use a dedicated in-process queue lane:
|
||||
- Lane name: `subagent`
|
||||
- Concurrency: `agents.defaults.subagents.maxConcurrent` (default `1`)
|
||||
- Concurrency: `agents.defaults.subagents.maxConcurrent` (default `8`)
|
||||
|
||||
## Stopping
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"ensureLogins": [
|
||||
"odrobnik",
|
||||
"alphonse-arianee",
|
||||
"ronak-guliani",
|
||||
"cpojer",
|
||||
|
||||
@@ -3,7 +3,8 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ensureAuthProfileStore } from "./auth-profiles.js";
|
||||
import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js";
|
||||
import { AUTH_STORE_VERSION, CODEX_CLI_PROFILE_ID } from "./auth-profiles/constants.js";
|
||||
import { withTempHome } from "../../test/helpers/temp-home.js";
|
||||
|
||||
describe("ensureAuthProfileStore", () => {
|
||||
it("migrates legacy auth.json and deletes it (PR #368)", () => {
|
||||
@@ -122,4 +123,80 @@ describe("ensureAuthProfileStore", () => {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withTempHome } from "../../test/helpers/temp-home.js";
|
||||
import { CODEX_CLI_PROFILE_ID, ensureAuthProfileStore } from "./auth-profiles.js";
|
||||
|
||||
describe("external CLI credential sync", () => {
|
||||
it("skips codex-cli sync when credentials already exist in another openai-codex profile", async () => {
|
||||
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-codex-dedup-skip-"));
|
||||
try {
|
||||
await withTempHome(
|
||||
async (tempHome) => {
|
||||
const codexDir = path.join(tempHome, ".codex");
|
||||
fs.mkdirSync(codexDir, { recursive: true });
|
||||
const codexAuthPath = path.join(codexDir, "auth.json");
|
||||
fs.writeFileSync(
|
||||
codexAuthPath,
|
||||
JSON.stringify({
|
||||
tokens: {
|
||||
access_token: "shared-access-token",
|
||||
refresh_token: "shared-refresh-token",
|
||||
},
|
||||
}),
|
||||
);
|
||||
fs.utimesSync(codexAuthPath, new Date(), new Date());
|
||||
|
||||
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||
fs.writeFileSync(
|
||||
authPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai-codex:my-custom-profile": {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "shared-access-token",
|
||||
refresh: "shared-refresh-token",
|
||||
expires: Date.now() + 3600000,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const store = ensureAuthProfileStore(agentDir);
|
||||
|
||||
expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeUndefined();
|
||||
expect(store.profiles["openai-codex:my-custom-profile"]).toBeDefined();
|
||||
},
|
||||
{ prefix: "clawdbot-home-" },
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("creates codex-cli profile when credentials differ from existing openai-codex profiles", async () => {
|
||||
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-codex-dedup-create-"));
|
||||
try {
|
||||
await withTempHome(
|
||||
async (tempHome) => {
|
||||
const codexDir = path.join(tempHome, ".codex");
|
||||
fs.mkdirSync(codexDir, { recursive: true });
|
||||
const codexAuthPath = path.join(codexDir, "auth.json");
|
||||
fs.writeFileSync(
|
||||
codexAuthPath,
|
||||
JSON.stringify({
|
||||
tokens: {
|
||||
access_token: "unique-access-token",
|
||||
refresh_token: "unique-refresh-token",
|
||||
},
|
||||
}),
|
||||
);
|
||||
fs.utimesSync(codexAuthPath, new Date(), new Date());
|
||||
|
||||
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||
fs.writeFileSync(
|
||||
authPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai-codex:my-custom-profile": {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "different-access-token",
|
||||
refresh: "different-refresh-token",
|
||||
expires: Date.now() + 3600000,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const store = ensureAuthProfileStore(agentDir);
|
||||
|
||||
expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeDefined();
|
||||
expect((store.profiles[CODEX_CLI_PROFILE_ID] as { access: string }).access).toBe(
|
||||
"unique-access-token",
|
||||
);
|
||||
expect(store.profiles["openai-codex:my-custom-profile"]).toBeDefined();
|
||||
},
|
||||
{ prefix: "clawdbot-home-" },
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("removes codex-cli profile when it duplicates another openai-codex profile", async () => {
|
||||
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-codex-dedup-remove-"));
|
||||
try {
|
||||
await withTempHome(
|
||||
async (tempHome) => {
|
||||
const codexDir = path.join(tempHome, ".codex");
|
||||
fs.mkdirSync(codexDir, { recursive: true });
|
||||
const codexAuthPath = path.join(codexDir, "auth.json");
|
||||
fs.writeFileSync(
|
||||
codexAuthPath,
|
||||
JSON.stringify({
|
||||
tokens: {
|
||||
access_token: "shared-access-token",
|
||||
refresh_token: "shared-refresh-token",
|
||||
},
|
||||
}),
|
||||
);
|
||||
fs.utimesSync(codexAuthPath, new Date(), new Date());
|
||||
|
||||
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||
fs.writeFileSync(
|
||||
authPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
profiles: {
|
||||
[CODEX_CLI_PROFILE_ID]: {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "shared-access-token",
|
||||
refresh: "shared-refresh-token",
|
||||
expires: Date.now() + 3600000,
|
||||
},
|
||||
"openai-codex:my-custom-profile": {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "shared-access-token",
|
||||
refresh: "shared-refresh-token",
|
||||
expires: Date.now() + 3600000,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const store = ensureAuthProfileStore(agentDir);
|
||||
|
||||
expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeUndefined();
|
||||
const saved = JSON.parse(fs.readFileSync(authPath, "utf8")) as {
|
||||
profiles?: Record<string, unknown>;
|
||||
};
|
||||
expect(saved.profiles?.[CODEX_CLI_PROFILE_ID]).toBeUndefined();
|
||||
expect(saved.profiles?.["openai-codex:my-custom-profile"]).toBeDefined();
|
||||
},
|
||||
{ prefix: "clawdbot-home-" },
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,26 @@ function isExternalProfileFresh(cred: AuthProfileCredential | undefined, now: nu
|
||||
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.
|
||||
* This allows clawdbot to use the same credentials as these tools without requiring
|
||||
@@ -143,31 +163,55 @@ export function syncExternalCliCredentials(
|
||||
|
||||
// Sync from Codex CLI
|
||||
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 =
|
||||
!existingCodex ||
|
||||
existingCodex.provider !== "openai-codex" ||
|
||||
!isExternalProfileFresh(existingCodex, now);
|
||||
const codexCreds = shouldSyncCodex
|
||||
const codexCreds = shouldSyncCodex || duplicateExistingId
|
||||
? readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS })
|
||||
: null;
|
||||
if (codexCreds) {
|
||||
const existing = store.profiles[CODEX_CLI_PROFILE_ID];
|
||||
const existingOAuth = existing?.type === "oauth" ? existing : undefined;
|
||||
const duplicateProfileId = findDuplicateCodexProfile(store, codexCreds);
|
||||
if (duplicateProfileId) {
|
||||
if (store.profiles[CODEX_CLI_PROFILE_ID]) {
|
||||
delete store.profiles[CODEX_CLI_PROFILE_ID];
|
||||
mutated = true;
|
||||
log.info("removed codex-cli profile: credentials already exist in another profile", {
|
||||
existingProfileId: duplicateProfileId,
|
||||
removedProfileId: CODEX_CLI_PROFILE_ID,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const existing = store.profiles[CODEX_CLI_PROFILE_ID];
|
||||
const existingOAuth = existing?.type === "oauth" ? existing : undefined;
|
||||
|
||||
// Codex creds don't carry expiry; use file mtime heuristic for freshness.
|
||||
const shouldUpdate =
|
||||
!existingOAuth ||
|
||||
existingOAuth.provider !== "openai-codex" ||
|
||||
existingOAuth.expires <= now ||
|
||||
codexCreds.expires > existingOAuth.expires;
|
||||
// Codex creds don't carry expiry; use file mtime heuristic for freshness.
|
||||
const shouldUpdate =
|
||||
!existingOAuth ||
|
||||
existingOAuth.provider !== "openai-codex" ||
|
||||
existingOAuth.expires <= now ||
|
||||
codexCreds.expires > existingOAuth.expires;
|
||||
|
||||
if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, codexCreds)) {
|
||||
store.profiles[CODEX_CLI_PROFILE_ID] = codexCreds;
|
||||
mutated = true;
|
||||
log.info("synced openai-codex credentials from codex cli", {
|
||||
profileId: CODEX_CLI_PROFILE_ID,
|
||||
expires: new Date(codexCreds.expires).toISOString(),
|
||||
});
|
||||
if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, codexCreds)) {
|
||||
store.profiles[CODEX_CLI_PROFILE_ID] = codexCreds;
|
||||
mutated = true;
|
||||
log.info("synced openai-codex credentials from codex cli", {
|
||||
profileId: CODEX_CLI_PROFILE_ID,
|
||||
expires: new Date(codexCreds.expires).toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,13 @@ import type { OAuthCredentials } from "@mariozechner/pi-ai";
|
||||
import lockfile from "proper-lockfile";
|
||||
import { resolveOAuthPath } from "../../config/paths.js";
|
||||
import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js";
|
||||
import { AUTH_STORE_LOCK_OPTIONS, AUTH_STORE_VERSION, log } from "./constants.js";
|
||||
import { syncExternalCliCredentials } from "./external-cli-sync.js";
|
||||
import {
|
||||
AUTH_STORE_LOCK_OPTIONS,
|
||||
AUTH_STORE_VERSION,
|
||||
CODEX_CLI_PROFILE_ID,
|
||||
log,
|
||||
} from "./constants.js";
|
||||
import { findDuplicateCodexProfile, syncExternalCliCredentials } from "./external-cli-sync.js";
|
||||
import { ensureAuthStoreFile, resolveAuthStorePath, resolveLegacyAuthStorePath } from "./paths.js";
|
||||
import type { AuthProfileCredential, AuthProfileStore, ProfileUsageStats } from "./types.js";
|
||||
|
||||
@@ -330,7 +335,18 @@ export function ensureAuthProfileStore(
|
||||
}
|
||||
|
||||
const mainStore = loadAuthProfileStoreForAgent(undefined, options);
|
||||
return mergeAuthProfileStores(mainStore, store);
|
||||
const merged = mergeAuthProfileStores(mainStore, store);
|
||||
|
||||
// Keep per-agent view clean even if the main store has codex-cli.
|
||||
const codexProfile = merged.profiles[CODEX_CLI_PROFILE_ID];
|
||||
if (codexProfile?.type === "oauth") {
|
||||
const duplicateId = findDuplicateCodexProfile(merged, codexProfile);
|
||||
if (duplicateId) {
|
||||
delete merged.profiles[CODEX_CLI_PROFILE_ID];
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function saveAuthProfileStore(store: AuthProfileStore, agentDir?: string): void {
|
||||
|
||||
+4
-2
@@ -1,2 +1,4 @@
|
||||
export const AGENT_LANE_NESTED = "nested" as const;
|
||||
export const AGENT_LANE_SUBAGENT = "subagent" as const;
|
||||
import { CommandLane } from "../process/lanes.js";
|
||||
|
||||
export const AGENT_LANE_NESTED = CommandLane.Nested;
|
||||
export const AGENT_LANE_SUBAGENT = CommandLane.Subagent;
|
||||
@@ -19,6 +19,15 @@ describe("isAuthErrorMessage", () => {
|
||||
expect(isAuthErrorMessage(sample)).toBe(true);
|
||||
}
|
||||
});
|
||||
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", () => {
|
||||
expect(isAuthErrorMessage("rate limit exceeded")).toBe(false);
|
||||
expect(isAuthErrorMessage("billing issue detected")).toBe(false);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isLikelyContextOverflowError } from "./pi-embedded-helpers.js";
|
||||
import { DEFAULT_AGENTS_FILENAME } from "./workspace.js";
|
||||
|
||||
const _makeFile = (overrides: Partial<WorkspaceBootstrapFile>): WorkspaceBootstrapFile => ({
|
||||
name: DEFAULT_AGENTS_FILENAME,
|
||||
path: "/tmp/AGENTS.md",
|
||||
content: "",
|
||||
missing: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("isLikelyContextOverflowError", () => {
|
||||
it("matches context overflow hints", () => {
|
||||
const samples = [
|
||||
"Model context window is 128k tokens, you requested 256k tokens",
|
||||
"Context window exceeded: requested 12000 tokens",
|
||||
"Prompt too large for this model",
|
||||
];
|
||||
for (const sample of samples) {
|
||||
expect(isLikelyContextOverflowError(sample)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("excludes context window too small errors", () => {
|
||||
const samples = [
|
||||
"Model context window too small (minimum is 128k tokens)",
|
||||
"Context window too small: minimum is 1000 tokens",
|
||||
];
|
||||
for (const sample of samples) {
|
||||
expect(isLikelyContextOverflowError(sample)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ export {
|
||||
isCloudCodeAssistFormatError,
|
||||
isCompactionFailureError,
|
||||
isContextOverflowError,
|
||||
isLikelyContextOverflowError,
|
||||
isFailoverAssistantError,
|
||||
isFailoverErrorMessage,
|
||||
isImageDimensionErrorMessage,
|
||||
|
||||
@@ -18,6 +18,17 @@ export function isContextOverflowError(errorMessage?: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
const CONTEXT_WINDOW_TOO_SMALL_RE = /context window.*(too small|minimum is)/i;
|
||||
const CONTEXT_OVERFLOW_HINT_RE =
|
||||
/context.*overflow|context window.*(too (?:large|long)|exceed|over|limit|max(?:imum)?|requested|sent|tokens)|(?:prompt|request|input).*(too (?:large|long)|exceed|over|limit|max(?:imum)?)/i;
|
||||
|
||||
export function isLikelyContextOverflowError(errorMessage?: string): boolean {
|
||||
if (!errorMessage) return false;
|
||||
if (CONTEXT_WINDOW_TOO_SMALL_RE.test(errorMessage)) return false;
|
||||
if (isContextOverflowError(errorMessage)) return true;
|
||||
return CONTEXT_OVERFLOW_HINT_RE.test(errorMessage);
|
||||
}
|
||||
|
||||
export function isCompactionFailureError(errorMessage?: string): boolean {
|
||||
if (!errorMessage) return false;
|
||||
if (!isContextOverflowError(errorMessage)) return false;
|
||||
@@ -328,6 +339,8 @@ const ERROR_PATTERNS = {
|
||||
"incorrect api key",
|
||||
"invalid token",
|
||||
"authentication",
|
||||
"re-authenticate",
|
||||
"oauth token refresh failed",
|
||||
"unauthorized",
|
||||
"forbidden",
|
||||
"access denied",
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { CommandLane } from "../../process/lanes.js";
|
||||
|
||||
export function resolveSessionLane(key: string) {
|
||||
const cleaned = key.trim() || "main";
|
||||
const cleaned = key.trim() || CommandLane.Main;
|
||||
return cleaned.startsWith("session:") ? cleaned : `session:${cleaned}`;
|
||||
}
|
||||
|
||||
export function resolveGlobalLane(lane?: string) {
|
||||
const cleaned = lane?.trim();
|
||||
return cleaned ? cleaned : "main";
|
||||
return cleaned ? cleaned : CommandLane.Main;
|
||||
}
|
||||
|
||||
export function resolveEmbeddedSessionLane(key: string) {
|
||||
|
||||
@@ -24,6 +24,11 @@ export function handleAutoCompactionStart(ctx: EmbeddedPiSubscribeContext) {
|
||||
ctx.state.compactionInFlight = true;
|
||||
ctx.ensureCompactionPromise();
|
||||
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?.({
|
||||
stream: "compaction",
|
||||
data: { phase: "start" },
|
||||
@@ -43,6 +48,11 @@ export function handleAutoCompactionEnd(
|
||||
} else {
|
||||
ctx.maybeResolveCompactionWait();
|
||||
}
|
||||
emitAgentEvent({
|
||||
runId: ctx.params.runId,
|
||||
stream: "compaction",
|
||||
data: { phase: "end", willRetry },
|
||||
});
|
||||
void ctx.params.onAgentEvent?.({
|
||||
stream: "compaction",
|
||||
data: { phase: "end", willRetry },
|
||||
|
||||
@@ -109,34 +109,34 @@ export function handleMessageUpdate(
|
||||
.trim();
|
||||
if (next && next !== ctx.state.lastStreamedAssistant) {
|
||||
const previousText = ctx.state.lastStreamedAssistant ?? "";
|
||||
ctx.state.lastStreamedAssistant = next;
|
||||
const { text: cleanedText, mediaUrls } = parseReplyDirectives(next);
|
||||
const { text: previousCleanedText } = parseReplyDirectives(previousText);
|
||||
const deltaText = cleanedText.startsWith(previousCleanedText)
|
||||
? cleanedText.slice(previousCleanedText.length)
|
||||
: cleanedText;
|
||||
emitAgentEvent({
|
||||
runId: ctx.params.runId,
|
||||
stream: "assistant",
|
||||
data: {
|
||||
text: cleanedText,
|
||||
delta: deltaText,
|
||||
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,
|
||||
if (cleanedText.startsWith(previousCleanedText)) {
|
||||
const deltaText = cleanedText.slice(previousCleanedText.length);
|
||||
ctx.state.lastStreamedAssistant = next;
|
||||
emitAgentEvent({
|
||||
runId: ctx.params.runId,
|
||||
stream: "assistant",
|
||||
data: {
|
||||
text: cleanedText,
|
||||
delta: deltaText,
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+68
@@ -184,4 +184,72 @@ describe("subscribeEmbeddedPiSession", () => {
|
||||
expect(payloads[1]?.text).toBe("Hello 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"]);
|
||||
});
|
||||
});
|
||||
+39
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { onAgentEvent } from "../infra/agent-events.js";
|
||||
import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js";
|
||||
|
||||
type StubSession = {
|
||||
@@ -54,6 +55,44 @@ describe("subscribeEmbeddedPiSession", () => {
|
||||
await waitPromise;
|
||||
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 () => {
|
||||
let handler: ((evt: unknown) => void) | undefined;
|
||||
const session: StubSession = {
|
||||
|
||||
@@ -8,8 +8,6 @@ import { describe, expect, it, afterEach } from "vitest";
|
||||
|
||||
import { loadClawdbotPlugins } from "../plugins/loader.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";
|
||||
|
||||
const EMPTY_CONFIG_SCHEMA = `configSchema: {
|
||||
@@ -24,15 +22,7 @@ function writeTempPlugin(params: { dir: string; id: string; body: string }): str
|
||||
return file;
|
||||
}
|
||||
|
||||
const ORIGINAL_BUNDLED_PLUGINS_DIR = process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR;
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
|
||||
import {
|
||||
isCompactionFailureError,
|
||||
isContextOverflowError,
|
||||
isLikelyContextOverflowError,
|
||||
sanitizeUserFacingText,
|
||||
} from "../../agents/pi-embedded-helpers.js";
|
||||
import {
|
||||
@@ -419,9 +420,7 @@ export async function runAgentTurnWithFallback(params: {
|
||||
break;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const isContextOverflow =
|
||||
isContextOverflowError(message) ||
|
||||
/context.*overflow|too large|context window/i.test(message);
|
||||
const isContextOverflow = isLikelyContextOverflowError(message);
|
||||
const isCompactionFailure = isCompactionFailureError(message);
|
||||
const isSessionCorruption = /function call turn comes immediately after/i.test(message);
|
||||
const isRoleOrderingError = /incorrect role information|roles must alternate/i.test(message);
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
import type { SkillCommandSpec } from "../../agents/skills.js";
|
||||
import type { AnyAgentTool } from "../../agents/pi-tools.types.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { MsgContext, TemplateContext } from "../templating.js";
|
||||
import type { InlineDirectives } from "./directive-handling.js";
|
||||
import { parseInlineDirectives } from "./directive-handling.js";
|
||||
import type { TypingController } from "./typing.js";
|
||||
import { handleInlineActions } from "./get-reply-inline-actions.js";
|
||||
|
||||
vi.mock("../../agents/clawdbot-tools.js", () => ({
|
||||
createClawdbotTools: vi.fn(),
|
||||
}));
|
||||
|
||||
import { createClawdbotTools } from "../../agents/clawdbot-tools.js";
|
||||
|
||||
const mockedCreateClawdbotTools = vi.mocked(createClawdbotTools);
|
||||
|
||||
const createTypingController = (): TypingController => ({
|
||||
onReplyStart: vi.fn(),
|
||||
startTypingLoop: vi.fn(),
|
||||
startTypingOnText: vi.fn(),
|
||||
refreshTypingTtl: vi.fn(),
|
||||
isActive: vi.fn(() => false),
|
||||
markRunComplete: vi.fn(),
|
||||
markDispatchIdle: vi.fn(),
|
||||
cleanup: vi.fn(),
|
||||
});
|
||||
|
||||
const baseCommand = {
|
||||
surface: "slack",
|
||||
channel: "slack",
|
||||
ownerList: [],
|
||||
isAuthorizedSender: true,
|
||||
rawBodyNormalized: "/dispatch",
|
||||
commandBodyNormalized: "/dispatch",
|
||||
senderId: "user-1",
|
||||
};
|
||||
|
||||
const baseDirectives = parseInlineDirectives("/dispatch") as InlineDirectives;
|
||||
|
||||
function createParams(overrides: Partial<Parameters<typeof handleInlineActions>[0]> = {}) {
|
||||
const cfg = (overrides.cfg ??
|
||||
({
|
||||
tools: {
|
||||
allow: ["tool_allowed"],
|
||||
},
|
||||
} as ClawdbotConfig)) as ClawdbotConfig;
|
||||
return {
|
||||
ctx: {
|
||||
Surface: "slack",
|
||||
Provider: "slack",
|
||||
AccountId: "default",
|
||||
} satisfies MsgContext as MsgContext,
|
||||
sessionCtx: {
|
||||
Body: "",
|
||||
BodyForAgent: "",
|
||||
BodyStripped: "",
|
||||
} satisfies TemplateContext as TemplateContext,
|
||||
cfg,
|
||||
agentId: "main",
|
||||
agentDir: "/tmp",
|
||||
sessionEntry: undefined,
|
||||
previousSessionEntry: undefined,
|
||||
sessionStore: undefined,
|
||||
sessionKey: "main",
|
||||
storePath: undefined,
|
||||
sessionScope: "per-sender",
|
||||
workspaceDir: "/tmp",
|
||||
isGroup: false,
|
||||
opts: undefined,
|
||||
typing: createTypingController(),
|
||||
allowTextCommands: true,
|
||||
inlineStatusRequested: false,
|
||||
command: baseCommand,
|
||||
skillCommands: [],
|
||||
directives: baseDirectives,
|
||||
cleanedBody: "/dispatch",
|
||||
elevatedEnabled: false,
|
||||
elevatedAllowed: false,
|
||||
elevatedFailures: [],
|
||||
defaultActivation: () => "always",
|
||||
resolvedThinkLevel: "off",
|
||||
resolvedVerboseLevel: "off",
|
||||
resolvedReasoningLevel: "off",
|
||||
resolvedElevatedLevel: "off",
|
||||
resolveDefaultThinkingLevel: async () => undefined,
|
||||
provider: "openai",
|
||||
model: "gpt-4o-mini",
|
||||
contextTokens: 0,
|
||||
directiveAck: undefined,
|
||||
abortedLastRun: false,
|
||||
skillFilter: undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createTool(name: string, execute: AnyAgentTool["execute"]): AnyAgentTool {
|
||||
return {
|
||||
name,
|
||||
label: name,
|
||||
description: name,
|
||||
parameters: {},
|
||||
execute,
|
||||
} as AnyAgentTool;
|
||||
}
|
||||
|
||||
describe("handleInlineActions tool-dispatch", () => {
|
||||
beforeEach(() => {
|
||||
mockedCreateClawdbotTools.mockReset();
|
||||
});
|
||||
|
||||
it("returns media payloads from tool results", async () => {
|
||||
const tool = createTool("tool_allowed", async () => ({
|
||||
content: [{ type: "text", text: "Done\nMEDIA:/tmp/photo.jpg" }],
|
||||
}));
|
||||
mockedCreateClawdbotTools.mockReturnValue([tool]);
|
||||
|
||||
const skillCommands: SkillCommandSpec[] = [
|
||||
{
|
||||
name: "dispatch",
|
||||
skillName: "dispatch",
|
||||
description: "Dispatch",
|
||||
dispatch: { kind: "tool", toolName: "tool_allowed", argMode: "raw" },
|
||||
},
|
||||
];
|
||||
|
||||
const result = await handleInlineActions(
|
||||
createParams({
|
||||
command: { ...baseCommand, commandBodyNormalized: "/dispatch hi" },
|
||||
skillCommands,
|
||||
cleanedBody: "/dispatch hi",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.kind).toBe("reply");
|
||||
const reply = (result as { reply?: unknown }).reply as { text?: string; mediaUrl?: string };
|
||||
expect(reply.text).toBe("Done");
|
||||
expect(reply.mediaUrl).toBe("file:///tmp/photo.jpg");
|
||||
});
|
||||
|
||||
it("blocks tool dispatch when policy disallows the tool", async () => {
|
||||
const allowed = createTool("tool_allowed", async () => ({ content: "ok" }));
|
||||
const blocked = createTool("tool_blocked", async () => ({ content: "nope" }));
|
||||
mockedCreateClawdbotTools.mockReturnValue([allowed, blocked]);
|
||||
|
||||
const cfg = {
|
||||
tools: {
|
||||
allow: ["tool_allowed"],
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const skillCommands: SkillCommandSpec[] = [
|
||||
{
|
||||
name: "dispatch",
|
||||
skillName: "dispatch",
|
||||
description: "Dispatch",
|
||||
dispatch: { kind: "tool", toolName: "tool_blocked", argMode: "raw" },
|
||||
},
|
||||
];
|
||||
|
||||
const result = await handleInlineActions(
|
||||
createParams({
|
||||
cfg,
|
||||
skillCommands,
|
||||
command: { ...baseCommand, commandBodyNormalized: "/dispatch arg" },
|
||||
cleanedBody: "/dispatch arg",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.kind).toBe("reply");
|
||||
const reply = (result as { reply?: { text?: string; isError?: boolean } }).reply;
|
||||
expect(reply?.text).toContain("Tool blocked by policy");
|
||||
expect(reply?.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,47 +1,21 @@
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import { getChannelDock } from "../../channels/dock.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 { SessionEntry } from "../../config/sessions.js";
|
||||
import type { MsgContext, TemplateContext } from "../templating.js";
|
||||
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.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 { buildStatusReply, handleCommands } from "./commands.js";
|
||||
import type { InlineDirectives } from "./directive-handling.js";
|
||||
import { isDirectiveOnly } from "./directive-handling.js";
|
||||
import type { createModelSelectionState } from "./model-selection.js";
|
||||
import { extractInlineSimpleCommand } from "./reply-inline.js";
|
||||
import { parseReplyDirectives } from "./reply-directives.js";
|
||||
import type { TypingController } from "./typing.js";
|
||||
import { listSkillCommandsForWorkspace, resolveSkillCommandInvocation } from "../skill-commands.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { createClawdbotTools } from "../../agents/clawdbot-tools.js";
|
||||
import {
|
||||
resolveGatewayMessageChannel,
|
||||
type GatewayMessageChannel,
|
||||
} from "../../utils/message-channel.js";
|
||||
import { getPluginToolMeta } from "../../plugins/tools.js";
|
||||
import { resolveGatewayMessageChannel } from "../../utils/message-channel.js";
|
||||
|
||||
export type InlineActionResult =
|
||||
| { kind: "reply"; reply: ReplyPayload | ReplyPayload[] | undefined }
|
||||
@@ -51,20 +25,9 @@ export type InlineActionResult =
|
||||
abortedLastRun: boolean;
|
||||
};
|
||||
|
||||
function normalizeMediaUrlCandidate(raw: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
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 {
|
||||
function extractTextFromToolResult(result: any): string | null {
|
||||
if (!result || typeof result !== "object") return null;
|
||||
const content = (result as { content?: unknown }).content;
|
||||
if (typeof content === "string") {
|
||||
const trimmed = content.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
@@ -79,202 +42,9 @@ function extractTextFromToolResultContent(content: unknown): string | null {
|
||||
parts.push(rec.text);
|
||||
}
|
||||
}
|
||||
const out = parts.join("\n").trim();
|
||||
return out ? out : 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,
|
||||
};
|
||||
const out = parts.join("");
|
||||
const trimmed = out.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
export async function handleInlineActions(params: {
|
||||
@@ -393,34 +163,18 @@ export async function handleInlineActions(params: {
|
||||
resolveGatewayMessageChannel(ctx.Surface) ??
|
||||
resolveGatewayMessageChannel(ctx.Provider) ??
|
||||
undefined;
|
||||
const { allTools, allowedTools } = resolveToolDispatchTools({
|
||||
cfg,
|
||||
sessionKey,
|
||||
provider,
|
||||
model,
|
||||
agentDir,
|
||||
|
||||
const tools = createClawdbotTools({
|
||||
agentSessionKey: sessionKey,
|
||||
agentChannel: channel,
|
||||
agentAccountId: (ctx as { AccountId?: string }).AccountId,
|
||||
agentDir,
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
});
|
||||
const requestedName = normalizeToolName(dispatch.toolName);
|
||||
const findTool = (tools: AnyAgentTool[]) =>
|
||||
tools.find((candidate) => normalizeToolName(candidate.name) === requestedName);
|
||||
const tool = findTool(allowedTools);
|
||||
|
||||
const tool = tools.find((candidate) => candidate.name === dispatch.toolName);
|
||||
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();
|
||||
return { kind: "reply", reply: { text: `❌ Tool not available: ${dispatch.toolName}` } };
|
||||
}
|
||||
@@ -432,13 +186,13 @@ export async function handleInlineActions(params: {
|
||||
commandName: skillInvocation.command.name,
|
||||
skillName: skillInvocation.command.skillName,
|
||||
} as any);
|
||||
const reply = extractReplyPayloadFromToolResult(result) ?? { text: "✅ Done." };
|
||||
const text = extractTextFromToolResult(result) ?? "✅ Done.";
|
||||
typing.cleanup();
|
||||
return { kind: "reply", reply };
|
||||
return { kind: "reply", reply: { text } };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
typing.cleanup();
|
||||
return { kind: "reply", reply: { text: `❌ ${message}`, isError: true } };
|
||||
return { kind: "reply", reply: { text: `❌ ${message}` } };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
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));
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -64,7 +64,8 @@ export function renderBeaconLines(beacon: GatewayBonjourBeacon, rich: boolean):
|
||||
|
||||
const host = pickBeaconHost(beacon);
|
||||
const gatewayPort = pickGatewayPort(beacon);
|
||||
const wsUrl = host ? `ws://${host}:${gatewayPort}` : null;
|
||||
const scheme = beacon.gatewayTls ? "wss" : "ws";
|
||||
const wsUrl = host ? `${scheme}://${host}:${gatewayPort}` : null;
|
||||
|
||||
const lines = [`- ${title} ${domain}`];
|
||||
|
||||
@@ -81,6 +82,12 @@ export function renderBeaconLines(beacon: GatewayBonjourBeacon, rich: boolean):
|
||||
if (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) {
|
||||
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)}`);
|
||||
|
||||
@@ -94,6 +94,14 @@ const entries: SubCliEntry[] = [
|
||||
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",
|
||||
description: "Node control",
|
||||
|
||||
@@ -18,6 +18,7 @@ import { loadModelCatalog } from "../agents/model-catalog.js";
|
||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||
import type { ClawdbotConfig } 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 { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createPluginRuntime } from "../plugins/runtime/index.js";
|
||||
@@ -139,6 +140,43 @@ describe("agentCommand", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not duplicate agent events from embedded runs", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
mockConfig(home, store);
|
||||
|
||||
const assistantEvents: Array<{ runId: string; text?: string }> = [];
|
||||
const stop = onAgentEvent((evt) => {
|
||||
if (evt.stream !== "assistant") return;
|
||||
assistantEvents.push({
|
||||
runId: evt.runId,
|
||||
text: typeof evt.data?.text === "string" ? (evt.data.text as string) : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
vi.mocked(runEmbeddedPiAgent).mockImplementationOnce(async (params) => {
|
||||
const runId = (params as { runId?: string } | undefined)?.runId ?? "run";
|
||||
const data = { text: "hello", delta: "hello" };
|
||||
(
|
||||
params as {
|
||||
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
|
||||
}
|
||||
).onAgentEvent?.({ stream: "assistant", data });
|
||||
emitAgentEvent({ runId, stream: "assistant", data });
|
||||
return {
|
||||
payloads: [{ text: "hello" }],
|
||||
meta: { agentMeta: { provider: "p", model: "m" } },
|
||||
} as never;
|
||||
});
|
||||
|
||||
await agentCommand({ message: "hi", to: "+1555" }, runtime);
|
||||
stop();
|
||||
|
||||
const matching = assistantEvents.filter((evt) => evt.text === "hello");
|
||||
expect(matching).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("uses provider/model from agents.defaults.model.primary", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
|
||||
@@ -434,6 +434,7 @@ export async function agentCommand(
|
||||
streamParams: opts.streamParams,
|
||||
agentDir,
|
||||
onAgentEvent: (evt) => {
|
||||
// Track lifecycle end for fallback emission below.
|
||||
if (
|
||||
evt.stream === "lifecycle" &&
|
||||
typeof evt.data?.phase === "string" &&
|
||||
@@ -441,11 +442,6 @@ export async function agentCommand(
|
||||
) {
|
||||
lifecycleEnded = true;
|
||||
}
|
||||
emitAgentEvent({
|
||||
runId,
|
||||
stream: evt.stream,
|
||||
data: evt.data,
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -6,6 +6,12 @@ import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
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 { rawDataToString } from "../infra/ws.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||
@@ -58,6 +64,23 @@ async function onceMessage<T = unknown>(
|
||||
async function connectReq(params: { url: string; token?: string }) {
|
||||
const ws = new WebSocket(params.url);
|
||||
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(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
@@ -75,6 +98,7 @@ async function connectReq(params: { url: string; token?: string }) {
|
||||
},
|
||||
caps: [],
|
||||
auth: params.token ? { token: params.token } : undefined,
|
||||
device,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -6,6 +6,12 @@ import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { 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 { getFreePort as getFreeTestPort } from "../gateway/test-helpers.js";
|
||||
import { rawDataToString } from "../infra/ws.js";
|
||||
@@ -64,6 +70,23 @@ async function onceMessage<T = unknown>(
|
||||
async function connectReq(params: { url: string; token?: string }) {
|
||||
const ws = new WebSocket(params.url);
|
||||
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(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
@@ -81,6 +104,7 @@ async function connectReq(params: { url: string; token?: string }) {
|
||||
},
|
||||
caps: [],
|
||||
auth: params.token ? { token: params.token } : undefined,
|
||||
device,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { ClawdbotConfig } from "./types.js";
|
||||
|
||||
export const DEFAULT_AGENT_MAX_CONCURRENT = 4;
|
||||
export const DEFAULT_SUBAGENT_MAX_CONCURRENT = 8;
|
||||
|
||||
export function resolveAgentMaxConcurrent(cfg?: ClawdbotConfig): number {
|
||||
const raw = cfg?.agents?.defaults?.maxConcurrent;
|
||||
if (typeof raw === "number" && Number.isFinite(raw)) {
|
||||
return Math.max(1, Math.floor(raw));
|
||||
}
|
||||
return DEFAULT_AGENT_MAX_CONCURRENT;
|
||||
}
|
||||
|
||||
export function resolveSubagentMaxConcurrent(cfg?: ClawdbotConfig): number {
|
||||
const raw = cfg?.agents?.defaults?.subagents?.maxConcurrent;
|
||||
if (typeof raw === "number" && Number.isFinite(raw)) {
|
||||
return Math.max(1, Math.floor(raw));
|
||||
}
|
||||
return DEFAULT_SUBAGENT_MAX_CONCURRENT;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DEFAULT_AGENT_MAX_CONCURRENT,
|
||||
DEFAULT_SUBAGENT_MAX_CONCURRENT,
|
||||
resolveAgentMaxConcurrent,
|
||||
resolveSubagentMaxConcurrent,
|
||||
} from "./agent-limits.js";
|
||||
import { withTempHome } from "./test-helpers.js";
|
||||
|
||||
describe("agent concurrency defaults", () => {
|
||||
it("resolves defaults when unset", () => {
|
||||
expect(resolveAgentMaxConcurrent({})).toBe(DEFAULT_AGENT_MAX_CONCURRENT);
|
||||
expect(resolveSubagentMaxConcurrent({})).toBe(DEFAULT_SUBAGENT_MAX_CONCURRENT);
|
||||
});
|
||||
|
||||
it("resolves configured values", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
maxConcurrent: 6,
|
||||
subagents: { maxConcurrent: 9 },
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(resolveAgentMaxConcurrent(cfg)).toBe(6);
|
||||
expect(resolveSubagentMaxConcurrent(cfg)).toBe(9);
|
||||
});
|
||||
|
||||
it("clamps invalid values to at least 1", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
maxConcurrent: 0,
|
||||
subagents: { maxConcurrent: -3 },
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(resolveAgentMaxConcurrent(cfg)).toBe(1);
|
||||
expect(resolveSubagentMaxConcurrent(cfg)).toBe(1);
|
||||
});
|
||||
|
||||
it("injects defaults on load", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configDir = path.join(home, ".clawdbot");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(configDir, "clawdbot.json"),
|
||||
JSON.stringify({}, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
vi.resetModules();
|
||||
const { loadConfig } = await import("./config.js");
|
||||
const cfg = loadConfig();
|
||||
|
||||
expect(cfg.agents?.defaults?.maxConcurrent).toBe(DEFAULT_AGENT_MAX_CONCURRENT);
|
||||
expect(cfg.agents?.defaults?.subagents?.maxConcurrent).toBe(DEFAULT_SUBAGENT_MAX_CONCURRENT);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
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";
|
||||
|
||||
describe("config identity defaults", () => {
|
||||
@@ -284,7 +285,7 @@ describe("config identity defaults", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not synthesize agent/session when absent", async () => {
|
||||
it("does not synthesize agent list/session when absent", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configDir = path.join(home, ".clawdbot");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
@@ -306,7 +307,9 @@ describe("config identity defaults", () => {
|
||||
|
||||
expect(cfg.messages?.responsePrefix).toBeUndefined();
|
||||
expect(cfg.messages?.groupChat?.mentionPatterns).toBeUndefined();
|
||||
expect(cfg.agents).toBeUndefined();
|
||||
expect(cfg.agents?.list).toBeUndefined();
|
||||
expect(cfg.agents?.defaults?.maxConcurrent).toBe(DEFAULT_AGENT_MAX_CONCURRENT);
|
||||
expect(cfg.agents?.defaults?.subagents?.maxConcurrent).toBe(DEFAULT_SUBAGENT_MAX_CONCURRENT);
|
||||
expect(cfg.session).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { resolveTalkApiKey } from "./talk.js";
|
||||
import type { ClawdbotConfig } from "./types.js";
|
||||
import { DEFAULT_AGENT_MAX_CONCURRENT, DEFAULT_SUBAGENT_MAX_CONCURRENT } from "./agent-limits.js";
|
||||
|
||||
type WarnState = { warned: boolean };
|
||||
|
||||
@@ -105,6 +106,43 @@ 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 {
|
||||
const logging = cfg.logging;
|
||||
if (!logging) return cfg;
|
||||
|
||||
+11
-4
@@ -15,6 +15,7 @@ import { DuplicateAgentDirError, findDuplicateAgentDirs } from "./agent-dirs.js"
|
||||
import {
|
||||
applyCompactionDefaults,
|
||||
applyContextPruningDefaults,
|
||||
applyAgentDefaults,
|
||||
applyLoggingDefaults,
|
||||
applyMessageDefaults,
|
||||
applyModelDefaults,
|
||||
@@ -244,8 +245,10 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
const cfg = applyModelDefaults(
|
||||
applyCompactionDefaults(
|
||||
applyContextPruningDefaults(
|
||||
applySessionDefaults(
|
||||
applyLoggingDefaults(applyMessageDefaults(validated.data as ClawdbotConfig)),
|
||||
applyAgentDefaults(
|
||||
applySessionDefaults(
|
||||
applyLoggingDefaults(applyMessageDefaults(validated.data as ClawdbotConfig)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -291,7 +294,9 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
const config = applyTalkApiKey(
|
||||
applyModelDefaults(
|
||||
applyCompactionDefaults(
|
||||
applyContextPruningDefaults(applySessionDefaults(applyMessageDefaults({}))),
|
||||
applyContextPruningDefaults(
|
||||
applyAgentDefaults(applySessionDefaults(applyMessageDefaults({}))),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -402,7 +407,9 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
config: normalizeConfigPaths(
|
||||
applyTalkApiKey(
|
||||
applyModelDefaults(
|
||||
applySessionDefaults(applyLoggingDefaults(applyMessageDefaults(validated.config))),
|
||||
applyAgentDefaults(
|
||||
applySessionDefaults(applyLoggingDefaults(applyMessageDefaults(validated.config))),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -169,6 +169,8 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint",
|
||||
"gateway.reload.mode": "Config Reload Mode",
|
||||
"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.watchDebounceMs": "Skills Watch Debounce (ms)",
|
||||
"agents.defaults.workspace": "Workspace",
|
||||
@@ -318,6 +320,10 @@ const FIELD_HELP: Record<string, string> = {
|
||||
"Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).",
|
||||
"gateway.reload.mode": 'Hot reload strategy for config changes ("hybrid" recommended).',
|
||||
"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":
|
||||
"Experimental. Enables apply_patch for OpenAI models when allowed by tool policy.",
|
||||
"tools.exec.applyPatch.allowModels":
|
||||
|
||||
@@ -170,6 +170,13 @@ export type GatewayHttpConfig = {
|
||||
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 = {
|
||||
/** Single multiplexed port for Gateway WS + HTTP (default: 18789). */
|
||||
port?: number;
|
||||
@@ -196,4 +203,5 @@ export type GatewayConfig = {
|
||||
reload?: GatewayReloadConfig;
|
||||
tls?: GatewayTlsConfig;
|
||||
http?: GatewayHttpConfig;
|
||||
nodes?: GatewayNodesConfig;
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { findDuplicateAgentDirs, formatDuplicateAgentDirError } from "./agent-dirs.js";
|
||||
import { applyModelDefaults, applySessionDefaults } from "./defaults.js";
|
||||
import { applyAgentDefaults, applyModelDefaults, applySessionDefaults } from "./defaults.js";
|
||||
import { findLegacyConfigIssues } from "./legacy.js";
|
||||
import type { ClawdbotConfig, ConfigValidationIssue } from "./types.js";
|
||||
import { ClawdbotSchema } from "./zod-schema.js";
|
||||
@@ -41,6 +41,8 @@ export function validateConfigObject(
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
config: applyModelDefaults(applySessionDefaults(validated.data as ClawdbotConfig)),
|
||||
config: applyModelDefaults(
|
||||
applyAgentDefaults(applySessionDefaults(validated.data as ClawdbotConfig)),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -341,6 +341,13 @@ export const ClawdbotSchema = z
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
nodes: z
|
||||
.object({
|
||||
allowCommands: z.array(z.string()).optional(),
|
||||
denyCommands: z.array(z.string()).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,22 @@
|
||||
import type { CronServiceState } from "./state.js";
|
||||
|
||||
export async function locked<T>(state: CronServiceState, fn: () => Promise<T>): Promise<T> {
|
||||
const next = state.op.then(fn, fn);
|
||||
// Keep the chain alive even when the operation fails.
|
||||
state.op = next.then(
|
||||
const storeLocks = new Map<string, Promise<void>>();
|
||||
|
||||
const resolveChain = (promise: Promise<unknown>) =>
|
||||
promise.then(
|
||||
() => 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;
|
||||
}
|
||||
@@ -4,8 +4,15 @@ import type { CronJob } from "../types.js";
|
||||
import { inferLegacyName, normalizeOptionalText } from "./normalize.js";
|
||||
import type { CronServiceState } from "./state.js";
|
||||
|
||||
const storeCache = new Map<string, { version: 1; jobs: CronJob[] }>();
|
||||
|
||||
export async function ensureLoaded(state: CronServiceState) {
|
||||
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 jobs = (loaded.jobs ?? []) as unknown as Array<Record<string, unknown>>;
|
||||
let mutated = false;
|
||||
@@ -35,6 +42,7 @@ export async function ensureLoaded(state: CronServiceState) {
|
||||
}
|
||||
}
|
||||
state.store = { version: 1, jobs: jobs as unknown as CronJob[] };
|
||||
storeCache.set(state.deps.storePath, state.store);
|
||||
if (mutated) await persist(state);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ export type ResolvedGatewayAuth = {
|
||||
|
||||
export type GatewayAuthResult = {
|
||||
ok: boolean;
|
||||
method?: "none" | "token" | "password" | "tailscale";
|
||||
method?: "none" | "token" | "password" | "tailscale" | "device-token";
|
||||
user?: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
+27
-6
@@ -3,7 +3,12 @@ import { WebSocket, type ClientOptions, type CertMeta } from "ws";
|
||||
import { rawDataToString } from "../infra/ws.js";
|
||||
import { logDebug, logError } from "../logger.js";
|
||||
import type { DeviceIdentity } from "../infra/device-identity.js";
|
||||
import { publicKeyRawBase64UrlFromPem, signDevicePayload } from "../infra/device-identity.js";
|
||||
import {
|
||||
loadOrCreateDeviceIdentity,
|
||||
publicKeyRawBase64UrlFromPem,
|
||||
signDevicePayload,
|
||||
} from "../infra/device-identity.js";
|
||||
import { loadDeviceAuthToken, storeDeviceAuthToken } from "../infra/device-auth-store.js";
|
||||
import {
|
||||
GATEWAY_CLIENT_MODES,
|
||||
GATEWAY_CLIENT_NAMES,
|
||||
@@ -78,7 +83,10 @@ export class GatewayClient {
|
||||
private tickTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(opts: GatewayClientOptions) {
|
||||
this.opts = opts;
|
||||
this.opts = {
|
||||
...opts,
|
||||
deviceIdentity: opts.deviceIdentity ?? loadOrCreateDeviceIdentity(),
|
||||
};
|
||||
}
|
||||
|
||||
start() {
|
||||
@@ -139,15 +147,19 @@ export class GatewayClient {
|
||||
}
|
||||
|
||||
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 =
|
||||
this.opts.token || this.opts.password
|
||||
authToken || this.opts.password
|
||||
? {
|
||||
token: this.opts.token,
|
||||
token: authToken,
|
||||
password: this.opts.password,
|
||||
}
|
||||
: undefined;
|
||||
const signedAtMs = Date.now();
|
||||
const role = this.opts.role ?? "operator";
|
||||
const scopes = this.opts.scopes ?? ["operator.admin"];
|
||||
const device = (() => {
|
||||
if (!this.opts.deviceIdentity) return undefined;
|
||||
@@ -158,7 +170,7 @@ export class GatewayClient {
|
||||
role,
|
||||
scopes,
|
||||
signedAtMs,
|
||||
token: this.opts.token ?? null,
|
||||
token: authToken ?? null,
|
||||
});
|
||||
const signature = signDevicePayload(this.opts.deviceIdentity.privateKeyPem, payload);
|
||||
return {
|
||||
@@ -193,6 +205,15 @@ export class GatewayClient {
|
||||
|
||||
void this.request<HelloOk>("connect", params)
|
||||
.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.tickIntervalMs =
|
||||
typeof helloOk.policy?.tickIntervalMs === "number"
|
||||
|
||||
@@ -6,9 +6,15 @@ import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
|
||||
import {
|
||||
loadOrCreateDeviceIdentity,
|
||||
publicKeyRawBase64UrlFromPem,
|
||||
signDevicePayload,
|
||||
} from "../infra/device-identity.js";
|
||||
import { rawDataToString } from "../infra/ws.js";
|
||||
import { getDeterministicFreePortBlock } from "../test-utils/ports.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";
|
||||
|
||||
async function getFreeGatewayPort(): Promise<number> {
|
||||
@@ -43,6 +49,23 @@ async function onceMessage<T = unknown>(
|
||||
async function connectReq(params: { url: string; token?: string }) {
|
||||
const ws = new WebSocket(params.url);
|
||||
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(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
@@ -60,6 +83,7 @@ async function connectReq(params: { url: string; token?: string }) {
|
||||
},
|
||||
caps: [],
|
||||
auth: params.token ? { token: params.token } : undefined,
|
||||
device,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
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 };
|
||||
}
|
||||
@@ -155,6 +155,7 @@ export class NodeRegistry {
|
||||
}): boolean {
|
||||
const pending = this.pendingInvokes.get(params.id);
|
||||
if (!pending) return false;
|
||||
if (pending.nodeId !== params.nodeId) return false;
|
||||
clearTimeout(pending.timer);
|
||||
this.pendingInvokes.delete(params.id);
|
||||
pending.resolve({
|
||||
|
||||
@@ -462,6 +462,39 @@ 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 () => {
|
||||
agentCommand.mockResolvedValueOnce({
|
||||
payloads: [{ text: "hello" }],
|
||||
|
||||
@@ -62,6 +62,10 @@ import {
|
||||
DevicePairListParamsSchema,
|
||||
type DevicePairRejectParams,
|
||||
DevicePairRejectParamsSchema,
|
||||
type DeviceTokenRevokeParams,
|
||||
DeviceTokenRevokeParamsSchema,
|
||||
type DeviceTokenRotateParams,
|
||||
DeviceTokenRotateParamsSchema,
|
||||
type ExecApprovalsGetParams,
|
||||
ExecApprovalsGetParamsSchema,
|
||||
type ExecApprovalsNodeGetParams,
|
||||
@@ -138,6 +142,9 @@ import {
|
||||
SessionsResolveParamsSchema,
|
||||
type ShutdownEvent,
|
||||
ShutdownEventSchema,
|
||||
type SkillsBinsParams,
|
||||
SkillsBinsParamsSchema,
|
||||
type SkillsBinsResult,
|
||||
type SkillsInstallParams,
|
||||
SkillsInstallParamsSchema,
|
||||
type SkillsStatusParams,
|
||||
@@ -247,6 +254,7 @@ export const validateChannelsLogoutParams = ajv.compile<ChannelsLogoutParams>(
|
||||
);
|
||||
export const validateModelsListParams = ajv.compile<ModelsListParams>(ModelsListParamsSchema);
|
||||
export const validateSkillsStatusParams = ajv.compile<SkillsStatusParams>(SkillsStatusParamsSchema);
|
||||
export const validateSkillsBinsParams = ajv.compile<SkillsBinsParams>(SkillsBinsParamsSchema);
|
||||
export const validateSkillsInstallParams =
|
||||
ajv.compile<SkillsInstallParams>(SkillsInstallParamsSchema);
|
||||
export const validateSkillsUpdateParams = ajv.compile<SkillsUpdateParams>(SkillsUpdateParamsSchema);
|
||||
@@ -266,6 +274,12 @@ export const validateDevicePairApproveParams = ajv.compile<DevicePairApprovePara
|
||||
export const validateDevicePairRejectParams = ajv.compile<DevicePairRejectParams>(
|
||||
DevicePairRejectParamsSchema,
|
||||
);
|
||||
export const validateDeviceTokenRotateParams = ajv.compile<DeviceTokenRotateParams>(
|
||||
DeviceTokenRotateParamsSchema,
|
||||
);
|
||||
export const validateDeviceTokenRevokeParams = ajv.compile<DeviceTokenRevokeParams>(
|
||||
DeviceTokenRevokeParamsSchema,
|
||||
);
|
||||
export const validateExecApprovalsGetParams = ajv.compile<ExecApprovalsGetParams>(
|
||||
ExecApprovalsGetParamsSchema,
|
||||
);
|
||||
@@ -424,6 +438,8 @@ export type {
|
||||
AgentsListParams,
|
||||
AgentsListResult,
|
||||
SkillsStatusParams,
|
||||
SkillsBinsParams,
|
||||
SkillsBinsResult,
|
||||
SkillsInstallParams,
|
||||
SkillsUpdateParams,
|
||||
NodePairRejectParams,
|
||||
|
||||
@@ -44,6 +44,15 @@ export const ModelsListResultSchema = Type.Object(
|
||||
|
||||
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(
|
||||
{
|
||||
name: NonEmptyString,
|
||||
|
||||
@@ -14,6 +14,23 @@ export const DevicePairRejectParamsSchema = Type.Object(
|
||||
{ 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(
|
||||
{
|
||||
requestId: NonEmptyString,
|
||||
|
||||
@@ -87,6 +87,17 @@ export const HelloOkSchema = Type.Object(
|
||||
),
|
||||
snapshot: SnapshotSchema,
|
||||
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(
|
||||
{
|
||||
maxPayload: Type.Integer({ minimum: 1 }),
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
ModelChoiceSchema,
|
||||
ModelsListParamsSchema,
|
||||
ModelsListResultSchema,
|
||||
SkillsBinsParamsSchema,
|
||||
SkillsBinsResultSchema,
|
||||
SkillsInstallParamsSchema,
|
||||
SkillsStatusParamsSchema,
|
||||
SkillsUpdateParamsSchema,
|
||||
@@ -62,6 +64,8 @@ import {
|
||||
DevicePairRejectParamsSchema,
|
||||
DevicePairRequestedEventSchema,
|
||||
DevicePairResolvedEventSchema,
|
||||
DeviceTokenRevokeParamsSchema,
|
||||
DeviceTokenRotateParamsSchema,
|
||||
} from "./devices.js";
|
||||
import {
|
||||
ConnectParamsSchema,
|
||||
@@ -179,6 +183,8 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
||||
ModelsListParams: ModelsListParamsSchema,
|
||||
ModelsListResult: ModelsListResultSchema,
|
||||
SkillsStatusParams: SkillsStatusParamsSchema,
|
||||
SkillsBinsParams: SkillsBinsParamsSchema,
|
||||
SkillsBinsResult: SkillsBinsResultSchema,
|
||||
SkillsInstallParams: SkillsInstallParamsSchema,
|
||||
SkillsUpdateParams: SkillsUpdateParamsSchema,
|
||||
CronJob: CronJobSchema,
|
||||
@@ -202,6 +208,8 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
||||
DevicePairListParams: DevicePairListParamsSchema,
|
||||
DevicePairApproveParams: DevicePairApproveParamsSchema,
|
||||
DevicePairRejectParams: DevicePairRejectParamsSchema,
|
||||
DeviceTokenRotateParams: DeviceTokenRotateParamsSchema,
|
||||
DeviceTokenRevokeParams: DeviceTokenRevokeParamsSchema,
|
||||
DevicePairRequestedEvent: DevicePairRequestedEventSchema,
|
||||
DevicePairResolvedEvent: DevicePairResolvedEventSchema,
|
||||
ChatHistoryParams: ChatHistoryParamsSchema,
|
||||
|
||||
@@ -13,6 +13,8 @@ import type {
|
||||
ModelChoiceSchema,
|
||||
ModelsListParamsSchema,
|
||||
ModelsListResultSchema,
|
||||
SkillsBinsParamsSchema,
|
||||
SkillsBinsResultSchema,
|
||||
SkillsInstallParamsSchema,
|
||||
SkillsStatusParamsSchema,
|
||||
SkillsUpdateParamsSchema,
|
||||
@@ -58,6 +60,8 @@ import type {
|
||||
DevicePairApproveParamsSchema,
|
||||
DevicePairListParamsSchema,
|
||||
DevicePairRejectParamsSchema,
|
||||
DeviceTokenRevokeParamsSchema,
|
||||
DeviceTokenRotateParamsSchema,
|
||||
} from "./devices.js";
|
||||
import type {
|
||||
ConnectParamsSchema,
|
||||
@@ -168,6 +172,8 @@ export type ModelChoice = Static<typeof ModelChoiceSchema>;
|
||||
export type ModelsListParams = Static<typeof ModelsListParamsSchema>;
|
||||
export type ModelsListResult = Static<typeof ModelsListResultSchema>;
|
||||
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 SkillsUpdateParams = Static<typeof SkillsUpdateParamsSchema>;
|
||||
export type CronJob = Static<typeof CronJobSchema>;
|
||||
@@ -191,6 +197,8 @@ export type ExecApprovalResolveParams = Static<typeof ExecApprovalResolveParamsS
|
||||
export type DevicePairListParams = Static<typeof DevicePairListParamsSchema>;
|
||||
export type DevicePairApproveParams = Static<typeof DevicePairApproveParamsSchema>;
|
||||
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 ChatInjectParams = Static<typeof ChatInjectParamsSchema>;
|
||||
export type ChatEvent = Static<typeof ChatEventSchema>;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { loadConfig } from "../config/config.js";
|
||||
import { resolveAgentMaxConcurrent, resolveSubagentMaxConcurrent } from "../config/agent-limits.js";
|
||||
import { setCommandLaneConcurrency } from "../process/command-queue.js";
|
||||
import { CommandLane } from "../process/lanes.js";
|
||||
|
||||
export function applyGatewayLaneConcurrency(cfg: ReturnType<typeof loadConfig>) {
|
||||
setCommandLaneConcurrency("cron", cfg.cron?.maxConcurrentRuns ?? 1);
|
||||
setCommandLaneConcurrency("main", cfg.agents?.defaults?.maxConcurrent ?? 1);
|
||||
setCommandLaneConcurrency("subagent", cfg.agents?.defaults?.subagents?.maxConcurrent ?? 1);
|
||||
setCommandLaneConcurrency(CommandLane.Cron, cfg.cron?.maxConcurrentRuns ?? 1);
|
||||
setCommandLaneConcurrency(CommandLane.Main, resolveAgentMaxConcurrent(cfg));
|
||||
setCommandLaneConcurrency(CommandLane.Subagent, resolveSubagentMaxConcurrent(cfg));
|
||||
}
|
||||
@@ -27,6 +27,7 @@ const BASE_METHODS = [
|
||||
"models.list",
|
||||
"agents.list",
|
||||
"skills.status",
|
||||
"skills.bins",
|
||||
"skills.install",
|
||||
"skills.update",
|
||||
"update.run",
|
||||
@@ -48,6 +49,8 @@ const BASE_METHODS = [
|
||||
"device.pair.list",
|
||||
"device.pair.approve",
|
||||
"device.pair.reject",
|
||||
"device.token.rotate",
|
||||
"device.token.revoke",
|
||||
"node.rename",
|
||||
"node.list",
|
||||
"node.describe",
|
||||
|
||||
@@ -25,11 +25,13 @@ import { webHandlers } from "./server-methods/web.js";
|
||||
import { wizardHandlers } from "./server-methods/wizard.js";
|
||||
|
||||
const ADMIN_SCOPE = "operator.admin";
|
||||
const READ_SCOPE = "operator.read";
|
||||
const WRITE_SCOPE = "operator.write";
|
||||
const APPROVALS_SCOPE = "operator.approvals";
|
||||
const PAIRING_SCOPE = "operator.pairing";
|
||||
|
||||
const APPROVAL_METHODS = new Set(["exec.approval.request", "exec.approval.resolve"]);
|
||||
const NODE_ROLE_METHODS = new Set(["node.invoke.result", "node.event"]);
|
||||
const NODE_ROLE_METHODS = new Set(["node.invoke.result", "node.event", "skills.bins"]);
|
||||
const PAIRING_METHODS = new Set([
|
||||
"node.pair.request",
|
||||
"node.pair.list",
|
||||
@@ -39,15 +41,53 @@ const PAIRING_METHODS = new Set([
|
||||
"device.pair.list",
|
||||
"device.pair.approve",
|
||||
"device.pair.reject",
|
||||
"device.token.rotate",
|
||||
"device.token.revoke",
|
||||
"node.rename",
|
||||
]);
|
||||
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"]) {
|
||||
if (!client?.connect) return null;
|
||||
const role = client.connect.role ?? "operator";
|
||||
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 (NODE_ROLE_METHODS.has(method)) return null;
|
||||
return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`);
|
||||
}
|
||||
if (role !== "operator") {
|
||||
@@ -60,10 +100,38 @@ function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["c
|
||||
if (PAIRING_METHODS.has(method) && !scopes.includes(PAIRING_SCOPE)) {
|
||||
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))) {
|
||||
return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.admin");
|
||||
}
|
||||
return null;
|
||||
if (
|
||||
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 = {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import {
|
||||
approveDevicePairing,
|
||||
listDevicePairing,
|
||||
type DeviceAuthToken,
|
||||
rejectDevicePairing,
|
||||
revokeDeviceToken,
|
||||
rotateDeviceToken,
|
||||
summarizeDeviceTokens,
|
||||
} from "../../infra/device-pairing.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
@@ -10,9 +14,19 @@ import {
|
||||
validateDevicePairApproveParams,
|
||||
validateDevicePairListParams,
|
||||
validateDevicePairRejectParams,
|
||||
validateDeviceTokenRevokeParams,
|
||||
validateDeviceTokenRotateParams,
|
||||
} from "../protocol/index.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 = {
|
||||
"device.pair.list": async ({ params, respond }) => {
|
||||
if (!validateDevicePairListParams(params)) {
|
||||
@@ -29,7 +43,14 @@ export const deviceHandlers: GatewayRequestHandlers = {
|
||||
return;
|
||||
}
|
||||
const list = await listDevicePairing();
|
||||
respond(true, list, undefined);
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
pending: list.pending,
|
||||
paired: list.paired.map((device) => redactPairedDevice(device)),
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
},
|
||||
"device.pair.approve": async ({ params, respond, context }) => {
|
||||
if (!validateDevicePairApproveParams(params)) {
|
||||
@@ -58,10 +79,10 @@ export const deviceHandlers: GatewayRequestHandlers = {
|
||||
deviceId: approved.device.deviceId,
|
||||
decision: "approved",
|
||||
ts: Date.now(),
|
||||
},
|
||||
},
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
respond(true, approved, undefined);
|
||||
respond(true, { requestId, device: redactPairedDevice(approved.device) }, undefined);
|
||||
},
|
||||
"device.pair.reject": async ({ params, respond, context }) => {
|
||||
if (!validateDevicePairRejectParams(params)) {
|
||||
@@ -95,4 +116,66 @@ export const deviceHandlers: GatewayRequestHandlers = {
|
||||
);
|
||||
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,
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -28,6 +28,11 @@ import {
|
||||
safeParseJson,
|
||||
uniqueSortedStrings,
|
||||
} from "./nodes.helpers.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import {
|
||||
isNodeCommandAllowed,
|
||||
resolveNodeCommandAllowlist,
|
||||
} from "../node-command-policy.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
function isNodeEntry(entry: { role?: string; roles?: string[] }) {
|
||||
@@ -353,6 +358,34 @@ export const nodeHandlers: GatewayRequestHandlers = {
|
||||
}
|
||||
|
||||
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({
|
||||
nodeId,
|
||||
command,
|
||||
@@ -384,7 +417,7 @@ export const nodeHandlers: GatewayRequestHandlers = {
|
||||
);
|
||||
});
|
||||
},
|
||||
"node.invoke.result": async ({ params, respond, context }) => {
|
||||
"node.invoke.result": async ({ params, respond, context, client }) => {
|
||||
if (!validateNodeInvokeResultParams(params)) {
|
||||
respondInvalidParams({
|
||||
respond,
|
||||
@@ -401,6 +434,11 @@ export const nodeHandlers: GatewayRequestHandlers = {
|
||||
payloadJSON?: 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({
|
||||
id: p.id,
|
||||
nodeId: p.nodeId,
|
||||
@@ -415,7 +453,7 @@ export const nodeHandlers: GatewayRequestHandlers = {
|
||||
}
|
||||
respond(true, { ok: true }, undefined);
|
||||
},
|
||||
"node.event": async ({ params, respond, context }) => {
|
||||
"node.event": async ({ params, respond, context, client }) => {
|
||||
if (!validateNodeEventParams(params)) {
|
||||
respondInvalidParams({
|
||||
respond,
|
||||
@@ -433,6 +471,7 @@ export const nodeHandlers: GatewayRequestHandlers = {
|
||||
: null;
|
||||
await respondUnavailableOnThrow(respond, async () => {
|
||||
const { handleNodeEvent } = await import("../server-node-events.js");
|
||||
const nodeId = client?.connect?.device?.id ?? client?.connect?.client?.id ?? "node";
|
||||
const nodeContext = {
|
||||
deps: context.deps,
|
||||
broadcast: context.broadcast,
|
||||
@@ -453,7 +492,7 @@ export const nodeHandlers: GatewayRequestHandlers = {
|
||||
loadGatewayModelCatalog: context.loadGatewayModelCatalog,
|
||||
logGateway: { warn: context.logGateway.warn },
|
||||
};
|
||||
await handleNodeEvent(nodeContext, "node", {
|
||||
await handleNodeEvent(nodeContext, nodeId, {
|
||||
event: p.event,
|
||||
payloadJSON,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||
import { installSkill } from "../../agents/skills-install.js";
|
||||
import { buildWorkspaceSkillStatus } from "../../agents/skills-status.js";
|
||||
import { loadWorkspaceSkillEntries, type SkillEntry } from "../../agents/skills.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { loadConfig, writeConfigFile } from "../../config/config.js";
|
||||
import { getRemoteSkillEligibility } from "../../infra/skills-remote.js";
|
||||
@@ -8,12 +9,52 @@ import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
formatValidationErrors,
|
||||
validateSkillsBinsParams,
|
||||
validateSkillsInstallParams,
|
||||
validateSkillsStatusParams,
|
||||
validateSkillsUpdateParams,
|
||||
} from "../protocol/index.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 = {
|
||||
"skills.status": ({ params, respond }) => {
|
||||
if (!validateSkillsStatusParams(params)) {
|
||||
@@ -35,6 +76,27 @@ export const skillsHandlers: GatewayRequestHandlers = {
|
||||
});
|
||||
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 }) => {
|
||||
if (!validateSkillsInstallParams(params)) {
|
||||
respond(
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
setGatewaySigusr1RestartPolicy,
|
||||
} from "../infra/restart.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 type { ChannelKind, GatewayReloadPlan } from "./config-reload.js";
|
||||
import { resolveHooksConfig } from "./hooks.js";
|
||||
@@ -126,12 +128,9 @@ export function createGatewayReloadHandlers(params: {
|
||||
}
|
||||
}
|
||||
|
||||
setCommandLaneConcurrency("cron", nextConfig.cron?.maxConcurrentRuns ?? 1);
|
||||
setCommandLaneConcurrency("main", nextConfig.agents?.defaults?.maxConcurrent ?? 1);
|
||||
setCommandLaneConcurrency(
|
||||
"subagent",
|
||||
nextConfig.agents?.defaults?.subagents?.maxConcurrent ?? 1,
|
||||
);
|
||||
setCommandLaneConcurrency(CommandLane.Cron, nextConfig.cron?.maxConcurrentRuns ?? 1);
|
||||
setCommandLaneConcurrency(CommandLane.Main, resolveAgentMaxConcurrent(nextConfig));
|
||||
setCommandLaneConcurrency(CommandLane.Subagent, resolveSubagentMaxConcurrent(nextConfig));
|
||||
|
||||
if (plan.hotReasons.length > 0) {
|
||||
params.logReload.info(`config hot reload applied (${plan.hotReasons.join(", ")})`);
|
||||
|
||||
@@ -100,6 +100,81 @@ describe("gateway server auth/connect", () => {
|
||||
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 () => {
|
||||
testState.gatewayAuth = { mode: "password", password: "secret" };
|
||||
const port = await getFreePort();
|
||||
@@ -150,6 +225,12 @@ describe("gateway server auth/connect", () => {
|
||||
platform: "web",
|
||||
mode: "webchat",
|
||||
},
|
||||
device: {
|
||||
id: 123,
|
||||
publicKey: "bad",
|
||||
signature: "bad",
|
||||
signedAt: "bad",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -185,6 +185,7 @@ describe("gateway server cron", () => {
|
||||
test("accepts jobId for cron.update", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-"));
|
||||
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
|
||||
testState.cronEnabled = false;
|
||||
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
|
||||
await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] }));
|
||||
|
||||
@@ -218,6 +219,7 @@ describe("gateway server cron", () => {
|
||||
await server.close();
|
||||
await rmTempDir(dir);
|
||||
testState.cronStorePath = undefined;
|
||||
testState.cronEnabled = undefined;
|
||||
});
|
||||
|
||||
test("disables cron jobs via enabled:false patches", async () => {
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||
import {
|
||||
loadOrCreateDeviceIdentity,
|
||||
publicKeyRawBase64UrlFromPem,
|
||||
signDevicePayload,
|
||||
} from "../infra/device-identity.js";
|
||||
import { emitHeartbeatEvent } from "../infra/heartbeat-events.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||
import {
|
||||
@@ -12,6 +19,7 @@ import {
|
||||
startGatewayServer,
|
||||
startServerWithClient,
|
||||
} from "./test-helpers.js";
|
||||
import { buildDeviceAuthPayload } from "./device-auth.js";
|
||||
|
||||
installGatewayTestHooks();
|
||||
|
||||
@@ -200,8 +208,24 @@ describe("gateway server health/presence", () => {
|
||||
});
|
||||
|
||||
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();
|
||||
await connectOk(ws, {
|
||||
role,
|
||||
scopes,
|
||||
client: {
|
||||
id: GATEWAY_CLIENT_NAMES.FINGERPRINT,
|
||||
version: "9.9.9",
|
||||
@@ -211,6 +235,12 @@ describe("gateway server health/presence", () => {
|
||||
mode: GATEWAY_CLIENT_MODES.UI,
|
||||
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);
|
||||
@@ -224,7 +254,9 @@ describe("gateway server health/presence", () => {
|
||||
|
||||
const presenceRes = await presenceP;
|
||||
const entries = presenceRes.payload as Array<Record<string, unknown>>;
|
||||
const clientEntry = entries.find((e) => e.instanceId === "abc");
|
||||
const clientEntry = entries.find(
|
||||
(e) => e.host === GATEWAY_CLIENT_NAMES.FINGERPRINT && e.version === "9.9.9",
|
||||
);
|
||||
expect(clientEntry?.host).toBe(GATEWAY_CLIENT_NAMES.FINGERPRINT);
|
||||
expect(clientEntry?.version).toBe("9.9.9");
|
||||
expect(clientEntry?.mode).toBe("ui");
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
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,9 +9,11 @@ import {
|
||||
} from "../../../infra/device-identity.js";
|
||||
import {
|
||||
approveDevicePairing,
|
||||
ensureDeviceToken,
|
||||
getPairedDevice,
|
||||
requestDevicePairing,
|
||||
updatePairedDeviceMetadata,
|
||||
verifyDeviceToken,
|
||||
} from "../../../infra/device-pairing.js";
|
||||
import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../../../infra/skills-remote.js";
|
||||
import { loadVoiceWakeConfig } from "../../../infra/voicewake.js";
|
||||
@@ -24,6 +26,7 @@ import { authorizeGatewayConnect } from "../../auth.js";
|
||||
import { loadConfig } from "../../../config/config.js";
|
||||
import { buildDeviceAuthPayload } from "../../device-auth.js";
|
||||
import { isLoopbackAddress } from "../../net.js";
|
||||
import { resolveNodeCommandAllowlist } from "../../node-command-policy.js";
|
||||
import {
|
||||
type ConnectParams,
|
||||
ErrorCodes,
|
||||
@@ -217,26 +220,12 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
const authResult = await authorizeGatewayConnect({
|
||||
auth: resolvedAuth,
|
||||
connectAuth: connectParams.auth,
|
||||
req: upgradeReq,
|
||||
});
|
||||
if (!authResult.ok) {
|
||||
const roleRaw = connectParams.role ?? "operator";
|
||||
const role = roleRaw === "operator" || roleRaw === "node" ? roleRaw : null;
|
||||
if (!role) {
|
||||
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,
|
||||
setCloseCause("invalid-role", {
|
||||
role: roleRaw,
|
||||
client: connectParams.client.id,
|
||||
clientDisplayName: connectParams.client.displayName,
|
||||
mode: connectParams.client.mode,
|
||||
@@ -246,24 +235,40 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: false,
|
||||
error: errorShape(ErrorCodes.INVALID_REQUEST, "unauthorized"),
|
||||
error: errorShape(ErrorCodes.INVALID_REQUEST, "invalid role"),
|
||||
});
|
||||
close(1008, "unauthorized");
|
||||
close(1008, "invalid role");
|
||||
return;
|
||||
}
|
||||
const authMethod = authResult.method ?? "none";
|
||||
|
||||
const role = connectParams.role ?? "operator";
|
||||
const scopes = Array.isArray(connectParams.scopes)
|
||||
? connectParams.scopes
|
||||
: role === "operator"
|
||||
? ["operator.admin"]
|
||||
: [];
|
||||
const requestedScopes = Array.isArray(connectParams.scopes) ? connectParams.scopes : [];
|
||||
const scopes =
|
||||
requestedScopes.length > 0
|
||||
? requestedScopes
|
||||
: role === "operator"
|
||||
? ["operator.admin"]
|
||||
: [];
|
||||
connectParams.role = role;
|
||||
connectParams.scopes = scopes;
|
||||
|
||||
const device = connectParams.device;
|
||||
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) {
|
||||
const derivedId = deriveDeviceIdFromPublicKey(device.publicKey);
|
||||
if (!derivedId || derivedId !== device.id) {
|
||||
@@ -307,7 +312,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
clientId: connectParams.client.id,
|
||||
clientMode: connectParams.client.mode,
|
||||
role,
|
||||
scopes,
|
||||
scopes: requestedScopes,
|
||||
signedAtMs: signedAt,
|
||||
token: connectParams.auth?.token ?? null,
|
||||
});
|
||||
@@ -346,10 +351,57 @@ 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) {
|
||||
const paired = await getPairedDevice(device.id);
|
||||
const isPaired = paired?.publicKey === devicePublicKey;
|
||||
if (!isPaired) {
|
||||
const requirePairing = async (reason: string, _paired?: { deviceId: string }) => {
|
||||
const pairing = await requestDevicePairing({
|
||||
deviceId: device.id,
|
||||
publicKey: devicePublicKey,
|
||||
@@ -360,7 +412,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
role,
|
||||
scopes,
|
||||
remoteIp: remoteAddr,
|
||||
silent: isLoopbackAddress(remoteAddr) && authMethod !== "none",
|
||||
silent: isLoopbackAddress(remoteAddr),
|
||||
});
|
||||
const context = buildRequestContext();
|
||||
if (pairing.request.silent === true) {
|
||||
@@ -385,6 +437,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
setCloseCause("pairing-required", {
|
||||
deviceId: device.id,
|
||||
requestId: pairing.request.requestId,
|
||||
reason,
|
||||
});
|
||||
send({
|
||||
type: "res",
|
||||
@@ -395,9 +448,47 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
}),
|
||||
});
|
||||
close(1008, "pairing required");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
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 {
|
||||
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, {
|
||||
displayName: connectParams.client.displayName,
|
||||
platform: connectParams.client.platform,
|
||||
@@ -410,10 +501,29 @@ 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 clientId = connectParams.client.id;
|
||||
const instanceId = connectParams.client.instanceId;
|
||||
const presenceKey = shouldTrackPresence ? (instanceId ?? connId) : undefined;
|
||||
const presenceKey = shouldTrackPresence
|
||||
? (connectParams.device?.id ?? instanceId ?? connId)
|
||||
: undefined;
|
||||
|
||||
logWs("in", "connect", {
|
||||
connId,
|
||||
@@ -441,7 +551,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
deviceFamily: connectParams.client.deviceFamily,
|
||||
modelIdentifier: connectParams.client.modelIdentifier,
|
||||
mode: connectParams.client.mode,
|
||||
instanceId,
|
||||
instanceId: connectParams.device?.id ?? instanceId,
|
||||
reason: "connect",
|
||||
});
|
||||
incrementPresenceVersion();
|
||||
@@ -465,6 +575,14 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
features: { methods: gatewayMethods, events },
|
||||
snapshot,
|
||||
canvasHostUrl,
|
||||
auth: deviceToken
|
||||
? {
|
||||
deviceToken: deviceToken.token,
|
||||
role: deviceToken.role,
|
||||
scopes: deviceToken.scopes,
|
||||
issuedAtMs: deviceToken.rotatedAtMs ?? deviceToken.createdAtMs,
|
||||
}
|
||||
: undefined,
|
||||
policy: {
|
||||
maxPayload: MAX_PAYLOAD_BYTES,
|
||||
maxBufferedBytes: MAX_BUFFERED_BYTES,
|
||||
|
||||
@@ -8,6 +8,11 @@ import { WebSocket } from "ws";
|
||||
|
||||
import { resolveMainSessionKeyFromConfig, type SessionEntry } from "../config/sessions.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 { rawDataToString } from "../infra/ws.js";
|
||||
import { resetLogger, setLoggerOverride } from "../logging.js";
|
||||
@@ -16,6 +21,7 @@ import { getDeterministicFreePortBlock } from "../test-utils/ports.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||
|
||||
import { PROTOCOL_VERSION } from "./protocol/index.js";
|
||||
import { buildDeviceAuthPayload } from "./device-auth.js";
|
||||
import type { GatewayServerOptions } from "./server.js";
|
||||
import {
|
||||
agentCommand,
|
||||
@@ -268,10 +274,44 @@ export async function connectReq(
|
||||
caps?: string[];
|
||||
commands?: string[];
|
||||
permissions?: Record<string, boolean>;
|
||||
device?: {
|
||||
id: string;
|
||||
publicKey: string;
|
||||
signature: string;
|
||||
signedAt: number;
|
||||
};
|
||||
},
|
||||
): Promise<ConnectResponse> {
|
||||
const { randomUUID } = await import("node:crypto");
|
||||
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(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
@@ -280,16 +320,11 @@ export async function connectReq(
|
||||
params: {
|
||||
minProtocol: opts?.minProtocol ?? PROTOCOL_VERSION,
|
||||
maxProtocol: opts?.maxProtocol ?? PROTOCOL_VERSION,
|
||||
client: opts?.client ?? {
|
||||
id: GATEWAY_CLIENT_NAMES.TEST,
|
||||
version: "1.0.0",
|
||||
platform: "test",
|
||||
mode: GATEWAY_CLIENT_MODES.TEST,
|
||||
},
|
||||
client,
|
||||
caps: opts?.caps ?? [],
|
||||
commands: opts?.commands ?? [],
|
||||
permissions: opts?.permissions ?? undefined,
|
||||
role: opts?.role,
|
||||
role,
|
||||
scopes: opts?.scopes,
|
||||
auth:
|
||||
opts?.token || opts?.password
|
||||
@@ -298,6 +333,7 @@ export async function connectReq(
|
||||
password: opts?.password,
|
||||
}
|
||||
: undefined,
|
||||
device,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -35,4 +35,24 @@ describe("agent-events sequencing", () => {
|
||||
expect(seen["run-1"]).toEqual([1, 2, 3]);
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
+223
-1
@@ -20,6 +20,25 @@ export type DevicePairingPendingRequest = {
|
||||
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 = {
|
||||
deviceId: string;
|
||||
publicKey: string;
|
||||
@@ -31,6 +50,7 @@ export type PairedDevice = {
|
||||
roles?: string[];
|
||||
scopes?: string[];
|
||||
remoteIp?: string;
|
||||
tokens?: Record<string, DeviceAuthToken>;
|
||||
createdAtMs: number;
|
||||
approvedAtMs: number;
|
||||
};
|
||||
@@ -136,6 +156,11 @@ function normalizeDeviceId(deviceId: string) {
|
||||
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 {
|
||||
const roles = new Set<string>();
|
||||
for (const item of items) {
|
||||
@@ -154,6 +179,40 @@ function mergeRoles(...items: Array<string | string[] | undefined>): string[] |
|
||||
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> {
|
||||
const state = await loadState(baseDir);
|
||||
const pending = Object.values(state.pendingById).sort((a, b) => b.ts - a.ts);
|
||||
@@ -223,6 +282,23 @@ export async function approveDevicePairing(
|
||||
const now = Date.now();
|
||||
const existing = state.pairedByDeviceId[pending.deviceId];
|
||||
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 = {
|
||||
deviceId: pending.deviceId,
|
||||
publicKey: pending.publicKey,
|
||||
@@ -232,8 +308,9 @@ export async function approveDevicePairing(
|
||||
clientMode: pending.clientMode,
|
||||
role: pending.role,
|
||||
roles,
|
||||
scopes: pending.scopes,
|
||||
scopes,
|
||||
remoteIp: pending.remoteIp,
|
||||
tokens,
|
||||
createdAtMs: existing?.createdAtMs ?? now,
|
||||
approvedAtMs: now,
|
||||
};
|
||||
@@ -268,6 +345,7 @@ export async function updatePairedDeviceMetadata(
|
||||
const existing = state.pairedByDeviceId[normalizeDeviceId(deviceId)];
|
||||
if (!existing) return;
|
||||
const roles = mergeRoles(existing.roles, existing.role, patch.role);
|
||||
const scopes = mergeScopes(existing.scopes, patch.scopes);
|
||||
state.pairedByDeviceId[deviceId] = {
|
||||
...existing,
|
||||
...patch,
|
||||
@@ -276,7 +354,151 @@ export async function updatePairedDeviceMetadata(
|
||||
approvedAtMs: existing.approvedAtMs,
|
||||
role: patch.role ?? existing.role,
|
||||
roles,
|
||||
scopes,
|
||||
};
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { getQueueSize } from "../process/command-queue.js";
|
||||
import { CommandLane } from "../process/lanes.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import { emitHeartbeatEvent } from "./heartbeat-events.js";
|
||||
@@ -327,7 +328,7 @@ export async function runHeartbeatOnce(opts: {
|
||||
return { status: "skipped", reason: "disabled" };
|
||||
}
|
||||
|
||||
const queueSize = (opts.deps?.getQueueSize ?? getQueueSize)("main");
|
||||
const queueSize = (opts.deps?.getQueueSize ?? getQueueSize)(CommandLane.Main);
|
||||
if (queueSize > 0) {
|
||||
return { status: "skipped", reason: "requests-in-flight" };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,36 @@
|
||||
import { lookup } from "node:dns/promises";
|
||||
import { logWarn } from "../logger.js";
|
||||
|
||||
import { createCanvas } from "@napi-rs/canvas";
|
||||
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
|
||||
type CanvasModule = typeof import("@napi-rs/canvas");
|
||||
type PdfJsModule = typeof import("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 = {
|
||||
type: "image";
|
||||
@@ -238,6 +267,7 @@ async function extractPdfContent(params: {
|
||||
limits: InputFileLimits;
|
||||
}): Promise<{ text: string; images: InputImageContent[] }> {
|
||||
const { buffer, limits } = params;
|
||||
const { getDocument } = await loadPdfJsModule();
|
||||
const pdf = await getDocument({
|
||||
data: new Uint8Array(buffer),
|
||||
disableWorker: true,
|
||||
@@ -260,6 +290,14 @@ async function extractPdfContent(params: {
|
||||
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[] = [];
|
||||
for (let pageNum = 1; pageNum <= maxPages; pageNum += 1) {
|
||||
const page = await pdf.getPage(pageNum);
|
||||
|
||||
@@ -335,6 +335,12 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
...(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 memorySlot = normalized.slots.memory;
|
||||
let selectedMemoryPluginId: string | null = null;
|
||||
@@ -442,6 +448,22 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
: undefined;
|
||||
|
||||
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`);
|
||||
record.status = "error";
|
||||
record.error = "missing config schema";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { CommandLane } from "./lanes.js";
|
||||
|
||||
// Minimal in-process queue to serialize command executions.
|
||||
// Default lane ("main") preserves the existing behavior. Additional lanes allow
|
||||
// low-risk parallelism (e.g. cron jobs) without interleaving stdin / logs for
|
||||
@@ -69,7 +71,7 @@ function drainLane(lane: string) {
|
||||
}
|
||||
|
||||
export function setCommandLaneConcurrency(lane: string, maxConcurrent: number) {
|
||||
const cleaned = lane.trim() || "main";
|
||||
const cleaned = lane.trim() || CommandLane.Main;
|
||||
const state = getLaneState(cleaned);
|
||||
state.maxConcurrent = Math.max(1, Math.floor(maxConcurrent));
|
||||
drainLane(cleaned);
|
||||
@@ -83,7 +85,7 @@ export function enqueueCommandInLane<T>(
|
||||
onWait?: (waitMs: number, queuedAhead: number) => void;
|
||||
},
|
||||
): Promise<T> {
|
||||
const cleaned = lane.trim() || "main";
|
||||
const cleaned = lane.trim() || CommandLane.Main;
|
||||
const warnAfterMs = opts?.warnAfterMs ?? 2_000;
|
||||
const state = getLaneState(cleaned);
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
@@ -106,10 +108,10 @@ export function enqueueCommand<T>(
|
||||
onWait?: (waitMs: number, queuedAhead: number) => void;
|
||||
},
|
||||
): Promise<T> {
|
||||
return enqueueCommandInLane("main", task, opts);
|
||||
return enqueueCommandInLane(CommandLane.Main, task, opts);
|
||||
}
|
||||
|
||||
export function getQueueSize(lane = "main") {
|
||||
export function getQueueSize(lane: string = CommandLane.Main) {
|
||||
const state = lanes.get(lane);
|
||||
if (!state) return 0;
|
||||
return state.queue.length + state.active;
|
||||
@@ -123,8 +125,8 @@ export function getTotalQueueSize() {
|
||||
return total;
|
||||
}
|
||||
|
||||
export function clearCommandLane(lane = "main") {
|
||||
const cleaned = lane.trim() || "main";
|
||||
export function clearCommandLane(lane: string = CommandLane.Main) {
|
||||
const cleaned = lane.trim() || CommandLane.Main;
|
||||
const state = lanes.get(cleaned);
|
||||
if (!state) return 0;
|
||||
const removed = state.queue.length;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export const enum CommandLane {
|
||||
Main = "main",
|
||||
Cron = "cron",
|
||||
Subagent = "subagent",
|
||||
Nested = "nested",
|
||||
}
|
||||
@@ -95,7 +95,10 @@ vi.mock("@slack/bolt", () => {
|
||||
start = vi.fn().mockResolvedValue(undefined);
|
||||
stop = vi.fn().mockResolvedValue(undefined);
|
||||
}
|
||||
return { App, default: { App } };
|
||||
class HTTPReceiver {
|
||||
requestListener = vi.fn();
|
||||
}
|
||||
return { App, HTTPReceiver, default: { App, HTTPReceiver } };
|
||||
});
|
||||
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
@@ -97,7 +97,10 @@ vi.mock("@slack/bolt", () => {
|
||||
start = vi.fn().mockResolvedValue(undefined);
|
||||
stop = vi.fn().mockResolvedValue(undefined);
|
||||
}
|
||||
return { App, default: { App } };
|
||||
class HTTPReceiver {
|
||||
requestListener = vi.fn();
|
||||
}
|
||||
return { App, HTTPReceiver, default: { App, HTTPReceiver } };
|
||||
});
|
||||
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
@@ -95,7 +95,10 @@ vi.mock("@slack/bolt", () => {
|
||||
start = vi.fn().mockResolvedValue(undefined);
|
||||
stop = vi.fn().mockResolvedValue(undefined);
|
||||
}
|
||||
return { App, default: { App } };
|
||||
class HTTPReceiver {
|
||||
requestListener = vi.fn();
|
||||
}
|
||||
return { App, HTTPReceiver, default: { App, HTTPReceiver } };
|
||||
});
|
||||
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
|
||||
import SlackBolt from "@slack/bolt";
|
||||
import SlackBoltDefault, * as SlackBoltModule from "@slack/bolt";
|
||||
|
||||
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
|
||||
import { DEFAULT_GROUP_HISTORY_LIMIT } from "../../auto-reply/reply/history.js";
|
||||
@@ -26,12 +26,24 @@ import { normalizeAllowList } from "./allow-list.js";
|
||||
|
||||
import type { MonitorSlackOpts } from "./types.js";
|
||||
|
||||
const slackBoltModule = SlackBolt as typeof import("@slack/bolt") & {
|
||||
default?: typeof import("@slack/bolt");
|
||||
};
|
||||
// Bun allows named imports from CJS; Node ESM doesn't. Use default+fallback for compatibility.
|
||||
const slackBolt = slackBoltModule.default ?? slackBoltModule;
|
||||
const { App, HTTPReceiver } = slackBolt;
|
||||
type SlackBoltNamespace = typeof import("@slack/bolt");
|
||||
type SlackBoltDefault = SlackBoltNamespace | SlackBoltNamespace["App"];
|
||||
|
||||
const slackBoltDefaultImport = SlackBoltDefault as SlackBoltDefault | undefined;
|
||||
const slackBoltModuleDefault = (SlackBoltModule as { default?: SlackBoltDefault }).default;
|
||||
const slackBoltDefault = slackBoltDefaultImport ?? slackBoltModuleDefault;
|
||||
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) {
|
||||
const token = raw?.trim();
|
||||
if (!token) return undefined;
|
||||
@@ -121,6 +133,13 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
const mediaMaxBytes = (opts.mediaMaxMb ?? slackCfg.mediaMaxMb ?? 20) * 1024 * 1024;
|
||||
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 =
|
||||
slackMode === "http"
|
||||
? new HTTPReceiver({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type RunOptions, run } from "@grammyjs/runner";
|
||||
import type { ClawdbotConfig } 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 { formatDurationMs } from "../infra/format-duration.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
@@ -28,7 +29,7 @@ export type MonitorTelegramOpts = {
|
||||
export function createTelegramRunnerOptions(cfg: ClawdbotConfig): RunOptions<unknown> {
|
||||
return {
|
||||
sink: {
|
||||
concurrency: cfg.agents?.defaults?.maxConcurrent ?? 1,
|
||||
concurrency: resolveAgentMaxConcurrent(cfg),
|
||||
},
|
||||
runner: {
|
||||
fetch: {
|
||||
|
||||
@@ -87,4 +87,14 @@ describe("extractContentFromMessage", () => {
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -65,4 +65,29 @@ describe("TuiStreamAssembler", () => {
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -27,10 +27,9 @@ export class TuiStreamAssembler {
|
||||
return state;
|
||||
}
|
||||
|
||||
ingestDelta(runId: string, message: unknown, showThinking: boolean): string | null {
|
||||
private updateRunState(state: RunStreamState, message: unknown, showThinking: boolean) {
|
||||
const thinkingText = extractThinkingFromMessage(message);
|
||||
const contentText = extractContentFromMessage(message);
|
||||
const state = this.getOrCreateRun(runId);
|
||||
|
||||
if (thinkingText) {
|
||||
state.thinkingText = thinkingText;
|
||||
@@ -45,29 +44,23 @@ export class TuiStreamAssembler {
|
||||
showThinking,
|
||||
});
|
||||
|
||||
if (!displayText || displayText === state.displayText) return null;
|
||||
|
||||
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 {
|
||||
const state = this.getOrCreateRun(runId);
|
||||
const thinkingText = extractThinkingFromMessage(message);
|
||||
const contentText = extractContentFromMessage(message);
|
||||
|
||||
if (thinkingText) {
|
||||
state.thinkingText = thinkingText;
|
||||
}
|
||||
if (contentText) {
|
||||
state.contentText = contentText;
|
||||
}
|
||||
|
||||
const finalComposed = composeThinkingAndContent({
|
||||
thinkingText: state.thinkingText,
|
||||
contentText: state.contentText,
|
||||
showThinking,
|
||||
});
|
||||
this.updateRunState(state, message, showThinking);
|
||||
const finalComposed = state.displayText;
|
||||
const finalText = resolveFinalAssistantText({
|
||||
finalText: finalComposed,
|
||||
streamedText: state.displayText,
|
||||
|
||||
+81
-122
@@ -6,12 +6,13 @@ import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { approveNodePairing, listNodePairing } from "../src/infra/node-pairing.js";
|
||||
import { loadOrCreateDeviceIdentity } from "../src/infra/device-identity.js";
|
||||
import { GatewayClient } from "../src/gateway/client.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../src/utils/message-channel.js";
|
||||
|
||||
type GatewayInstance = {
|
||||
name: string;
|
||||
port: number;
|
||||
bridgePort: number;
|
||||
hookToken: string;
|
||||
gatewayToken: string;
|
||||
homeDir: string;
|
||||
@@ -28,10 +29,6 @@ type NodeListPayload = {
|
||||
|
||||
type HealthPayload = { ok?: boolean };
|
||||
|
||||
type PairingList = {
|
||||
pending: Array<{ requestId: string; nodeId: string }>;
|
||||
};
|
||||
|
||||
const GATEWAY_START_TIMEOUT_MS = 45_000;
|
||||
const E2E_TIMEOUT_MS = 120_000;
|
||||
|
||||
@@ -96,7 +93,6 @@ const waitForPortOpen = async (
|
||||
|
||||
const spawnGatewayInstance = async (name: string): Promise<GatewayInstance> => {
|
||||
const port = await getFreePort();
|
||||
const bridgePort = await getFreePort();
|
||||
const hookToken = `token-${name}-${randomUUID()}`;
|
||||
const gatewayToken = `gateway-${name}-${randomUUID()}`;
|
||||
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), `clawdbot-e2e-${name}-`));
|
||||
@@ -107,7 +103,6 @@ const spawnGatewayInstance = async (name: string): Promise<GatewayInstance> => {
|
||||
const config = {
|
||||
gateway: { port, auth: { mode: "token", token: gatewayToken } },
|
||||
hooks: { enabled: true, token: hookToken, path: "/hooks" },
|
||||
bridge: { bind: "loopback", port: bridgePort },
|
||||
};
|
||||
await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf8");
|
||||
|
||||
@@ -139,9 +134,6 @@ const spawnGatewayInstance = async (name: string): Promise<GatewayInstance> => {
|
||||
CLAWDBOT_SKIP_CHANNELS: "1",
|
||||
CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER: "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"],
|
||||
},
|
||||
@@ -157,7 +149,6 @@ const spawnGatewayInstance = async (name: string): Promise<GatewayInstance> => {
|
||||
return {
|
||||
name,
|
||||
port,
|
||||
bridgePort,
|
||||
hookToken,
|
||||
gatewayToken,
|
||||
homeDir,
|
||||
@@ -278,105 +269,91 @@ const postJson = async (url: string, body: unknown) => {
|
||||
});
|
||||
};
|
||||
|
||||
const createLineReader = (socket: net.Socket) => {
|
||||
let buffer = "";
|
||||
const pending: Array<(line: string) => void> = [];
|
||||
|
||||
const flush = () => {
|
||||
while (pending.length > 0) {
|
||||
const idx = buffer.indexOf("\n");
|
||||
if (idx === -1) return;
|
||||
const line = buffer.slice(0, idx);
|
||||
buffer = buffer.slice(idx + 1);
|
||||
const resolve = pending.shift();
|
||||
resolve?.(line);
|
||||
}
|
||||
};
|
||||
|
||||
socket.on("data", (chunk) => {
|
||||
buffer += chunk.toString("utf8");
|
||||
flush();
|
||||
});
|
||||
|
||||
const readLine = async () => {
|
||||
flush();
|
||||
const idx = buffer.indexOf("\n");
|
||||
if (idx !== -1) {
|
||||
const line = buffer.slice(0, idx);
|
||||
buffer = buffer.slice(idx + 1);
|
||||
return line;
|
||||
}
|
||||
return await new Promise<string>((resolve) => pending.push(resolve));
|
||||
};
|
||||
|
||||
return readLine;
|
||||
};
|
||||
|
||||
const sendLine = (socket: net.Socket, obj: unknown) => {
|
||||
socket.write(`${JSON.stringify(obj)}\n`);
|
||||
};
|
||||
|
||||
const readLineWithTimeout = async (
|
||||
readLine: () => Promise<string>,
|
||||
const connectNode = async (
|
||||
inst: GatewayInstance,
|
||||
label: string,
|
||||
timeoutMs = 10_000,
|
||||
) => {
|
||||
const timer = sleep(timeoutMs).then(() => {
|
||||
throw new Error(`timeout waiting for ${label}`);
|
||||
): Promise<{ client: GatewayClient; nodeId: string }> => {
|
||||
const identityPath = path.join(inst.homeDir, `${label}-device.json`);
|
||||
const deviceIdentity = loadOrCreateDeviceIdentity(identityPath);
|
||||
const nodeId = deviceIdentity.deviceId;
|
||||
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;
|
||||
});
|
||||
return await Promise.race([readLine(), timer]);
|
||||
|
||||
const client = new GatewayClient({
|
||||
url: `ws://127.0.0.1:${inst.port}`,
|
||||
token: inst.gatewayToken,
|
||||
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
|
||||
clientDisplayName: label,
|
||||
clientVersion: "1.0.0",
|
||||
platform: "ios",
|
||||
mode: GATEWAY_CLIENT_MODES.NODE,
|
||||
role: "node",
|
||||
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();
|
||||
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 waitForPairRequest = async (baseDir: string, nodeId: string, timeoutMs = 10_000) => {
|
||||
const waitForNodeStatus = async (inst: GatewayInstance, nodeId: string, timeoutMs = 10_000) => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const list = (await listNodePairing(baseDir)) as PairingList;
|
||||
const match = list.pending.find((p) => p.nodeId === nodeId);
|
||||
if (match?.requestId) return match.requestId;
|
||||
const list = (await runCliJson(
|
||||
["nodes", "status", "--json", "--url", `ws://127.0.0.1:${inst.port}`],
|
||||
{
|
||||
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);
|
||||
}
|
||||
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;
|
||||
throw new Error(`timeout waiting for node status for ${nodeId}`);
|
||||
};
|
||||
|
||||
describe("gateway multi-instance e2e", () => {
|
||||
const instances: GatewayInstance[] = [];
|
||||
const nodeClients: GatewayClient[] = [];
|
||||
|
||||
afterAll(async () => {
|
||||
for (const client of nodeClients) {
|
||||
client.stop();
|
||||
}
|
||||
for (const inst of instances) {
|
||||
await stopGatewayInstance(inst);
|
||||
}
|
||||
@@ -421,32 +398,14 @@ describe("gateway multi-instance e2e", () => {
|
||||
expect(hookResB.status).toBe(200);
|
||||
expect((hookResB.json as { ok?: boolean } | undefined)?.ok).toBe(true);
|
||||
|
||||
const nodeASocket = await pairNode(gwA, "node-a");
|
||||
const nodeBSocket = await pairNode(gwB, "node-b");
|
||||
const nodeA = await connectNode(gwA, "node-a");
|
||||
const nodeB = await connectNode(gwB, "node-b");
|
||||
nodeClients.push(nodeA.client, nodeB.client);
|
||||
|
||||
const [nodeListA, nodeListB] = (await Promise.all([
|
||||
runCliJson(["nodes", "status", "--json", "--url", `ws://127.0.0.1:${gwA.port}`], {
|
||||
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();
|
||||
await Promise.all([
|
||||
waitForNodeStatus(gwA, nodeA.nodeId),
|
||||
waitForNodeStatus(gwB, nodeB.nodeId),
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -43,6 +43,7 @@ export async function patchSession(
|
||||
state: SessionsState,
|
||||
key: string,
|
||||
patch: {
|
||||
label?: string | null;
|
||||
thinkingLevel?: string | null;
|
||||
verboseLevel?: string | null;
|
||||
reasoningLevel?: string | null;
|
||||
@@ -50,6 +51,7 @@ export async function patchSession(
|
||||
) {
|
||||
if (!state.client || !state.connected) return;
|
||||
const params: Record<string, unknown> = { key };
|
||||
if ("label" in patch) params.label = patch.label;
|
||||
if ("thinkingLevel" in patch) params.thinkingLevel = patch.thinkingLevel;
|
||||
if ("verboseLevel" in patch) params.verboseLevel = patch.verboseLevel;
|
||||
if ("reasoningLevel" in patch) params.reasoningLevel = patch.reasoningLevel;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ export type SessionsProps = {
|
||||
onPatch: (
|
||||
key: string,
|
||||
patch: {
|
||||
label?: string | null;
|
||||
thinkingLevel?: string | null;
|
||||
verboseLevel?: string | null;
|
||||
reasoningLevel?: string | null;
|
||||
@@ -195,7 +196,19 @@ function renderRow(
|
||||
<div class="mono">${canLink
|
||||
? html`<a href=${chatUrl} class="session-link">${displayName}</a>`
|
||||
: displayName}</div>
|
||||
<div>${row.label ?? ""}</div>
|
||||
<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>${updated}</div>
|
||||
<div>${formatSessionTokens(row)}</div>
|
||||
|
||||
+2
-1
@@ -4,6 +4,7 @@ import { defineConfig } from "vitest/config";
|
||||
|
||||
const repoRoot = path.dirname(fileURLToPath(import.meta.url));
|
||||
const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true";
|
||||
const localWorkers = 4;
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
@@ -15,7 +16,7 @@ export default defineConfig({
|
||||
testTimeout: 60_000,
|
||||
hookTimeout: 120_000,
|
||||
pool: "forks",
|
||||
maxWorkers: isCI ? 3 : 4,
|
||||
maxWorkers: isCI ? 3 : localWorkers,
|
||||
include: [
|
||||
"src/**/*.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
Reference in new issue
Block a user