Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
caf9dec89c | ||
|
|
438a41f91f | ||
|
|
a0cd295c0f | ||
|
|
14d3d72bcc | ||
|
|
03916ed10e | ||
|
|
5bd55037e4 | ||
|
|
ec51bb700c | ||
|
|
051d518078 | ||
|
|
294886b54f | ||
|
|
6629e31789 | ||
|
|
9d7087168f | ||
|
|
daceeaa24c | ||
|
|
778800be70 | ||
|
|
1b973caf7a | ||
|
|
ea775025c0 | ||
|
|
0b2830470c | ||
|
|
e81ca7ab00 | ||
|
|
27acfa59c5 | ||
|
|
b333c4a994 | ||
|
|
23f7dd8b25 | ||
|
|
77d9451712 | ||
|
|
a4fc2b4536 | ||
|
|
20a7dd8a80 | ||
|
|
450d2d25e2 | ||
|
|
df024afc97 | ||
|
|
12168dc64f | ||
|
|
4232081fcb | ||
|
|
17f3635109 | ||
|
|
9206d21c76 | ||
|
|
96be166bd6 | ||
|
|
d8abd53a1d | ||
|
|
eff292eda4 | ||
|
|
c74551c2ae | ||
|
|
48b0d08493 | ||
|
|
dd38185e6c | ||
|
|
ec01e5c7e6 | ||
|
|
e447233533 | ||
|
|
00bcb01bb4 | ||
|
|
458850483a | ||
|
|
76bae8da40 | ||
|
|
c33c0629ec | ||
|
|
e5ea8a0d22 | ||
|
|
e083f678fd | ||
|
|
4ad9622efb | ||
|
|
7e59c15496 | ||
|
|
079af0d0b0 |
No files matched your search
+14
-1
@@ -9,6 +9,7 @@ Docs: https://docs.clawd.bot
|
|||||||
- Repo: remove the Peekaboo git submodule now that the SPM release is used.
|
- Repo: remove the Peekaboo git submodule now that the SPM release is used.
|
||||||
- Update: sync plugin sources on channel switches and update npm-installed plugins during `clawdbot update`.
|
- Update: sync plugin sources on channel switches and update npm-installed plugins during `clawdbot update`.
|
||||||
- Plugins: share npm plugin update logic between `clawdbot update` and `clawdbot plugins update`.
|
- Plugins: share npm plugin update logic between `clawdbot update` and `clawdbot plugins update`.
|
||||||
|
- Browser: allow config defaults for efficient snapshots in the tool/CLI. (#1336) — thanks @sebslight.
|
||||||
- Channels: add the Nostr plugin channel with profile management + onboarding install defaults. (#1323) — thanks @joelklabo.
|
- Channels: add the Nostr plugin channel with profile management + onboarding install defaults. (#1323) — thanks @joelklabo.
|
||||||
- Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow.
|
- Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow.
|
||||||
- Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel.
|
- Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel.
|
||||||
@@ -18,20 +19,32 @@ Docs: https://docs.clawd.bot
|
|||||||
- Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) — thanks @steipete.
|
- Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) — thanks @steipete.
|
||||||
- Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) — thanks @suminhthanh.
|
- Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) — thanks @suminhthanh.
|
||||||
- Security: warn when <=300B models run without sandboxing and with web tools enabled.
|
- Security: warn when <=300B models run without sandboxing and with web tools enabled.
|
||||||
|
- Skills: add download installs with OS-filtered install options; add local sherpa-onnx-tts skill.
|
||||||
|
- Docs: clarify WhatsApp voice notes and Windows WSL portproxy LAN access notes.
|
||||||
|
- UI: add copy-as-markdown with error feedback and drop legacy list view. (#1345) — thanks @bradleypriest.
|
||||||
### Fixes
|
### Fixes
|
||||||
- Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs.
|
- Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs.
|
||||||
- Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241) — thanks @gnarco.
|
- Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241) — thanks @gnarco.
|
||||||
- Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs.
|
- Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs.
|
||||||
- Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry.
|
- Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry.
|
||||||
|
- Diagnostics: emit message-flow diagnostics across channels via shared dispatch; gate heartbeat/webhook logging. (#1244) — thanks @oscargavin.
|
||||||
|
- CLI: preserve cron delivery settings when editing message payloads. (#1322) — thanks @KrauseFx.
|
||||||
|
- CLI: keep `clawdbot logs` output resilient to broken pipes while preserving progress output.
|
||||||
- Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332) — thanks @dougvk.
|
- Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332) — thanks @dougvk.
|
||||||
- Doctor: clarify plugin auto-enable hint text in the startup banner.
|
- Doctor: clarify plugin auto-enable hint text in the startup banner.
|
||||||
- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.
|
- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.
|
||||||
|
- Gateway: clarify connect/validation errors for gateway params. (#1347) — thanks @vignesh07.
|
||||||
|
- Gateway: preserve restart wake routing + thread replies across restarts. (#1337) — thanks @John-Rood.
|
||||||
|
- Gateway: reschedule per-agent heartbeats on config hot reload without restarting the runner.
|
||||||
|
- Config: log invalid config issues once per run and keep invalid-config errors stackless.
|
||||||
- UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315) — thanks @MaudeBot.
|
- UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315) — thanks @MaudeBot.
|
||||||
|
- UI: preserve ordered list numbering in chat markdown. (#1341) — thanks @bradleypriest.
|
||||||
|
- UI: allow Control UI to read gatewayUrl from URL params for remote WebSocket targets. (#1342) — thanks @ameno-.
|
||||||
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
|
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
|
||||||
|
- Web fetch: harden SSRF protection with shared hostname checks and redirect limits. (#1346) — thanks @fogboots.
|
||||||
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.
|
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.
|
||||||
- TUI: align custom editor initialization with the latest pi-tui API. (#1298) — thanks @sibbl.
|
- TUI: align custom editor initialization with the latest pi-tui API. (#1298) — thanks @sibbl.
|
||||||
- CLI: avoid duplicating --profile/--dev flags when formatting commands.
|
- CLI: avoid duplicating --profile/--dev flags when formatting commands.
|
||||||
- CLI: load channel plugins for commands that need registry-backed lookups. (#1338) — thanks @MaudeBot.
|
|
||||||
- Status: route native `/status` to the active agent so model selection reflects the correct profile. (#1301)
|
- Status: route native `/status` to the active agent so model selection reflects the correct profile. (#1301)
|
||||||
- Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297) — thanks @ysqander.
|
- Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297) — thanks @ysqander.
|
||||||
- Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304)
|
- Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304)
|
||||||
|
|||||||
@@ -479,27 +479,27 @@ Core contributors:
|
|||||||
Thanks to all clawtributors:
|
Thanks to all clawtributors:
|
||||||
|
|
||||||
<p align="left">
|
<p align="left">
|
||||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a>
|
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a>
|
||||||
<a href="https://github.com/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/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/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/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/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a>
|
<a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a>
|
||||||
<a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a>
|
<a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a>
|
||||||
<a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a>
|
<a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a>
|
||||||
<a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a>
|
<a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a>
|
||||||
<a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a>
|
<a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a>
|
||||||
<a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a>
|
<a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a>
|
||||||
<a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a>
|
<a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a>
|
||||||
<a href="https://github.com/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/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a>
|
||||||
<a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a>
|
<a href="https://github.com/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/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a>
|
<a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a>
|
||||||
<a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a>
|
<a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a>
|
||||||
<a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a>
|
<a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a>
|
||||||
<a href="https://github.com/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/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/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/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a>
|
||||||
<a href="https://github.com/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/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/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/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=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a>
|
<a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a>
|
||||||
<a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a>
|
<a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a>
|
||||||
<a href="https://github.com/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/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/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/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||||
</p>
|
</p>
|
||||||
@@ -6,15 +6,19 @@ struct ConfigSettings: View {
|
|||||||
private let isNixMode = ProcessInfo.processInfo.isNixMode
|
private let isNixMode = ProcessInfo.processInfo.isNixMode
|
||||||
@Bindable var store: ChannelsStore
|
@Bindable var store: ChannelsStore
|
||||||
@State private var hasLoaded = false
|
@State private var hasLoaded = false
|
||||||
|
@State private var activeSectionKey: String?
|
||||||
|
@State private var activeSubsection: SubsectionSelection?
|
||||||
|
|
||||||
init(store: ChannelsStore = .shared) {
|
init(store: ChannelsStore = .shared) {
|
||||||
self.store = store
|
self.store = store
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
HStack(spacing: 16) {
|
||||||
self.content
|
self.sidebar
|
||||||
|
self.detail
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
.task {
|
.task {
|
||||||
guard !self.hasLoaded else { return }
|
guard !self.hasLoaded else { return }
|
||||||
guard !self.isPreview else { return }
|
guard !self.isPreview else { return }
|
||||||
@@ -22,42 +26,125 @@ struct ConfigSettings: View {
|
|||||||
await self.store.loadConfigSchema()
|
await self.store.loadConfigSchema()
|
||||||
await self.store.loadConfig()
|
await self.store.loadConfig()
|
||||||
}
|
}
|
||||||
|
.onAppear { self.ensureSelection() }
|
||||||
|
.onChange(of: self.store.configSchemaLoading) { _, loading in
|
||||||
|
if !loading { self.ensureSelection() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ConfigSettings {
|
extension ConfigSettings {
|
||||||
private var content: some View {
|
private enum SubsectionSelection: Hashable {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
case all
|
||||||
self.header
|
case key(String)
|
||||||
if let status = self.store.configStatus {
|
}
|
||||||
Text(status)
|
|
||||||
.font(.callout)
|
private struct ConfigSection: Identifiable {
|
||||||
.foregroundStyle(.secondary)
|
let key: String
|
||||||
}
|
let label: String
|
||||||
self.actionRow
|
let help: String?
|
||||||
Group {
|
let node: ConfigSchemaNode
|
||||||
if self.store.configSchemaLoading {
|
|
||||||
ProgressView().controlSize(.small)
|
var id: String { self.key }
|
||||||
} else if let schema = self.store.configSchema {
|
}
|
||||||
ConfigSchemaForm(store: self.store, schema: schema, path: [])
|
|
||||||
.disabled(self.isNixMode)
|
private struct ConfigSubsection: Identifiable {
|
||||||
} else {
|
let key: String
|
||||||
Text("Schema unavailable.")
|
let label: String
|
||||||
|
let help: String?
|
||||||
|
let node: ConfigSchemaNode
|
||||||
|
let path: ConfigPath
|
||||||
|
|
||||||
|
var id: String { self.key }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sections: [ConfigSection] {
|
||||||
|
guard let schema = self.store.configSchema else { return [] }
|
||||||
|
return self.resolveSections(schema)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var activeSection: ConfigSection? {
|
||||||
|
self.sections.first { $0.key == self.activeSectionKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sidebar: some View {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 8) {
|
||||||
|
if self.sections.isEmpty {
|
||||||
|
Text("No config sections available.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
} else {
|
||||||
|
ForEach(self.sections) { section in
|
||||||
|
self.sidebarRow(section)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if self.store.configDirty, !self.isNixMode {
|
.padding(.vertical, 10)
|
||||||
Text("Unsaved changes")
|
.padding(.horizontal, 10)
|
||||||
|
}
|
||||||
|
.frame(minWidth: 220, idealWidth: 240, maxWidth: 280, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
|
.fill(Color(nsColor: .windowBackgroundColor)))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var detail: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
if self.store.configSchemaLoading {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
} else if let section = self.activeSection {
|
||||||
|
self.sectionDetail(section)
|
||||||
|
} else if self.store.configSchema != nil {
|
||||||
|
self.emptyDetail
|
||||||
|
} else {
|
||||||
|
Text("Schema unavailable.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Spacer(minLength: 0)
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(minWidth: 460, maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var emptyDetail: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
self.header
|
||||||
|
Text("Select a config section to view settings.")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
.padding(.vertical, 18)
|
.padding(.vertical, 18)
|
||||||
.groupBoxStyle(PlainSettingsGroupBoxStyle())
|
}
|
||||||
|
|
||||||
|
private func sectionDetail(_ section: ConfigSection) -> some View {
|
||||||
|
ScrollView(.vertical) {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
self.header
|
||||||
|
if let status = self.store.configStatus {
|
||||||
|
Text(status)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
self.actionRow
|
||||||
|
self.sectionHeader(section)
|
||||||
|
self.subsectionNav(section)
|
||||||
|
self.sectionForm(section)
|
||||||
|
if self.store.configDirty, !self.isNixMode {
|
||||||
|
Text("Unsaved changes")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.vertical, 18)
|
||||||
|
.groupBoxStyle(PlainSettingsGroupBoxStyle())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -71,6 +158,18 @@ extension ConfigSettings {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func sectionHeader(_ section: ConfigSection) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(section.label)
|
||||||
|
.font(.title3.weight(.semibold))
|
||||||
|
if let help = section.help {
|
||||||
|
Text(help)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var actionRow: some View {
|
private var actionRow: some View {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
Button("Reload") {
|
Button("Reload") {
|
||||||
@@ -85,6 +184,203 @@ extension ConfigSettings {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func sidebarRow(_ section: ConfigSection) -> some View {
|
||||||
|
let isSelected = self.activeSectionKey == section.key
|
||||||
|
return Button {
|
||||||
|
self.selectSection(section)
|
||||||
|
} label: {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(section.label)
|
||||||
|
if let help = section.help {
|
||||||
|
Text(help)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(isSelected ? Color.accentColor.opacity(0.18) : Color.clear)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||||
|
.background(Color.clear)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func subsectionNav(_ section: ConfigSection) -> some View {
|
||||||
|
let subsections = self.resolveSubsections(for: section)
|
||||||
|
if subsections.isEmpty {
|
||||||
|
EmptyView()
|
||||||
|
} else {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
self.subsectionButton(
|
||||||
|
title: "All",
|
||||||
|
isSelected: self.activeSubsection == .all)
|
||||||
|
{
|
||||||
|
self.activeSubsection = .all
|
||||||
|
}
|
||||||
|
ForEach(subsections) { subsection in
|
||||||
|
self.subsectionButton(
|
||||||
|
title: subsection.label,
|
||||||
|
isSelected: self.activeSubsection == .key(subsection.key))
|
||||||
|
{
|
||||||
|
self.activeSubsection = .key(subsection.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func subsectionButton(
|
||||||
|
title: String,
|
||||||
|
isSelected: Bool,
|
||||||
|
action: @escaping () -> Void) -> some View
|
||||||
|
{
|
||||||
|
Button(action: action) {
|
||||||
|
Text(title)
|
||||||
|
.font(.callout.weight(.semibold))
|
||||||
|
.foregroundStyle(isSelected ? Color.accentColor : .primary)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(isSelected ? Color.accentColor.opacity(0.18) : Color(nsColor: .controlBackgroundColor))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sectionForm(_ section: ConfigSection) -> some View {
|
||||||
|
let subsection = self.activeSubsection
|
||||||
|
let defaultPath: ConfigPath = [.key(section.key)]
|
||||||
|
let subsections = self.resolveSubsections(for: section)
|
||||||
|
let resolved: (ConfigSchemaNode, ConfigPath) = {
|
||||||
|
if case let .key(key) = subsection,
|
||||||
|
let match = subsections.first(where: { $0.key == key }) {
|
||||||
|
return (match.node, match.path)
|
||||||
|
}
|
||||||
|
return (self.resolvedSchemaNode(section.node), defaultPath)
|
||||||
|
}()
|
||||||
|
|
||||||
|
return ConfigSchemaForm(store: self.store, schema: resolved.0, path: resolved.1)
|
||||||
|
.disabled(self.isNixMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensureSelection() {
|
||||||
|
guard let schema = self.store.configSchema else { return }
|
||||||
|
let sections = self.resolveSections(schema)
|
||||||
|
guard !sections.isEmpty else { return }
|
||||||
|
|
||||||
|
let active = sections.first { $0.key == self.activeSectionKey } ?? sections[0]
|
||||||
|
if self.activeSectionKey != active.key {
|
||||||
|
self.activeSectionKey = active.key
|
||||||
|
}
|
||||||
|
self.ensureSubsection(for: active)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensureSubsection(for section: ConfigSection) {
|
||||||
|
let subsections = self.resolveSubsections(for: section)
|
||||||
|
guard !subsections.isEmpty else {
|
||||||
|
self.activeSubsection = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch self.activeSubsection {
|
||||||
|
case .all:
|
||||||
|
return
|
||||||
|
case let .key(key):
|
||||||
|
if subsections.contains(where: { $0.key == key }) { return }
|
||||||
|
case .none:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if let first = subsections.first {
|
||||||
|
self.activeSubsection = .key(first.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func selectSection(_ section: ConfigSection) {
|
||||||
|
guard self.activeSectionKey != section.key else { return }
|
||||||
|
self.activeSectionKey = section.key
|
||||||
|
let subsections = self.resolveSubsections(for: section)
|
||||||
|
if let first = subsections.first {
|
||||||
|
self.activeSubsection = .key(first.key)
|
||||||
|
} else {
|
||||||
|
self.activeSubsection = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveSections(_ root: ConfigSchemaNode) -> [ConfigSection] {
|
||||||
|
let node = self.resolvedSchemaNode(root)
|
||||||
|
let hints = self.store.configUiHints
|
||||||
|
let keys = node.properties.keys.sorted { lhs, rhs in
|
||||||
|
let orderA = hintForPath([.key(lhs)], hints: hints)?.order ?? 0
|
||||||
|
let orderB = hintForPath([.key(rhs)], hints: hints)?.order ?? 0
|
||||||
|
if orderA != orderB { return orderA < orderB }
|
||||||
|
return lhs < rhs
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys.compactMap { key in
|
||||||
|
guard let child = node.properties[key] else { return nil }
|
||||||
|
let path: ConfigPath = [.key(key)]
|
||||||
|
let hint = hintForPath(path, hints: hints)
|
||||||
|
let label = hint?.label
|
||||||
|
?? child.title
|
||||||
|
?? self.humanize(key)
|
||||||
|
let help = hint?.help ?? child.description
|
||||||
|
return ConfigSection(key: key, label: label, help: help, node: child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveSubsections(for section: ConfigSection) -> [ConfigSubsection] {
|
||||||
|
let node = self.resolvedSchemaNode(section.node)
|
||||||
|
guard node.schemaType == "object" else { return [] }
|
||||||
|
let hints = self.store.configUiHints
|
||||||
|
let keys = node.properties.keys.sorted { lhs, rhs in
|
||||||
|
let orderA = hintForPath([.key(section.key), .key(lhs)], hints: hints)?.order ?? 0
|
||||||
|
let orderB = hintForPath([.key(section.key), .key(rhs)], hints: hints)?.order ?? 0
|
||||||
|
if orderA != orderB { return orderA < orderB }
|
||||||
|
return lhs < rhs
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys.compactMap { key in
|
||||||
|
guard let child = node.properties[key] else { return nil }
|
||||||
|
let path: ConfigPath = [.key(section.key), .key(key)]
|
||||||
|
let hint = hintForPath(path, hints: hints)
|
||||||
|
let label = hint?.label
|
||||||
|
?? child.title
|
||||||
|
?? self.humanize(key)
|
||||||
|
let help = hint?.help ?? child.description
|
||||||
|
return ConfigSubsection(
|
||||||
|
key: key,
|
||||||
|
label: label,
|
||||||
|
help: help,
|
||||||
|
node: child,
|
||||||
|
path: path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolvedSchemaNode(_ node: ConfigSchemaNode) -> ConfigSchemaNode {
|
||||||
|
let variants = node.anyOf.isEmpty ? node.oneOf : node.anyOf
|
||||||
|
if !variants.isEmpty {
|
||||||
|
let nonNull = variants.filter { !$0.isNullSchema }
|
||||||
|
if nonNull.count == 1, let only = nonNull.first { return only }
|
||||||
|
}
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
private func humanize(_ key: String) -> String {
|
||||||
|
key.replacingOccurrences(of: "_", with: " ")
|
||||||
|
.replacingOccurrences(of: "-", with: " ")
|
||||||
|
.capitalized
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ConfigSettings_Previews: PreviewProvider {
|
struct ConfigSettings_Previews: PreviewProvider {
|
||||||
|
|||||||
@@ -554,13 +554,16 @@ actor GatewayEndpointStore {
|
|||||||
{
|
{
|
||||||
switch bindMode {
|
switch bindMode {
|
||||||
case "tailnet":
|
case "tailnet":
|
||||||
tailscaleIP ?? "127.0.0.1"
|
return tailscaleIP ?? "127.0.0.1"
|
||||||
case "auto":
|
case "auto":
|
||||||
"127.0.0.1"
|
if let tailscaleIP, !tailscaleIP.isEmpty {
|
||||||
|
return tailscaleIP
|
||||||
|
}
|
||||||
|
return "127.0.0.1"
|
||||||
case "custom":
|
case "custom":
|
||||||
customBindHost ?? "127.0.0.1"
|
return customBindHost ?? "127.0.0.1"
|
||||||
default:
|
default:
|
||||||
"127.0.0.1"
|
return "127.0.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import AppKit
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
import os
|
import os
|
||||||
|
#if canImport(Darwin)
|
||||||
|
import Darwin
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Manages Tailscale integration and status checking.
|
/// Manages Tailscale integration and status checking.
|
||||||
@Observable
|
@Observable
|
||||||
@@ -101,15 +104,12 @@ final class TailscaleService {
|
|||||||
|
|
||||||
func checkTailscaleStatus() async {
|
func checkTailscaleStatus() async {
|
||||||
self.isInstalled = self.checkAppInstallation()
|
self.isInstalled = self.checkAppInstallation()
|
||||||
guard self.isInstalled else {
|
if !self.isInstalled {
|
||||||
self.isRunning = false
|
self.isRunning = false
|
||||||
self.tailscaleHostname = nil
|
self.tailscaleHostname = nil
|
||||||
self.tailscaleIP = nil
|
self.tailscaleIP = nil
|
||||||
self.statusError = "Tailscale is not installed"
|
self.statusError = "Tailscale is not installed"
|
||||||
return
|
} else if let apiResponse = await fetchTailscaleStatus() {
|
||||||
}
|
|
||||||
|
|
||||||
if let apiResponse = await fetchTailscaleStatus() {
|
|
||||||
self.isRunning = apiResponse.status.lowercased() == "running"
|
self.isRunning = apiResponse.status.lowercased() == "running"
|
||||||
|
|
||||||
if self.isRunning {
|
if self.isRunning {
|
||||||
@@ -138,6 +138,15 @@ final class TailscaleService {
|
|||||||
self.statusError = "Please start the Tailscale app"
|
self.statusError = "Please start the Tailscale app"
|
||||||
self.logger.info("Tailscale API not responding; app likely not running")
|
self.logger.info("Tailscale API not responding; app likely not running")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.tailscaleIP == nil, let fallback = Self.detectTailnetIPv4() {
|
||||||
|
self.tailscaleIP = fallback
|
||||||
|
if !self.isRunning {
|
||||||
|
self.isRunning = true
|
||||||
|
}
|
||||||
|
self.statusError = nil
|
||||||
|
self.logger.info("Tailscale interface IP detected (fallback) ip=\(fallback, privacy: .public)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func openTailscaleApp() {
|
func openTailscaleApp() {
|
||||||
@@ -163,4 +172,46 @@ final class TailscaleService {
|
|||||||
NSWorkspace.shared.open(url)
|
NSWorkspace.shared.open(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func isTailnetIPv4(_ address: String) -> Bool {
|
||||||
|
let parts = address.split(separator: ".")
|
||||||
|
guard parts.count == 4 else { return false }
|
||||||
|
let octets = parts.compactMap { Int($0) }
|
||||||
|
guard octets.count == 4 else { return false }
|
||||||
|
let a = octets[0]
|
||||||
|
let b = octets[1]
|
||||||
|
return a == 100 && b >= 64 && b <= 127
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func detectTailnetIPv4() -> String? {
|
||||||
|
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||||
|
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||||
|
defer { freeifaddrs(addrList) }
|
||||||
|
|
||||||
|
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
|
||||||
|
let flags = Int32(ptr.pointee.ifa_flags)
|
||||||
|
let isUp = (flags & IFF_UP) != 0
|
||||||
|
let isLoopback = (flags & IFF_LOOPBACK) != 0
|
||||||
|
let family = ptr.pointee.ifa_addr.pointee.sa_family
|
||||||
|
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
|
||||||
|
|
||||||
|
var addr = ptr.pointee.ifa_addr.pointee
|
||||||
|
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||||
|
let result = getnameinfo(
|
||||||
|
&addr,
|
||||||
|
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
|
||||||
|
&buffer,
|
||||||
|
socklen_t(buffer.count),
|
||||||
|
nil,
|
||||||
|
0,
|
||||||
|
NI_NUMERICHOST)
|
||||||
|
guard result == 0 else { continue }
|
||||||
|
let len = buffer.prefix { $0 != 0 }
|
||||||
|
let bytes = len.map { UInt8(bitPattern: $0) }
|
||||||
|
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
|
||||||
|
if Self.isTailnetIPv4(ip) { return ip }
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import ClawdbotKit
|
import ClawdbotKit
|
||||||
import ClawdbotProtocol
|
import ClawdbotProtocol
|
||||||
import Foundation
|
import Foundation
|
||||||
|
#if canImport(Darwin)
|
||||||
|
import Darwin
|
||||||
|
#endif
|
||||||
|
|
||||||
struct ConnectOptions {
|
struct ConnectOptions {
|
||||||
var url: String?
|
var url: String?
|
||||||
@@ -268,7 +271,7 @@ private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig)
|
|||||||
}
|
}
|
||||||
|
|
||||||
let port = config.port ?? 18789
|
let port = config.port ?? 18789
|
||||||
let host = "127.0.0.1"
|
let host = resolveLocalHost(bind: config.bind)
|
||||||
guard let url = URL(string: "ws://\(host):\(port)") else {
|
guard let url = URL(string: "ws://\(host):\(port)") else {
|
||||||
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: ws://\(host):\(port)"])
|
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: ws://\(host):\(port)"])
|
||||||
}
|
}
|
||||||
@@ -304,3 +307,56 @@ private func resolvedPassword(opts: ConnectOptions, mode: String, config: Gatewa
|
|||||||
}
|
}
|
||||||
return config.password
|
return config.password
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func resolveLocalHost(bind: String?) -> String {
|
||||||
|
let normalized = (bind ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
let tailnetIP = detectTailnetIPv4()
|
||||||
|
switch normalized {
|
||||||
|
case "tailnet", "auto":
|
||||||
|
return tailnetIP ?? "127.0.0.1"
|
||||||
|
default:
|
||||||
|
return "127.0.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func detectTailnetIPv4() -> String? {
|
||||||
|
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||||
|
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||||
|
defer { freeifaddrs(addrList) }
|
||||||
|
|
||||||
|
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
|
||||||
|
let flags = Int32(ptr.pointee.ifa_flags)
|
||||||
|
let isUp = (flags & IFF_UP) != 0
|
||||||
|
let isLoopback = (flags & IFF_LOOPBACK) != 0
|
||||||
|
let family = ptr.pointee.ifa_addr.pointee.sa_family
|
||||||
|
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
|
||||||
|
|
||||||
|
var addr = ptr.pointee.ifa_addr.pointee
|
||||||
|
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||||
|
let result = getnameinfo(
|
||||||
|
&addr,
|
||||||
|
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
|
||||||
|
&buffer,
|
||||||
|
socklen_t(buffer.count),
|
||||||
|
nil,
|
||||||
|
0,
|
||||||
|
NI_NUMERICHOST)
|
||||||
|
guard result == 0 else { continue }
|
||||||
|
let len = buffer.prefix { $0 != 0 }
|
||||||
|
let bytes = len.map { UInt8(bitPattern: $0) }
|
||||||
|
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
|
||||||
|
if isTailnetIPv4(ip) { return ip }
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isTailnetIPv4(_ address: String) -> Bool {
|
||||||
|
let parts = address.split(separator: ".")
|
||||||
|
guard parts.count == 4 else { return false }
|
||||||
|
let octets = parts.compactMap { Int($0) }
|
||||||
|
guard octets.count == 4 else { return false }
|
||||||
|
let a = octets[0]
|
||||||
|
let b = octets[1]
|
||||||
|
return a == 100 && b >= 64 && b <= 127
|
||||||
|
}
|
||||||
@@ -473,6 +473,7 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
public let replychannel: String?
|
public let replychannel: String?
|
||||||
public let accountid: String?
|
public let accountid: String?
|
||||||
public let replyaccountid: String?
|
public let replyaccountid: String?
|
||||||
|
public let threadid: String?
|
||||||
public let timeout: Int?
|
public let timeout: Int?
|
||||||
public let lane: String?
|
public let lane: String?
|
||||||
public let extrasystemprompt: String?
|
public let extrasystemprompt: String?
|
||||||
@@ -494,6 +495,7 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
replychannel: String?,
|
replychannel: String?,
|
||||||
accountid: String?,
|
accountid: String?,
|
||||||
replyaccountid: String?,
|
replyaccountid: String?,
|
||||||
|
threadid: String?,
|
||||||
timeout: Int?,
|
timeout: Int?,
|
||||||
lane: String?,
|
lane: String?,
|
||||||
extrasystemprompt: String?,
|
extrasystemprompt: String?,
|
||||||
@@ -514,6 +516,7 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
self.replychannel = replychannel
|
self.replychannel = replychannel
|
||||||
self.accountid = accountid
|
self.accountid = accountid
|
||||||
self.replyaccountid = replyaccountid
|
self.replyaccountid = replyaccountid
|
||||||
|
self.threadid = threadid
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.lane = lane
|
self.lane = lane
|
||||||
self.extrasystemprompt = extrasystemprompt
|
self.extrasystemprompt = extrasystemprompt
|
||||||
@@ -535,6 +538,7 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
case replychannel = "replyChannel"
|
case replychannel = "replyChannel"
|
||||||
case accountid = "accountId"
|
case accountid = "accountId"
|
||||||
case replyaccountid = "replyAccountId"
|
case replyaccountid = "replyAccountId"
|
||||||
|
case threadid = "threadId"
|
||||||
case timeout
|
case timeout
|
||||||
case lane
|
case lane
|
||||||
case extrasystemprompt = "extraSystemPrompt"
|
case extrasystemprompt = "extraSystemPrompt"
|
||||||
@@ -835,35 +839,47 @@ public struct SessionsListParams: Codable, Sendable {
|
|||||||
public let activeminutes: Int?
|
public let activeminutes: Int?
|
||||||
public let includeglobal: Bool?
|
public let includeglobal: Bool?
|
||||||
public let includeunknown: Bool?
|
public let includeunknown: Bool?
|
||||||
|
public let includederivedtitles: Bool?
|
||||||
|
public let includelastmessage: Bool?
|
||||||
public let label: String?
|
public let label: String?
|
||||||
public let spawnedby: String?
|
public let spawnedby: String?
|
||||||
public let agentid: String?
|
public let agentid: String?
|
||||||
|
public let search: String?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
limit: Int?,
|
limit: Int?,
|
||||||
activeminutes: Int?,
|
activeminutes: Int?,
|
||||||
includeglobal: Bool?,
|
includeglobal: Bool?,
|
||||||
includeunknown: Bool?,
|
includeunknown: Bool?,
|
||||||
|
includederivedtitles: Bool?,
|
||||||
|
includelastmessage: Bool?,
|
||||||
label: String?,
|
label: String?,
|
||||||
spawnedby: String?,
|
spawnedby: String?,
|
||||||
agentid: String?
|
agentid: String?,
|
||||||
|
search: String?
|
||||||
) {
|
) {
|
||||||
self.limit = limit
|
self.limit = limit
|
||||||
self.activeminutes = activeminutes
|
self.activeminutes = activeminutes
|
||||||
self.includeglobal = includeglobal
|
self.includeglobal = includeglobal
|
||||||
self.includeunknown = includeunknown
|
self.includeunknown = includeunknown
|
||||||
|
self.includederivedtitles = includederivedtitles
|
||||||
|
self.includelastmessage = includelastmessage
|
||||||
self.label = label
|
self.label = label
|
||||||
self.spawnedby = spawnedby
|
self.spawnedby = spawnedby
|
||||||
self.agentid = agentid
|
self.agentid = agentid
|
||||||
|
self.search = search
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case limit
|
case limit
|
||||||
case activeminutes = "activeMinutes"
|
case activeminutes = "activeMinutes"
|
||||||
case includeglobal = "includeGlobal"
|
case includeglobal = "includeGlobal"
|
||||||
case includeunknown = "includeUnknown"
|
case includeunknown = "includeUnknown"
|
||||||
|
case includederivedtitles = "includeDerivedTitles"
|
||||||
|
case includelastmessage = "includeLastMessage"
|
||||||
case label
|
case label
|
||||||
case spawnedby = "spawnedBy"
|
case spawnedby = "spawnedBy"
|
||||||
case agentid = "agentId"
|
case agentid = "agentId"
|
||||||
|
case search
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -473,6 +473,7 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
public let replychannel: String?
|
public let replychannel: String?
|
||||||
public let accountid: String?
|
public let accountid: String?
|
||||||
public let replyaccountid: String?
|
public let replyaccountid: String?
|
||||||
|
public let threadid: String?
|
||||||
public let timeout: Int?
|
public let timeout: Int?
|
||||||
public let lane: String?
|
public let lane: String?
|
||||||
public let extrasystemprompt: String?
|
public let extrasystemprompt: String?
|
||||||
@@ -494,6 +495,7 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
replychannel: String?,
|
replychannel: String?,
|
||||||
accountid: String?,
|
accountid: String?,
|
||||||
replyaccountid: String?,
|
replyaccountid: String?,
|
||||||
|
threadid: String?,
|
||||||
timeout: Int?,
|
timeout: Int?,
|
||||||
lane: String?,
|
lane: String?,
|
||||||
extrasystemprompt: String?,
|
extrasystemprompt: String?,
|
||||||
@@ -514,6 +516,7 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
self.replychannel = replychannel
|
self.replychannel = replychannel
|
||||||
self.accountid = accountid
|
self.accountid = accountid
|
||||||
self.replyaccountid = replyaccountid
|
self.replyaccountid = replyaccountid
|
||||||
|
self.threadid = threadid
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.lane = lane
|
self.lane = lane
|
||||||
self.extrasystemprompt = extrasystemprompt
|
self.extrasystemprompt = extrasystemprompt
|
||||||
@@ -535,6 +538,7 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
case replychannel = "replyChannel"
|
case replychannel = "replyChannel"
|
||||||
case accountid = "accountId"
|
case accountid = "accountId"
|
||||||
case replyaccountid = "replyAccountId"
|
case replyaccountid = "replyAccountId"
|
||||||
|
case threadid = "threadId"
|
||||||
case timeout
|
case timeout
|
||||||
case lane
|
case lane
|
||||||
case extrasystemprompt = "extraSystemPrompt"
|
case extrasystemprompt = "extraSystemPrompt"
|
||||||
@@ -835,35 +839,47 @@ public struct SessionsListParams: Codable, Sendable {
|
|||||||
public let activeminutes: Int?
|
public let activeminutes: Int?
|
||||||
public let includeglobal: Bool?
|
public let includeglobal: Bool?
|
||||||
public let includeunknown: Bool?
|
public let includeunknown: Bool?
|
||||||
|
public let includederivedtitles: Bool?
|
||||||
|
public let includelastmessage: Bool?
|
||||||
public let label: String?
|
public let label: String?
|
||||||
public let spawnedby: String?
|
public let spawnedby: String?
|
||||||
public let agentid: String?
|
public let agentid: String?
|
||||||
|
public let search: String?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
limit: Int?,
|
limit: Int?,
|
||||||
activeminutes: Int?,
|
activeminutes: Int?,
|
||||||
includeglobal: Bool?,
|
includeglobal: Bool?,
|
||||||
includeunknown: Bool?,
|
includeunknown: Bool?,
|
||||||
|
includederivedtitles: Bool?,
|
||||||
|
includelastmessage: Bool?,
|
||||||
label: String?,
|
label: String?,
|
||||||
spawnedby: String?,
|
spawnedby: String?,
|
||||||
agentid: String?
|
agentid: String?,
|
||||||
|
search: String?
|
||||||
) {
|
) {
|
||||||
self.limit = limit
|
self.limit = limit
|
||||||
self.activeminutes = activeminutes
|
self.activeminutes = activeminutes
|
||||||
self.includeglobal = includeglobal
|
self.includeglobal = includeglobal
|
||||||
self.includeunknown = includeunknown
|
self.includeunknown = includeunknown
|
||||||
|
self.includederivedtitles = includederivedtitles
|
||||||
|
self.includelastmessage = includelastmessage
|
||||||
self.label = label
|
self.label = label
|
||||||
self.spawnedby = spawnedby
|
self.spawnedby = spawnedby
|
||||||
self.agentid = agentid
|
self.agentid = agentid
|
||||||
|
self.search = search
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case limit
|
case limit
|
||||||
case activeminutes = "activeMinutes"
|
case activeminutes = "activeMinutes"
|
||||||
case includeglobal = "includeGlobal"
|
case includeglobal = "includeGlobal"
|
||||||
case includeunknown = "includeUnknown"
|
case includeunknown = "includeUnknown"
|
||||||
|
case includederivedtitles = "includeDerivedTitles"
|
||||||
|
case includelastmessage = "includeLastMessage"
|
||||||
case label
|
case label
|
||||||
case spawnedby = "spawnedBy"
|
case spawnedby = "spawnedBy"
|
||||||
case agentid = "agentId"
|
case agentid = "agentId"
|
||||||
|
case search
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1324,6 +1340,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
|
|||||||
public let ts: Int
|
public let ts: Int
|
||||||
public let channelorder: [String]
|
public let channelorder: [String]
|
||||||
public let channellabels: [String: AnyCodable]
|
public let channellabels: [String: AnyCodable]
|
||||||
|
public let channeldetaillabels: [String: AnyCodable]?
|
||||||
|
public let channelsystemimages: [String: AnyCodable]?
|
||||||
|
public let channelmeta: [[String: AnyCodable]]?
|
||||||
public let channels: [String: AnyCodable]
|
public let channels: [String: AnyCodable]
|
||||||
public let channelaccounts: [String: AnyCodable]
|
public let channelaccounts: [String: AnyCodable]
|
||||||
public let channeldefaultaccountid: [String: AnyCodable]
|
public let channeldefaultaccountid: [String: AnyCodable]
|
||||||
@@ -1332,6 +1351,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
|
|||||||
ts: Int,
|
ts: Int,
|
||||||
channelorder: [String],
|
channelorder: [String],
|
||||||
channellabels: [String: AnyCodable],
|
channellabels: [String: AnyCodable],
|
||||||
|
channeldetaillabels: [String: AnyCodable]?,
|
||||||
|
channelsystemimages: [String: AnyCodable]?,
|
||||||
|
channelmeta: [[String: AnyCodable]]?,
|
||||||
channels: [String: AnyCodable],
|
channels: [String: AnyCodable],
|
||||||
channelaccounts: [String: AnyCodable],
|
channelaccounts: [String: AnyCodable],
|
||||||
channeldefaultaccountid: [String: AnyCodable]
|
channeldefaultaccountid: [String: AnyCodable]
|
||||||
@@ -1339,6 +1361,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
|
|||||||
self.ts = ts
|
self.ts = ts
|
||||||
self.channelorder = channelorder
|
self.channelorder = channelorder
|
||||||
self.channellabels = channellabels
|
self.channellabels = channellabels
|
||||||
|
self.channeldetaillabels = channeldetaillabels
|
||||||
|
self.channelsystemimages = channelsystemimages
|
||||||
|
self.channelmeta = channelmeta
|
||||||
self.channels = channels
|
self.channels = channels
|
||||||
self.channelaccounts = channelaccounts
|
self.channelaccounts = channelaccounts
|
||||||
self.channeldefaultaccountid = channeldefaultaccountid
|
self.channeldefaultaccountid = channeldefaultaccountid
|
||||||
@@ -1347,6 +1372,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
|
|||||||
case ts
|
case ts
|
||||||
case channelorder = "channelOrder"
|
case channelorder = "channelOrder"
|
||||||
case channellabels = "channelLabels"
|
case channellabels = "channelLabels"
|
||||||
|
case channeldetaillabels = "channelDetailLabels"
|
||||||
|
case channelsystemimages = "channelSystemImages"
|
||||||
|
case channelmeta = "channelMeta"
|
||||||
case channels
|
case channels
|
||||||
case channelaccounts = "channelAccounts"
|
case channelaccounts = "channelAccounts"
|
||||||
case channeldefaultaccountid = "channelDefaultAccountId"
|
case channeldefaultaccountid = "channelDefaultAccountId"
|
||||||
|
|||||||
@@ -286,6 +286,11 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
|
|||||||
- CLI: `clawdbot message send --media <mp4> --gif-playback`
|
- CLI: `clawdbot message send --media <mp4> --gif-playback`
|
||||||
- Gateway: `send` params include `gifPlayback: true`
|
- Gateway: `send` params include `gifPlayback: true`
|
||||||
|
|
||||||
|
## Voice notes (PTT audio)
|
||||||
|
WhatsApp sends audio as **voice notes** (PTT bubble).
|
||||||
|
- Best results: OGG/Opus. Clawdbot rewrites `audio/ogg` to `audio/ogg; codecs=opus`.
|
||||||
|
- `[[audio_as_voice]]` is ignored for WhatsApp (audio already ships as voice note).
|
||||||
|
|
||||||
## Media limits + optimization
|
## Media limits + optimization
|
||||||
- Default outbound cap: 5 MB (per media item).
|
- Default outbound cap: 5 MB (per media item).
|
||||||
- Override: `agents.defaults.mediaMaxMb`.
|
- Override: `agents.defaults.mediaMaxMb`.
|
||||||
|
|||||||
@@ -14,3 +14,16 @@ Related:
|
|||||||
|
|
||||||
Tip: run `clawdbot cron --help` for the full command surface.
|
Tip: run `clawdbot cron --help` for the full command surface.
|
||||||
|
|
||||||
|
## Common edits
|
||||||
|
|
||||||
|
Update delivery settings without changing the message:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot cron edit <job-id> --deliver --channel telegram --to "123456789"
|
||||||
|
```
|
||||||
|
|
||||||
|
Disable delivery for an isolated job:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot cron edit <job-id> --no-deliver
|
||||||
|
```
|
||||||
+2
-1
@@ -23,10 +23,11 @@ clawdbot nodes approve <requestId>
|
|||||||
clawdbot nodes status
|
clawdbot nodes status
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`nodes list` prints pending/paired tables. Paired rows include the most recent connect age (Last Connect).
|
||||||
|
|
||||||
## Invoke / run
|
## Invoke / run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot nodes invoke --node <id|name|ip> --command <command> --params <json>
|
clawdbot nodes invoke --node <id|name|ip> --command <command> --params <json>
|
||||||
clawdbot nodes run --node <id|name|ip> <command...>
|
clawdbot nodes run --node <id|name|ip> <command...>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -21,4 +21,4 @@ clawdbot security audit --fix
|
|||||||
```
|
```
|
||||||
|
|
||||||
The audit warns when multiple DM senders share the main session and recommends `session.dmScope="per-channel-peer"` for shared inboxes.
|
The audit warns when multiple DM senders share the main session and recommends `session.dmScope="per-channel-peer"` for shared inboxes.
|
||||||
It also warns when small models (<=300B) are used without sandboxing and with web/browser tools enabled.
|
It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled.
|
||||||
@@ -77,6 +77,21 @@ Client Gateway
|
|||||||
safely retry; the server keeps a short‑lived dedupe cache.
|
safely retry; the server keeps a short‑lived dedupe cache.
|
||||||
- Nodes must include `role: "node"` plus caps/commands/permissions in `connect`.
|
- Nodes must include `role: "node"` plus caps/commands/permissions in `connect`.
|
||||||
|
|
||||||
|
## Pairing + local trust
|
||||||
|
|
||||||
|
- All WS clients (operators + nodes) include a **device identity** on `connect`.
|
||||||
|
- New device IDs require pairing approval; the Gateway issues a **device token**
|
||||||
|
for subsequent connects.
|
||||||
|
- **Local** connects (loopback or the gateway host’s own tailnet address) can be
|
||||||
|
auto‑approved to keep same‑host UX smooth.
|
||||||
|
- **Non‑local** connects must sign the `connect.challenge` nonce and require
|
||||||
|
explicit approval.
|
||||||
|
- Gateway auth (`gateway.auth.*`) still applies to **all** connections, local or
|
||||||
|
remote.
|
||||||
|
|
||||||
|
Details: [Gateway protocol](/gateway/protocol), [Pairing](/start/pairing),
|
||||||
|
[Security](/gateway/security).
|
||||||
|
|
||||||
## Protocol typing and codegen
|
## Protocol typing and codegen
|
||||||
|
|
||||||
- TypeBox schemas define the protocol.
|
- TypeBox schemas define the protocol.
|
||||||
|
|||||||
@@ -1774,6 +1774,7 @@ Note: `applyPatch` is only under `tools.exec`.
|
|||||||
- `tools.web.fetch.maxChars` (default 50000)
|
- `tools.web.fetch.maxChars` (default 50000)
|
||||||
- `tools.web.fetch.timeoutSeconds` (default 30)
|
- `tools.web.fetch.timeoutSeconds` (default 30)
|
||||||
- `tools.web.fetch.cacheTtlMinutes` (default 15)
|
- `tools.web.fetch.cacheTtlMinutes` (default 15)
|
||||||
|
- `tools.web.fetch.maxRedirects` (default 3)
|
||||||
- `tools.web.fetch.userAgent` (optional override)
|
- `tools.web.fetch.userAgent` (optional override)
|
||||||
- `tools.web.fetch.readability` (default true; disable to use basic HTML cleanup only)
|
- `tools.web.fetch.readability` (default true; disable to use basic HTML cleanup only)
|
||||||
- `tools.web.fetch.firecrawl.enabled` (default true when an API key is set)
|
- `tools.web.fetch.firecrawl.enabled` (default true when an API key is set)
|
||||||
@@ -2614,10 +2615,13 @@ Defaults:
|
|||||||
// noSandbox: false,
|
// noSandbox: false,
|
||||||
// executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
// executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
||||||
// attachOnly: false, // set true when tunneling a remote CDP to localhost
|
// attachOnly: false, // set true when tunneling a remote CDP to localhost
|
||||||
|
// snapshotDefaults: { mode: "efficient" }, // tool/CLI default snapshot preset
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Note: `browser.snapshotDefaults` only affects Clawdbot's browser tool + CLI. Direct HTTP clients must pass `mode` explicitly.
|
||||||
|
|
||||||
### `ui` (Appearance)
|
### `ui` (Appearance)
|
||||||
|
|
||||||
Optional accent color used by the native apps for UI chrome (e.g. Talk Mode bubble tint).
|
Optional accent color used by the native apps for UI chrome (e.g. Talk Mode bubble tint).
|
||||||
|
|||||||
@@ -195,6 +195,8 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
|
|||||||
- Gateways issue tokens per device + role.
|
- Gateways issue tokens per device + role.
|
||||||
- Pairing approvals are required for new device IDs unless local auto-approval
|
- Pairing approvals are required for new device IDs unless local auto-approval
|
||||||
is enabled.
|
is enabled.
|
||||||
|
- **Local** connects include loopback and the gateway host’s own tailnet address
|
||||||
|
(so same‑host tailnet binds can still auto‑approve).
|
||||||
- All WS clients must include `device` identity during `connect` (operator + node).
|
- All WS clients must include `device` identity during `connect` (operator + node).
|
||||||
- Non-local connections must sign the server-provided `connect.challenge` nonce.
|
- Non-local connections must sign the server-provided `connect.challenge` nonce.
|
||||||
|
|
||||||
|
|||||||
@@ -270,6 +270,12 @@ Note: `gateway.remote.token` is **only** for remote CLI calls; it does not
|
|||||||
protect local WS access.
|
protect local WS access.
|
||||||
Optional: pin remote TLS with `gateway.remote.tlsFingerprint` when using `wss://`.
|
Optional: pin remote TLS with `gateway.remote.tlsFingerprint` when using `wss://`.
|
||||||
|
|
||||||
|
Local device pairing:
|
||||||
|
- Device pairing is auto‑approved for **local** connects (loopback or the
|
||||||
|
gateway host’s own tailnet address) to keep same‑host clients smooth.
|
||||||
|
- Other tailnet peers are **not** treated as local; they still need pairing
|
||||||
|
approval.
|
||||||
|
|
||||||
Auth modes:
|
Auth modes:
|
||||||
- `gateway.auth.mode: "token"`: shared bearer token (recommended for most setups).
|
- `gateway.auth.mode: "token"`: shared bearer token (recommended for most setups).
|
||||||
- `gateway.auth.mode: "password"`: password auth (prefer setting via env: `CLAWDBOT_GATEWAY_PASSWORD`).
|
- `gateway.auth.mode: "password"`: password auth (prefer setting via env: `CLAWDBOT_GATEWAY_PASSWORD`).
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
summary: "Network hub: gateway surfaces, pairing, discovery, and security"
|
||||||
|
read_when:
|
||||||
|
- You need the network architecture + security overview
|
||||||
|
- You are debugging local vs tailnet access or pairing
|
||||||
|
- You want the canonical list of networking docs
|
||||||
|
---
|
||||||
|
# Network hub
|
||||||
|
|
||||||
|
This hub links the core docs for how Clawdbot connects, pairs, and secures
|
||||||
|
devices across localhost, LAN, and tailnet.
|
||||||
|
|
||||||
|
## Core model
|
||||||
|
|
||||||
|
- [Gateway architecture](/concepts/architecture)
|
||||||
|
- [Gateway protocol](/gateway/protocol)
|
||||||
|
- [Gateway runbook](/gateway)
|
||||||
|
- [Web surfaces + bind modes](/web)
|
||||||
|
|
||||||
|
## Pairing + identity
|
||||||
|
|
||||||
|
- [Pairing overview (DM + nodes)](/start/pairing)
|
||||||
|
- [Gateway-owned node pairing](/gateway/pairing)
|
||||||
|
- [Devices CLI (pairing + token rotation)](/cli/devices)
|
||||||
|
- [Pairing CLI (DM approvals)](/cli/pairing)
|
||||||
|
|
||||||
|
Local trust:
|
||||||
|
- Local connections (loopback or the gateway host’s own tailnet address) can be
|
||||||
|
auto‑approved for pairing to keep same‑host UX smooth.
|
||||||
|
- Non‑local tailnet/LAN clients still require explicit pairing approval.
|
||||||
|
|
||||||
|
## Discovery + transports
|
||||||
|
|
||||||
|
- [Discovery & transports](/gateway/discovery)
|
||||||
|
- [Bonjour / mDNS](/gateway/bonjour)
|
||||||
|
- [Remote access (SSH)](/gateway/remote)
|
||||||
|
- [Tailscale](/gateway/tailscale)
|
||||||
|
|
||||||
|
## Nodes + bridge
|
||||||
|
|
||||||
|
- [Nodes overview](/nodes)
|
||||||
|
- [Bridge protocol (legacy nodes)](/gateway/bridge-protocol)
|
||||||
|
- [Node runbook: iOS](/platforms/ios)
|
||||||
|
- [Node runbook: Android](/platforms/android)
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- [Security overview](/gateway/security)
|
||||||
|
- [Gateway config reference](/gateway/configuration)
|
||||||
|
- [Troubleshooting](/gateway/troubleshooting)
|
||||||
|
- [Doctor](/gateway/doctor)
|
||||||
@@ -49,6 +49,50 @@ Repair/migrate:
|
|||||||
clawdbot doctor
|
clawdbot doctor
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Advanced: expose WSL services over LAN (portproxy)
|
||||||
|
|
||||||
|
WSL has its own virtual network. If another machine needs to reach a service
|
||||||
|
running **inside WSL** (SSH, a local TTS server, or the Gateway), you must
|
||||||
|
forward a Windows port to the current WSL IP. The WSL IP changes after restarts,
|
||||||
|
so you may need to refresh the forwarding rule.
|
||||||
|
|
||||||
|
Example (PowerShell **as Administrator**):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$Distro = "Ubuntu-24.04"
|
||||||
|
$ListenPort = 2222
|
||||||
|
$TargetPort = 22
|
||||||
|
|
||||||
|
$WslIp = (wsl -d $Distro -- hostname -I).Trim().Split(" ")[0]
|
||||||
|
if (-not $WslIp) { throw "WSL IP not found." }
|
||||||
|
|
||||||
|
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=$ListenPort `
|
||||||
|
connectaddress=$WslIp connectport=$TargetPort
|
||||||
|
```
|
||||||
|
|
||||||
|
Allow the port through Windows Firewall (one-time):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
New-NetFirewallRule -DisplayName "WSL SSH $ListenPort" -Direction Inbound `
|
||||||
|
-Protocol TCP -LocalPort $ListenPort -Action Allow
|
||||||
|
```
|
||||||
|
|
||||||
|
Refresh the portproxy after WSL restarts:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
netsh interface portproxy delete v4tov4 listenport=$ListenPort listenaddress=0.0.0.0 | Out-Null
|
||||||
|
netsh interface portproxy add v4tov4 listenport=$ListenPort listenaddress=0.0.0.0 `
|
||||||
|
connectaddress=$WslIp connectport=$TargetPort | Out-Null
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- SSH from another machine targets the **Windows host IP** (example: `ssh user@windows-host -p 2222`).
|
||||||
|
- Remote nodes must point at a **reachable** Gateway URL (not `127.0.0.1`); use
|
||||||
|
`clawdbot status --all` to confirm.
|
||||||
|
- Use `listenaddress=0.0.0.0` for LAN access; `127.0.0.1` keeps it local only.
|
||||||
|
- If you want this automatic, register a Scheduled Task to run the refresh
|
||||||
|
step at login.
|
||||||
|
|
||||||
## Step-by-step WSL2 install
|
## Step-by-step WSL2 install
|
||||||
|
|
||||||
### 1) Install WSL2 + Ubuntu
|
### 1) Install WSL2 + Ubuntu
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ Use these hubs to discover every page, including deep dives and reference docs t
|
|||||||
## Core concepts
|
## Core concepts
|
||||||
|
|
||||||
- [Architecture](/concepts/architecture)
|
- [Architecture](/concepts/architecture)
|
||||||
|
- [Network hub](/network)
|
||||||
- [Agent runtime](/concepts/agent)
|
- [Agent runtime](/concepts/agent)
|
||||||
- [Agent workspace](/concepts/agent-workspace)
|
- [Agent workspace](/concepts/agent-workspace)
|
||||||
- [Memory](/concepts/memory)
|
- [Memory](/concepts/memory)
|
||||||
|
|||||||
@@ -500,6 +500,7 @@ Notes:
|
|||||||
- `--format ai` (default when Playwright is installed): returns an AI snapshot with numeric refs (`aria-ref="<n>"`).
|
- `--format ai` (default when Playwright is installed): returns an AI snapshot with numeric refs (`aria-ref="<n>"`).
|
||||||
- `--format aria`: returns the accessibility tree (no refs; inspection only).
|
- `--format aria`: returns the accessibility tree (no refs; inspection only).
|
||||||
- `--efficient` (or `--mode efficient`): compact role snapshot preset (interactive + compact + depth + lower maxChars).
|
- `--efficient` (or `--mode efficient`): compact role snapshot preset (interactive + compact + depth + lower maxChars).
|
||||||
|
- Config default (tool/CLI only): set `browser.snapshotDefaults.mode: "efficient"` to use efficient snapshots when the caller does not pass a mode (see [Gateway configuration](/gateway/configuration#browser-clawd-managed-browser)).
|
||||||
- Role snapshot options (`--interactive`, `--compact`, `--depth`, `--selector`) force a role-based snapshot with refs like `ref=e12`.
|
- Role snapshot options (`--interactive`, `--compact`, `--depth`, `--selector`) force a role-based snapshot with refs like `ref=e12`.
|
||||||
- `--frame "<iframe selector>"` scopes role snapshots to an iframe (pairs with role refs like `e12`).
|
- `--frame "<iframe selector>"` scopes role snapshots to an iframe (pairs with role refs like `e12`).
|
||||||
- `--interactive` outputs a flat, easy-to-pick list of interactive elements (best for driving actions).
|
- `--interactive` outputs a flat, easy-to-pick list of interactive elements (best for driving actions).
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ Fields under `metadata.clawdbot`:
|
|||||||
- `requires.env` — list; env var must exist **or** be provided in config.
|
- `requires.env` — list; env var must exist **or** be provided in config.
|
||||||
- `requires.config` — list of `clawdbot.json` paths that must be truthy.
|
- `requires.config` — list of `clawdbot.json` paths that must be truthy.
|
||||||
- `primaryEnv` — env var name associated with `skills.entries.<name>.apiKey`.
|
- `primaryEnv` — env var name associated with `skills.entries.<name>.apiKey`.
|
||||||
- `install` — optional array of installer specs used by the macOS Skills UI (brew/node/go/uv).
|
- `install` — optional array of installer specs used by the macOS Skills UI (brew/node/go/uv/download).
|
||||||
|
|
||||||
Note on sandboxing:
|
Note on sandboxing:
|
||||||
- `requires.bins` is checked on the **host** at skill load time.
|
- `requires.bins` is checked on the **host** at skill load time.
|
||||||
@@ -134,10 +134,13 @@ metadata: {"clawdbot":{"emoji":"♊️","requires":{"bins":["gemini"]},"install"
|
|||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- If multiple installers are listed, the gateway picks a **single** preferred option (brew when available, otherwise node).
|
- If multiple installers are listed, the gateway picks a **single** preferred option (brew when available, otherwise node).
|
||||||
|
- If all installers are `download`, Clawdbot lists each entry so you can see the available artifacts.
|
||||||
|
- Installer specs can include `os: ["darwin"|"linux"|"win32"]` to filter options by platform.
|
||||||
- Node installs honor `skills.install.nodeManager` in `clawdbot.json` (default: npm; options: npm/pnpm/yarn/bun).
|
- Node installs honor `skills.install.nodeManager` in `clawdbot.json` (default: npm; options: npm/pnpm/yarn/bun).
|
||||||
This only affects **skill installs**; the Gateway runtime should still be Node
|
This only affects **skill installs**; the Gateway runtime should still be Node
|
||||||
(Bun is not recommended for WhatsApp/Telegram).
|
(Bun is not recommended for WhatsApp/Telegram).
|
||||||
- Go installs: if `go` is missing and `brew` is available, the gateway installs Go via Homebrew first and sets `GOBIN` to Homebrew’s `bin` when possible.
|
- Go installs: if `go` is missing and `brew` is available, the gateway installs Go via Homebrew first and sets `GOBIN` to Homebrew’s `bin` when possible.
|
||||||
|
- Download installs: `url` (required), `archive` (`tar.gz` | `tar.bz2` | `zip`), `extract` (default: auto when archive detected), `stripComponents`, `targetDir` (default: `~/.clawdbot/tools/<skillKey>`).
|
||||||
|
|
||||||
If no `metadata.clawdbot` is present, the skill is always eligible (unless
|
If no `metadata.clawdbot` is present, the skill is always eligible (unless
|
||||||
disabled in config or blocked by `skills.allowBundled` for bundled skills).
|
disabled in config or blocked by `skills.allowBundled` for bundled skills).
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ Fetch a URL and extract readable content.
|
|||||||
maxChars: 50000,
|
maxChars: 50000,
|
||||||
timeoutSeconds: 30,
|
timeoutSeconds: 30,
|
||||||
cacheTtlMinutes: 15,
|
cacheTtlMinutes: 15,
|
||||||
|
maxRedirects: 3,
|
||||||
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
||||||
readability: true,
|
readability: true,
|
||||||
firecrawl: {
|
firecrawl: {
|
||||||
@@ -241,6 +242,7 @@ Notes:
|
|||||||
- `web_fetch` uses Readability (main-content extraction) first, then Firecrawl (if configured). If both fail, the tool returns an error.
|
- `web_fetch` uses Readability (main-content extraction) first, then Firecrawl (if configured). If both fail, the tool returns an error.
|
||||||
- Firecrawl requests use bot-circumvention mode and cache results by default.
|
- Firecrawl requests use bot-circumvention mode and cache results by default.
|
||||||
- `web_fetch` sends a Chrome-like User-Agent and `Accept-Language` by default; override `userAgent` if needed.
|
- `web_fetch` sends a Chrome-like User-Agent and `Accept-Language` by default; override `userAgent` if needed.
|
||||||
|
- `web_fetch` blocks private/internal hostnames and re-checks redirects (limit with `maxRedirects`).
|
||||||
- `web_fetch` is best-effort extraction; some sites will need the browser tool.
|
- `web_fetch` is best-effort extraction; some sites will need the browser tool.
|
||||||
- See [Firecrawl](/tools/firecrawl) for key setup and service details.
|
- See [Firecrawl](/tools/firecrawl) for key setup and service details.
|
||||||
- Responses are cached (default 15 minutes) to reduce repeated fetches.
|
- Responses are cached (default 15 minutes) to reduce repeated fetches.
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
name: sherpa-onnx-tts
|
||||||
|
description: Local text-to-speech via sherpa-onnx (offline, no cloud)
|
||||||
|
metadata: {"clawdbot":{"emoji":"🗣️","os":["darwin","linux","win32"],"requires":{"env":["SHERPA_ONNX_RUNTIME_DIR","SHERPA_ONNX_MODEL_DIR"]},"install":[{"id":"download-runtime-macos","kind":"download","os":["darwin"],"url":"https://github.com/k2-fsa/sherpa-onnx/releases/download/v1.12.23/sherpa-onnx-v1.12.23-osx-universal2-shared.tar.bz2","archive":"tar.bz2","extract":true,"stripComponents":1,"targetDir":"~/.clawdbot/tools/sherpa-onnx-tts/runtime","label":"Download sherpa-onnx runtime (macOS)"},{"id":"download-runtime-linux-x64","kind":"download","os":["linux"],"url":"https://github.com/k2-fsa/sherpa-onnx/releases/download/v1.12.23/sherpa-onnx-v1.12.23-linux-x64-shared.tar.bz2","archive":"tar.bz2","extract":true,"stripComponents":1,"targetDir":"~/.clawdbot/tools/sherpa-onnx-tts/runtime","label":"Download sherpa-onnx runtime (Linux x64)"},{"id":"download-runtime-win-x64","kind":"download","os":["win32"],"url":"https://github.com/k2-fsa/sherpa-onnx/releases/download/v1.12.23/sherpa-onnx-v1.12.23-win-x64-shared.tar.bz2","archive":"tar.bz2","extract":true,"stripComponents":1,"targetDir":"~/.clawdbot/tools/sherpa-onnx-tts/runtime","label":"Download sherpa-onnx runtime (Windows x64)"},{"id":"download-model-lessac","kind":"download","url":"https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-en_US-lessac-high.tar.bz2","archive":"tar.bz2","extract":true,"targetDir":"~/.clawdbot/tools/sherpa-onnx-tts/models","label":"Download Piper en_US lessac (high)"}]}}
|
||||||
|
---
|
||||||
|
|
||||||
|
# sherpa-onnx-tts
|
||||||
|
|
||||||
|
Local TTS using the sherpa-onnx offline CLI.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
1) Download the runtime for your OS (extracts into `~/.clawdbot/tools/sherpa-onnx-tts/runtime`)
|
||||||
|
2) Download a voice model (extracts into `~/.clawdbot/tools/sherpa-onnx-tts/models`)
|
||||||
|
|
||||||
|
Update `~/.clawdbot/clawdbot.json`:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
skills: {
|
||||||
|
entries: {
|
||||||
|
"sherpa-onnx-tts": {
|
||||||
|
env: {
|
||||||
|
SHERPA_ONNX_RUNTIME_DIR: "~/.clawdbot/tools/sherpa-onnx-tts/runtime",
|
||||||
|
SHERPA_ONNX_MODEL_DIR: "~/.clawdbot/tools/sherpa-onnx-tts/models/vits-piper-en_US-lessac-high"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The wrapper lives in this skill folder. Run it directly, or add the wrapper to PATH:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PATH="{baseDir}/bin:$PATH"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
{baseDir}/bin/sherpa-onnx-tts -o ./tts.wav "Hello from local TTS."
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Pick a different model from the sherpa-onnx `tts-models` release if you want another voice.
|
||||||
|
- If the model dir has multiple `.onnx` files, set `SHERPA_ONNX_MODEL_FILE` or pass `--model-file`.
|
||||||
|
- You can also pass `--tokens-file` or `--data-dir` to override the defaults.
|
||||||
|
- Windows: run `node {baseDir}\\bin\\sherpa-onnx-tts -o tts.wav "Hello from local TTS."`
|
||||||
Executable
+178
@@ -0,0 +1,178 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { spawnSync } = require("node:child_process");
|
||||||
|
|
||||||
|
function usage(message) {
|
||||||
|
if (message) {
|
||||||
|
console.error(message);
|
||||||
|
}
|
||||||
|
console.error(
|
||||||
|
"\nUsage: sherpa-onnx-tts [--runtime-dir <dir>] [--model-dir <dir>] [--model-file <file>] [--tokens-file <file>] [--data-dir <dir>] [--output <file>] \"text\"",
|
||||||
|
);
|
||||||
|
console.error("\nRequired env (or flags):\n SHERPA_ONNX_RUNTIME_DIR\n SHERPA_ONNX_MODEL_DIR");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRuntimeDir(explicit) {
|
||||||
|
const value = explicit || process.env.SHERPA_ONNX_RUNTIME_DIR || "";
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveModelDir(explicit) {
|
||||||
|
const value = explicit || process.env.SHERPA_ONNX_MODEL_DIR || "";
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveModelFile(modelDir, explicitFlag) {
|
||||||
|
const explicit = (explicitFlag || process.env.SHERPA_ONNX_MODEL_FILE || "").trim();
|
||||||
|
if (explicit) return explicit;
|
||||||
|
try {
|
||||||
|
const candidates = fs
|
||||||
|
.readdirSync(modelDir)
|
||||||
|
.filter((entry) => entry.endsWith(".onnx"))
|
||||||
|
.map((entry) => path.join(modelDir, entry));
|
||||||
|
if (candidates.length === 1) return candidates[0];
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTokensFile(modelDir, explicitFlag) {
|
||||||
|
const explicit = (explicitFlag || process.env.SHERPA_ONNX_TOKENS_FILE || "").trim();
|
||||||
|
if (explicit) return explicit;
|
||||||
|
const candidate = path.join(modelDir, "tokens.txt");
|
||||||
|
return fs.existsSync(candidate) ? candidate : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDataDir(modelDir, explicitFlag) {
|
||||||
|
const explicit = (explicitFlag || process.env.SHERPA_ONNX_DATA_DIR || "").trim();
|
||||||
|
if (explicit) return explicit;
|
||||||
|
const candidate = path.join(modelDir, "espeak-ng-data");
|
||||||
|
return fs.existsSync(candidate) ? candidate : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBinary(runtimeDir) {
|
||||||
|
const binName = process.platform === "win32" ? "sherpa-onnx-offline-tts.exe" : "sherpa-onnx-offline-tts";
|
||||||
|
return path.join(runtimeDir, "bin", binName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prependEnvPath(current, next) {
|
||||||
|
if (!next) return current;
|
||||||
|
if (!current) return next;
|
||||||
|
return `${next}${path.delimiter}${current}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
let runtimeDir = "";
|
||||||
|
let modelDir = "";
|
||||||
|
let modelFile = "";
|
||||||
|
let tokensFile = "";
|
||||||
|
let dataDir = "";
|
||||||
|
let output = "tts.wav";
|
||||||
|
const textParts = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i += 1) {
|
||||||
|
const arg = args[i];
|
||||||
|
if (arg === "--runtime-dir") {
|
||||||
|
runtimeDir = args[i + 1] || "";
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--model-dir") {
|
||||||
|
modelDir = args[i + 1] || "";
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--model-file") {
|
||||||
|
modelFile = args[i + 1] || "";
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--tokens-file") {
|
||||||
|
tokensFile = args[i + 1] || "";
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--data-dir") {
|
||||||
|
dataDir = args[i + 1] || "";
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "-o" || arg === "--output") {
|
||||||
|
output = args[i + 1] || output;
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--text") {
|
||||||
|
textParts.push(args[i + 1] || "");
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
textParts.push(arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
runtimeDir = resolveRuntimeDir(runtimeDir);
|
||||||
|
modelDir = resolveModelDir(modelDir);
|
||||||
|
|
||||||
|
if (!runtimeDir || !modelDir) {
|
||||||
|
usage("Missing runtime/model directory.");
|
||||||
|
}
|
||||||
|
|
||||||
|
modelFile = resolveModelFile(modelDir, modelFile);
|
||||||
|
tokensFile = resolveTokensFile(modelDir, tokensFile);
|
||||||
|
dataDir = resolveDataDir(modelDir, dataDir);
|
||||||
|
|
||||||
|
if (!modelFile || !tokensFile || !dataDir) {
|
||||||
|
usage(
|
||||||
|
"Model directory is missing required files. Set SHERPA_ONNX_MODEL_FILE, SHERPA_ONNX_TOKENS_FILE, SHERPA_ONNX_DATA_DIR or pass --model-file/--tokens-file/--data-dir.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = textParts.join(" ").trim();
|
||||||
|
if (!text) {
|
||||||
|
usage("Missing text.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const bin = resolveBinary(runtimeDir);
|
||||||
|
if (!fs.existsSync(bin)) {
|
||||||
|
usage(`TTS binary not found: ${bin}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = { ...process.env };
|
||||||
|
const libDir = path.join(runtimeDir, "lib");
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
env.DYLD_LIBRARY_PATH = prependEnvPath(env.DYLD_LIBRARY_PATH || "", libDir);
|
||||||
|
} else if (process.platform === "win32") {
|
||||||
|
env.PATH = prependEnvPath(env.PATH || "", [path.join(runtimeDir, "bin"), libDir].join(path.delimiter));
|
||||||
|
} else {
|
||||||
|
env.LD_LIBRARY_PATH = prependEnvPath(env.LD_LIBRARY_PATH || "", libDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputPath = path.isAbsolute(output) ? output : path.join(process.cwd(), output);
|
||||||
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||||
|
|
||||||
|
const child = spawnSync(
|
||||||
|
bin,
|
||||||
|
[
|
||||||
|
`--vits-model=${modelFile}`,
|
||||||
|
`--vits-tokens=${tokensFile}`,
|
||||||
|
`--vits-data-dir=${dataDir}`,
|
||||||
|
`--output-filename=${outputPath}`,
|
||||||
|
text,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
stdio: "inherit",
|
||||||
|
env,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (typeof child.status === "number") {
|
||||||
|
process.exit(child.status);
|
||||||
|
}
|
||||||
|
if (child.error) {
|
||||||
|
console.error(child.error.message || String(child.error));
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { Readable } from "node:stream";
|
||||||
|
import type { ReadableStream as NodeReadableStream } from "node:stream/web";
|
||||||
|
import { pipeline } from "node:stream/promises";
|
||||||
|
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { resolveBrewExecutable } from "../infra/brew.js";
|
import { resolveBrewExecutable } from "../infra/brew.js";
|
||||||
import { runCommandWithTimeout } from "../process/exec.js";
|
import { runCommandWithTimeout } from "../process/exec.js";
|
||||||
import { resolveUserPath } from "../utils.js";
|
import { CONFIG_DIR, ensureDir, resolveUserPath } from "../utils.js";
|
||||||
import {
|
import {
|
||||||
hasBinary,
|
hasBinary,
|
||||||
loadWorkspaceSkillEntries,
|
loadWorkspaceSkillEntries,
|
||||||
@@ -13,6 +16,7 @@ import {
|
|||||||
type SkillInstallSpec,
|
type SkillInstallSpec,
|
||||||
type SkillsInstallPreferences,
|
type SkillsInstallPreferences,
|
||||||
} from "./skills.js";
|
} from "./skills.js";
|
||||||
|
import { resolveSkillKey } from "./skills/frontmatter.js";
|
||||||
|
|
||||||
export type SkillInstallRequest = {
|
export type SkillInstallRequest = {
|
||||||
workspaceDir: string;
|
workspaceDir: string;
|
||||||
@@ -30,6 +34,10 @@ export type SkillInstallResult = {
|
|||||||
code: number | null;
|
code: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isNodeReadableStream(value: unknown): value is NodeJS.ReadableStream {
|
||||||
|
return Boolean(value && typeof (value as NodeJS.ReadableStream).pipe === "function");
|
||||||
|
}
|
||||||
|
|
||||||
function summarizeInstallOutput(text: string): string | undefined {
|
function summarizeInstallOutput(text: string): string | undefined {
|
||||||
const raw = text.trim();
|
const raw = text.trim();
|
||||||
if (!raw) return undefined;
|
if (!raw) return undefined;
|
||||||
@@ -112,11 +120,162 @@ function buildInstallCommand(
|
|||||||
if (!spec.package) return { argv: null, error: "missing uv package" };
|
if (!spec.package) return { argv: null, error: "missing uv package" };
|
||||||
return { argv: ["uv", "tool", "install", spec.package] };
|
return { argv: ["uv", "tool", "install", spec.package] };
|
||||||
}
|
}
|
||||||
|
case "download": {
|
||||||
|
return { argv: null, error: "download install handled separately" };
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return { argv: null, error: "unsupported installer" };
|
return { argv: null, error: "unsupported installer" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveDownloadTargetDir(entry: SkillEntry, spec: SkillInstallSpec): string {
|
||||||
|
if (spec.targetDir?.trim()) return resolveUserPath(spec.targetDir);
|
||||||
|
const key = resolveSkillKey(entry.skill, entry);
|
||||||
|
return path.join(CONFIG_DIR, "tools", key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveArchiveType(spec: SkillInstallSpec, filename: string): string | undefined {
|
||||||
|
const explicit = spec.archive?.trim().toLowerCase();
|
||||||
|
if (explicit) return explicit;
|
||||||
|
const lower = filename.toLowerCase();
|
||||||
|
if (lower.endsWith(".tar.gz") || lower.endsWith(".tgz")) return "tar.gz";
|
||||||
|
if (lower.endsWith(".tar.bz2") || lower.endsWith(".tbz2")) return "tar.bz2";
|
||||||
|
if (lower.endsWith(".zip")) return "zip";
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFile(
|
||||||
|
url: string,
|
||||||
|
destPath: string,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<{ bytes: number }> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), Math.max(1_000, timeoutMs));
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { signal: controller.signal });
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
throw new Error(`Download failed (${response.status} ${response.statusText})`);
|
||||||
|
}
|
||||||
|
await ensureDir(path.dirname(destPath));
|
||||||
|
const file = fs.createWriteStream(destPath);
|
||||||
|
const body = response.body as unknown;
|
||||||
|
const readable = isNodeReadableStream(body)
|
||||||
|
? body
|
||||||
|
: Readable.fromWeb(body as NodeReadableStream);
|
||||||
|
await pipeline(readable, file);
|
||||||
|
const stat = await fs.promises.stat(destPath);
|
||||||
|
return { bytes: stat.size };
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractArchive(params: {
|
||||||
|
archivePath: string;
|
||||||
|
archiveType: string;
|
||||||
|
targetDir: string;
|
||||||
|
stripComponents?: number;
|
||||||
|
timeoutMs: number;
|
||||||
|
}): Promise<{ stdout: string; stderr: string; code: number | null }> {
|
||||||
|
const { archivePath, archiveType, targetDir, stripComponents, timeoutMs } = params;
|
||||||
|
if (archiveType === "zip") {
|
||||||
|
if (!hasBinary("unzip")) {
|
||||||
|
return { stdout: "", stderr: "unzip not found on PATH", code: null };
|
||||||
|
}
|
||||||
|
const argv = ["unzip", "-q", archivePath, "-d", targetDir];
|
||||||
|
return await runCommandWithTimeout(argv, { timeoutMs });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasBinary("tar")) {
|
||||||
|
return { stdout: "", stderr: "tar not found on PATH", code: null };
|
||||||
|
}
|
||||||
|
const argv = ["tar", "xf", archivePath, "-C", targetDir];
|
||||||
|
if (typeof stripComponents === "number" && Number.isFinite(stripComponents)) {
|
||||||
|
argv.push("--strip-components", String(Math.max(0, Math.floor(stripComponents))));
|
||||||
|
}
|
||||||
|
return await runCommandWithTimeout(argv, { timeoutMs });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installDownloadSpec(params: {
|
||||||
|
entry: SkillEntry;
|
||||||
|
spec: SkillInstallSpec;
|
||||||
|
timeoutMs: number;
|
||||||
|
}): Promise<SkillInstallResult> {
|
||||||
|
const { entry, spec, timeoutMs } = params;
|
||||||
|
const url = spec.url?.trim();
|
||||||
|
if (!url) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: "missing download url",
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
code: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let filename = "";
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
filename = path.basename(parsed.pathname);
|
||||||
|
} catch {
|
||||||
|
filename = path.basename(url);
|
||||||
|
}
|
||||||
|
if (!filename) filename = "download";
|
||||||
|
|
||||||
|
const targetDir = resolveDownloadTargetDir(entry, spec);
|
||||||
|
await ensureDir(targetDir);
|
||||||
|
|
||||||
|
const archivePath = path.join(targetDir, filename);
|
||||||
|
let downloaded = 0;
|
||||||
|
try {
|
||||||
|
const result = await downloadFile(url, archivePath, timeoutMs);
|
||||||
|
downloaded = result.bytes;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return { ok: false, message, stdout: "", stderr: message, code: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const archiveType = resolveArchiveType(spec, filename);
|
||||||
|
const shouldExtract = spec.extract ?? Boolean(archiveType);
|
||||||
|
if (!shouldExtract) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message: `Downloaded to ${archivePath}`,
|
||||||
|
stdout: `downloaded=${downloaded}`,
|
||||||
|
stderr: "",
|
||||||
|
code: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!archiveType) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: "extract requested but archive type could not be detected",
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
code: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractResult = await extractArchive({
|
||||||
|
archivePath,
|
||||||
|
archiveType,
|
||||||
|
targetDir,
|
||||||
|
stripComponents: spec.stripComponents,
|
||||||
|
timeoutMs,
|
||||||
|
});
|
||||||
|
const success = extractResult.code === 0;
|
||||||
|
return {
|
||||||
|
ok: success,
|
||||||
|
message: success
|
||||||
|
? `Downloaded and extracted to ${targetDir}`
|
||||||
|
: formatInstallFailureMessage(extractResult),
|
||||||
|
stdout: extractResult.stdout.trim(),
|
||||||
|
stderr: extractResult.stderr.trim(),
|
||||||
|
code: extractResult.code,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveBrewBinDir(timeoutMs: number, brewExe?: string): Promise<string | undefined> {
|
async function resolveBrewBinDir(timeoutMs: number, brewExe?: string): Promise<string | undefined> {
|
||||||
const exe = brewExe ?? (hasBinary("brew") ? "brew" : resolveBrewExecutable());
|
const exe = brewExe ?? (hasBinary("brew") ? "brew" : resolveBrewExecutable());
|
||||||
if (!exe) return undefined;
|
if (!exe) return undefined;
|
||||||
@@ -167,6 +326,9 @@ export async function installSkill(params: SkillInstallRequest): Promise<SkillIn
|
|||||||
code: null,
|
code: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (spec.kind === "download") {
|
||||||
|
return await installDownloadSpec({ entry, spec, timeoutMs });
|
||||||
|
}
|
||||||
|
|
||||||
const prefs = resolveSkillsInstallPreferences(params.config);
|
const prefs = resolveSkillsInstallPreferences(params.config);
|
||||||
const command = buildInstallCommand(spec, prefs);
|
const command = buildInstallCommand(spec, prefs);
|
||||||
|
|||||||
+40
-27
@@ -100,36 +100,49 @@ function normalizeInstallOptions(
|
|||||||
): SkillInstallOption[] {
|
): SkillInstallOption[] {
|
||||||
const install = entry.clawdbot?.install ?? [];
|
const install = entry.clawdbot?.install ?? [];
|
||||||
if (install.length === 0) return [];
|
if (install.length === 0) return [];
|
||||||
const preferred = selectPreferredInstallSpec(install, prefs);
|
|
||||||
if (!preferred) return [];
|
const platform = process.platform;
|
||||||
const { spec, index } = preferred;
|
const filtered = install.filter((spec) => {
|
||||||
const id = (spec.id ?? `${spec.kind}-${index}`).trim();
|
const osList = spec.os ?? [];
|
||||||
const bins = spec.bins ?? [];
|
return osList.length === 0 || osList.includes(platform);
|
||||||
let label = (spec.label ?? "").trim();
|
});
|
||||||
if (spec.kind === "node" && spec.package) {
|
if (filtered.length === 0) return [];
|
||||||
label = `Install ${spec.package} (${prefs.nodeManager})`;
|
|
||||||
}
|
const toOption = (spec: SkillInstallSpec, index: number): SkillInstallOption => {
|
||||||
if (!label) {
|
const id = (spec.id ?? `${spec.kind}-${index}`).trim();
|
||||||
if (spec.kind === "brew" && spec.formula) {
|
const bins = spec.bins ?? [];
|
||||||
label = `Install ${spec.formula} (brew)`;
|
let label = (spec.label ?? "").trim();
|
||||||
} else if (spec.kind === "node" && spec.package) {
|
if (spec.kind === "node" && spec.package) {
|
||||||
label = `Install ${spec.package} (${prefs.nodeManager})`;
|
label = `Install ${spec.package} (${prefs.nodeManager})`;
|
||||||
} else if (spec.kind === "go" && spec.module) {
|
|
||||||
label = `Install ${spec.module} (go)`;
|
|
||||||
} else if (spec.kind === "uv" && spec.package) {
|
|
||||||
label = `Install ${spec.package} (uv)`;
|
|
||||||
} else {
|
|
||||||
label = "Run installer";
|
|
||||||
}
|
}
|
||||||
|
if (!label) {
|
||||||
|
if (spec.kind === "brew" && spec.formula) {
|
||||||
|
label = `Install ${spec.formula} (brew)`;
|
||||||
|
} else if (spec.kind === "node" && spec.package) {
|
||||||
|
label = `Install ${spec.package} (${prefs.nodeManager})`;
|
||||||
|
} else if (spec.kind === "go" && spec.module) {
|
||||||
|
label = `Install ${spec.module} (go)`;
|
||||||
|
} else if (spec.kind === "uv" && spec.package) {
|
||||||
|
label = `Install ${spec.package} (uv)`;
|
||||||
|
} else if (spec.kind === "download" && spec.url) {
|
||||||
|
const url = spec.url.trim();
|
||||||
|
const last = url.split("/").pop();
|
||||||
|
label = `Download ${last && last.length > 0 ? last : url}`;
|
||||||
|
} else {
|
||||||
|
label = "Run installer";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { id, kind: spec.kind, label, bins };
|
||||||
|
};
|
||||||
|
|
||||||
|
const allDownloads = filtered.every((spec) => spec.kind === "download");
|
||||||
|
if (allDownloads) {
|
||||||
|
return filtered.map((spec, index) => toOption(spec, index));
|
||||||
}
|
}
|
||||||
return [
|
|
||||||
{
|
const preferred = selectPreferredInstallSpec(filtered, prefs);
|
||||||
id,
|
if (!preferred) return [];
|
||||||
kind: spec.kind,
|
return [toOption(preferred.spec, preferred.index)];
|
||||||
label,
|
|
||||||
bins,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSkillStatus(
|
function buildSkillStatus(
|
||||||
|
|||||||
@@ -109,4 +109,33 @@ describe("buildWorkspaceSkillStatus", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("filters install options by OS", async () => {
|
||||||
|
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-"));
|
||||||
|
const skillDir = path.join(workspaceDir, "skills", "install-skill");
|
||||||
|
|
||||||
|
await writeSkill({
|
||||||
|
dir: skillDir,
|
||||||
|
name: "install-skill",
|
||||||
|
description: "OS-specific installs",
|
||||||
|
metadata:
|
||||||
|
'{"clawdbot":{"requires":{"bins":["missing-bin"]},"install":[{"id":"mac","kind":"download","os":["darwin"],"url":"https://example.com/mac.tar.bz2"},{"id":"linux","kind":"download","os":["linux"],"url":"https://example.com/linux.tar.bz2"},{"id":"win","kind":"download","os":["win32"],"url":"https://example.com/win.tar.bz2"}]}}',
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = buildWorkspaceSkillStatus(workspaceDir, {
|
||||||
|
managedSkillsDir: path.join(workspaceDir, ".managed"),
|
||||||
|
});
|
||||||
|
const skill = report.skills.find((entry) => entry.name === "install-skill");
|
||||||
|
|
||||||
|
expect(skill).toBeDefined();
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
expect(skill?.install.map((opt) => opt.id)).toEqual(["mac"]);
|
||||||
|
} else if (process.platform === "linux") {
|
||||||
|
expect(skill?.install.map((opt) => opt.id)).toEqual(["linux"]);
|
||||||
|
} else if (process.platform === "win32") {
|
||||||
|
expect(skill?.install.map((opt) => opt.id)).toEqual(["win"]);
|
||||||
|
} else {
|
||||||
|
expect(skill?.install).toEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -35,7 +35,7 @@ function parseInstallSpec(input: unknown): SkillInstallSpec | undefined {
|
|||||||
const kindRaw =
|
const kindRaw =
|
||||||
typeof raw.kind === "string" ? raw.kind : typeof raw.type === "string" ? raw.type : "";
|
typeof raw.kind === "string" ? raw.kind : typeof raw.type === "string" ? raw.type : "";
|
||||||
const kind = kindRaw.trim().toLowerCase();
|
const kind = kindRaw.trim().toLowerCase();
|
||||||
if (kind !== "brew" && kind !== "node" && kind !== "go" && kind !== "uv") {
|
if (kind !== "brew" && kind !== "node" && kind !== "go" && kind !== "uv" && kind !== "download") {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,9 +47,16 @@ function parseInstallSpec(input: unknown): SkillInstallSpec | undefined {
|
|||||||
if (typeof raw.label === "string") spec.label = raw.label;
|
if (typeof raw.label === "string") spec.label = raw.label;
|
||||||
const bins = normalizeStringList(raw.bins);
|
const bins = normalizeStringList(raw.bins);
|
||||||
if (bins.length > 0) spec.bins = bins;
|
if (bins.length > 0) spec.bins = bins;
|
||||||
|
const osList = normalizeStringList(raw.os);
|
||||||
|
if (osList.length > 0) spec.os = osList;
|
||||||
if (typeof raw.formula === "string") spec.formula = raw.formula;
|
if (typeof raw.formula === "string") spec.formula = raw.formula;
|
||||||
if (typeof raw.package === "string") spec.package = raw.package;
|
if (typeof raw.package === "string") spec.package = raw.package;
|
||||||
if (typeof raw.module === "string") spec.module = raw.module;
|
if (typeof raw.module === "string") spec.module = raw.module;
|
||||||
|
if (typeof raw.url === "string") spec.url = raw.url;
|
||||||
|
if (typeof raw.archive === "string") spec.archive = raw.archive;
|
||||||
|
if (typeof raw.extract === "boolean") spec.extract = raw.extract;
|
||||||
|
if (typeof raw.stripComponents === "number") spec.stripComponents = raw.stripComponents;
|
||||||
|
if (typeof raw.targetDir === "string") spec.targetDir = raw.targetDir;
|
||||||
|
|
||||||
return spec;
|
return spec;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,18 @@ import type { Skill } from "@mariozechner/pi-coding-agent";
|
|||||||
|
|
||||||
export type SkillInstallSpec = {
|
export type SkillInstallSpec = {
|
||||||
id?: string;
|
id?: string;
|
||||||
kind: "brew" | "node" | "go" | "uv";
|
kind: "brew" | "node" | "go" | "uv" | "download";
|
||||||
label?: string;
|
label?: string;
|
||||||
bins?: string[];
|
bins?: string[];
|
||||||
|
os?: string[];
|
||||||
formula?: string;
|
formula?: string;
|
||||||
package?: string;
|
package?: string;
|
||||||
module?: string;
|
module?: string;
|
||||||
|
url?: string;
|
||||||
|
archive?: string;
|
||||||
|
extract?: boolean;
|
||||||
|
stripComponents?: number;
|
||||||
|
targetDir?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ClawdbotSkillMetadata = {
|
export type ClawdbotSkillMetadata = {
|
||||||
|
|||||||
@@ -49,9 +49,10 @@ const browserConfigMocks = vi.hoisted(() => ({
|
|||||||
}));
|
}));
|
||||||
vi.mock("../../browser/config.js", () => browserConfigMocks);
|
vi.mock("../../browser/config.js", () => browserConfigMocks);
|
||||||
|
|
||||||
vi.mock("../../config/config.js", () => ({
|
const configMocks = vi.hoisted(() => ({
|
||||||
loadConfig: vi.fn(() => ({ browser: {} })),
|
loadConfig: vi.fn(() => ({ browser: {} })),
|
||||||
}));
|
}));
|
||||||
|
vi.mock("../../config/config.js", () => configMocks);
|
||||||
|
|
||||||
const toolCommonMocks = vi.hoisted(() => ({
|
const toolCommonMocks = vi.hoisted(() => ({
|
||||||
imageResultFromFile: vi.fn(),
|
imageResultFromFile: vi.fn(),
|
||||||
@@ -70,11 +71,12 @@ import { createBrowserTool } from "./browser-tool.js";
|
|||||||
describe("browser tool snapshot maxChars", () => {
|
describe("browser tool snapshot maxChars", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
configMocks.loadConfig.mockReturnValue({ browser: {} });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies the default ai snapshot limit", async () => {
|
it("applies the default ai snapshot limit", async () => {
|
||||||
const tool = createBrowserTool();
|
const tool = createBrowserTool();
|
||||||
await tool.execute?.(null, { action: "snapshot", format: "ai" });
|
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai" });
|
||||||
|
|
||||||
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
||||||
"http://127.0.0.1:18791",
|
"http://127.0.0.1:18791",
|
||||||
@@ -90,7 +92,7 @@ describe("browser tool snapshot maxChars", () => {
|
|||||||
const override = 2_000;
|
const override = 2_000;
|
||||||
await tool.execute?.(null, {
|
await tool.execute?.(null, {
|
||||||
action: "snapshot",
|
action: "snapshot",
|
||||||
format: "ai",
|
snapshotFormat: "ai",
|
||||||
maxChars: override,
|
maxChars: override,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -106,7 +108,7 @@ describe("browser tool snapshot maxChars", () => {
|
|||||||
const tool = createBrowserTool();
|
const tool = createBrowserTool();
|
||||||
await tool.execute?.(null, {
|
await tool.execute?.(null, {
|
||||||
action: "snapshot",
|
action: "snapshot",
|
||||||
format: "ai",
|
snapshotFormat: "ai",
|
||||||
maxChars: 0,
|
maxChars: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -124,7 +126,7 @@ describe("browser tool snapshot maxChars", () => {
|
|||||||
|
|
||||||
it("passes refs mode through to browser snapshot", async () => {
|
it("passes refs mode through to browser snapshot", async () => {
|
||||||
const tool = createBrowserTool();
|
const tool = createBrowserTool();
|
||||||
await tool.execute?.(null, { action: "snapshot", format: "ai", refs: "aria" });
|
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai", refs: "aria" });
|
||||||
|
|
||||||
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
||||||
"http://127.0.0.1:18791",
|
"http://127.0.0.1:18791",
|
||||||
@@ -135,9 +137,36 @@ describe("browser tool snapshot maxChars", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses config snapshot defaults when mode is not provided", async () => {
|
||||||
|
configMocks.loadConfig.mockReturnValue({
|
||||||
|
browser: { snapshotDefaults: { mode: "efficient" } },
|
||||||
|
});
|
||||||
|
const tool = createBrowserTool();
|
||||||
|
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai" });
|
||||||
|
|
||||||
|
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
||||||
|
"http://127.0.0.1:18791",
|
||||||
|
expect.objectContaining({
|
||||||
|
mode: "efficient",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not apply config snapshot defaults to aria snapshots", async () => {
|
||||||
|
configMocks.loadConfig.mockReturnValue({
|
||||||
|
browser: { snapshotDefaults: { mode: "efficient" } },
|
||||||
|
});
|
||||||
|
const tool = createBrowserTool();
|
||||||
|
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "aria" });
|
||||||
|
|
||||||
|
expect(browserClientMocks.browserSnapshot).toHaveBeenCalled();
|
||||||
|
const [, opts] = browserClientMocks.browserSnapshot.mock.calls.at(-1) ?? [];
|
||||||
|
expect(opts?.mode).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("defaults to host when using profile=chrome (even in sandboxed sessions)", async () => {
|
it("defaults to host when using profile=chrome (even in sandboxed sessions)", async () => {
|
||||||
const tool = createBrowserTool({ defaultControlUrl: "http://127.0.0.1:9999" });
|
const tool = createBrowserTool({ defaultControlUrl: "http://127.0.0.1:9999" });
|
||||||
await tool.execute?.(null, { action: "snapshot", profile: "chrome", format: "ai" });
|
await tool.execute?.(null, { action: "snapshot", profile: "chrome", snapshotFormat: "ai" });
|
||||||
|
|
||||||
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
||||||
"http://127.0.0.1:18791",
|
"http://127.0.0.1:18791",
|
||||||
@@ -151,6 +180,7 @@ describe("browser tool snapshot maxChars", () => {
|
|||||||
describe("browser tool snapshot labels", () => {
|
describe("browser tool snapshot labels", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
configMocks.loadConfig.mockReturnValue({ browser: {} });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns image + text when labels are requested", async () => {
|
it("returns image + text when labels are requested", async () => {
|
||||||
@@ -175,7 +205,7 @@ describe("browser tool snapshot labels", () => {
|
|||||||
|
|
||||||
const result = await tool.execute?.(null, {
|
const result = await tool.execute?.(null, {
|
||||||
action: "snapshot",
|
action: "snapshot",
|
||||||
format: "ai",
|
snapshotFormat: "ai",
|
||||||
labels: true,
|
labels: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -190,11 +190,17 @@ export function createBrowserTool(opts?: {
|
|||||||
return jsonResult({ ok: true });
|
return jsonResult({ ok: true });
|
||||||
}
|
}
|
||||||
case "snapshot": {
|
case "snapshot": {
|
||||||
|
const snapshotDefaults = loadConfig().browser?.snapshotDefaults;
|
||||||
const format =
|
const format =
|
||||||
params.snapshotFormat === "ai" || params.snapshotFormat === "aria"
|
params.snapshotFormat === "ai" || params.snapshotFormat === "aria"
|
||||||
? (params.snapshotFormat as "ai" | "aria")
|
? (params.snapshotFormat as "ai" | "aria")
|
||||||
: "ai";
|
: "ai";
|
||||||
const mode = params.mode === "efficient" ? "efficient" : undefined;
|
const mode =
|
||||||
|
params.mode === "efficient"
|
||||||
|
? "efficient"
|
||||||
|
: format === "ai" && snapshotDefaults?.mode === "efficient"
|
||||||
|
? "efficient"
|
||||||
|
: undefined;
|
||||||
const labels = typeof params.labels === "boolean" ? params.labels : undefined;
|
const labels = typeof params.labels === "boolean" ? params.labels : undefined;
|
||||||
const refs = params.refs === "aria" || params.refs === "role" ? params.refs : undefined;
|
const refs = params.refs === "aria" || params.refs === "role" ? params.refs : undefined;
|
||||||
const hasMaxChars = Object.hasOwn(params, "maxChars");
|
const hasMaxChars = Object.hasOwn(params, "maxChars");
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ describe("cron tool", () => {
|
|||||||
data: {
|
data: {
|
||||||
name: "wake-up",
|
name: "wake-up",
|
||||||
schedule: { atMs: 123 },
|
schedule: { atMs: 123 },
|
||||||
payload: { text: "hello" },
|
payload: { kind: "systemEvent", text: "hello" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -105,7 +105,7 @@ describe("cron tool", () => {
|
|||||||
job: {
|
job: {
|
||||||
name: "reminder",
|
name: "reminder",
|
||||||
schedule: { atMs: 123 },
|
schedule: { atMs: 123 },
|
||||||
payload: { text: "Reminder: the thing." },
|
payload: { kind: "systemEvent", text: "Reminder: the thing." },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import crypto from "node:crypto";
|
|||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
|
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
|
import { loadConfig } from "../../config/io.js";
|
||||||
|
import { loadSessionStore, resolveStorePath } from "../../config/sessions.js";
|
||||||
import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js";
|
import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js";
|
||||||
import {
|
import {
|
||||||
formatDoctorNonInteractiveHint,
|
formatDoctorNonInteractiveHint,
|
||||||
@@ -77,11 +79,42 @@ export function createGatewayTool(opts?: {
|
|||||||
: undefined;
|
: undefined;
|
||||||
const note =
|
const note =
|
||||||
typeof params.note === "string" && params.note.trim() ? params.note.trim() : undefined;
|
typeof params.note === "string" && params.note.trim() ? params.note.trim() : undefined;
|
||||||
|
// Extract channel + threadId for routing after restart
|
||||||
|
let deliveryContext: { channel?: string; to?: string; accountId?: string } | undefined;
|
||||||
|
let threadId: string | undefined;
|
||||||
|
if (sessionKey) {
|
||||||
|
const threadMarker = ":thread:";
|
||||||
|
const threadIndex = sessionKey.lastIndexOf(threadMarker);
|
||||||
|
const baseSessionKey = threadIndex === -1 ? sessionKey : sessionKey.slice(0, threadIndex);
|
||||||
|
const threadIdRaw =
|
||||||
|
threadIndex === -1 ? undefined : sessionKey.slice(threadIndex + threadMarker.length);
|
||||||
|
threadId = threadIdRaw?.trim() || undefined;
|
||||||
|
try {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const storePath = resolveStorePath(cfg.session?.store);
|
||||||
|
const store = loadSessionStore(storePath);
|
||||||
|
let entry = store[sessionKey];
|
||||||
|
if (!entry?.deliveryContext && threadIndex !== -1 && baseSessionKey) {
|
||||||
|
entry = store[baseSessionKey];
|
||||||
|
}
|
||||||
|
if (entry?.deliveryContext) {
|
||||||
|
deliveryContext = {
|
||||||
|
channel: entry.deliveryContext.channel,
|
||||||
|
to: entry.deliveryContext.to,
|
||||||
|
accountId: entry.deliveryContext.accountId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore: best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
const payload: RestartSentinelPayload = {
|
const payload: RestartSentinelPayload = {
|
||||||
kind: "restart",
|
kind: "restart",
|
||||||
status: "ok",
|
status: "ok",
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
sessionKey,
|
sessionKey,
|
||||||
|
deliveryContext,
|
||||||
|
threadId,
|
||||||
message: note ?? reason ?? null,
|
message: note ?? reason ?? null,
|
||||||
doctorHint: formatDoctorNonInteractiveHint(),
|
doctorHint: formatDoctorNonInteractiveHint(),
|
||||||
stats: {
|
stats: {
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const lookupMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("node:dns/promises", () => ({
|
||||||
|
lookup: lookupMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function makeHeaders(map: Record<string, string>): { get: (key: string) => string | null } {
|
||||||
|
return {
|
||||||
|
get: (key) => map[key.toLowerCase()] ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirectResponse(location: string): Response {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 302,
|
||||||
|
headers: makeHeaders({ location }),
|
||||||
|
body: { cancel: vi.fn() },
|
||||||
|
} as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
function textResponse(body: string): Response {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
headers: makeHeaders({ "content-type": "text/plain" }),
|
||||||
|
text: async () => body,
|
||||||
|
} as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("web_fetch SSRF protection", () => {
|
||||||
|
const priorFetch = global.fetch;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// @ts-expect-error restore
|
||||||
|
global.fetch = priorFetch;
|
||||||
|
lookupMock.mockReset();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks localhost hostnames before fetch/firecrawl", async () => {
|
||||||
|
const fetchSpy = vi.fn();
|
||||||
|
// @ts-expect-error mock fetch
|
||||||
|
global.fetch = fetchSpy;
|
||||||
|
|
||||||
|
const { createWebFetchTool } = await import("./web-tools.js");
|
||||||
|
const tool = createWebFetchTool({
|
||||||
|
config: {
|
||||||
|
tools: {
|
||||||
|
web: {
|
||||||
|
fetch: {
|
||||||
|
cacheTtlMinutes: 0,
|
||||||
|
firecrawl: { apiKey: "firecrawl-test" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(tool?.execute?.("call", { url: "http://localhost/test" })).rejects.toThrow(
|
||||||
|
/Blocked hostname/i,
|
||||||
|
);
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
expect(lookupMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks private IP literals without DNS", async () => {
|
||||||
|
const fetchSpy = vi.fn();
|
||||||
|
// @ts-expect-error mock fetch
|
||||||
|
global.fetch = fetchSpy;
|
||||||
|
|
||||||
|
const { createWebFetchTool } = await import("./web-tools.js");
|
||||||
|
const tool = createWebFetchTool({
|
||||||
|
config: {
|
||||||
|
tools: { web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(tool?.execute?.("call", { url: "http://127.0.0.1/test" })).rejects.toThrow(
|
||||||
|
/private|internal|blocked/i,
|
||||||
|
);
|
||||||
|
await expect(tool?.execute?.("call", { url: "http://[::ffff:127.0.0.1]/" })).rejects.toThrow(
|
||||||
|
/private|internal|blocked/i,
|
||||||
|
);
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
expect(lookupMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks when DNS resolves to private addresses", async () => {
|
||||||
|
lookupMock.mockImplementation(async (hostname: string) => {
|
||||||
|
if (hostname === "public.test") {
|
||||||
|
return [{ address: "93.184.216.34", family: 4 }];
|
||||||
|
}
|
||||||
|
return [{ address: "10.0.0.5", family: 4 }];
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchSpy = vi.fn();
|
||||||
|
// @ts-expect-error mock fetch
|
||||||
|
global.fetch = fetchSpy;
|
||||||
|
|
||||||
|
const { createWebFetchTool } = await import("./web-tools.js");
|
||||||
|
const tool = createWebFetchTool({
|
||||||
|
config: {
|
||||||
|
tools: { web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(tool?.execute?.("call", { url: "https://private.test/resource" })).rejects.toThrow(
|
||||||
|
/private|internal|blocked/i,
|
||||||
|
);
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks redirects to private hosts", async () => {
|
||||||
|
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
|
||||||
|
|
||||||
|
const fetchSpy = vi.fn().mockResolvedValueOnce(redirectResponse("http://127.0.0.1/secret"));
|
||||||
|
// @ts-expect-error mock fetch
|
||||||
|
global.fetch = fetchSpy;
|
||||||
|
|
||||||
|
const { createWebFetchTool } = await import("./web-tools.js");
|
||||||
|
const tool = createWebFetchTool({
|
||||||
|
config: {
|
||||||
|
tools: {
|
||||||
|
web: {
|
||||||
|
fetch: { cacheTtlMinutes: 0, firecrawl: { apiKey: "firecrawl-test" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(tool?.execute?.("call", { url: "https://example.com" })).rejects.toThrow(
|
||||||
|
/private|internal|blocked/i,
|
||||||
|
);
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows public hosts", async () => {
|
||||||
|
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
|
||||||
|
|
||||||
|
const fetchSpy = vi.fn().mockResolvedValue(textResponse("ok"));
|
||||||
|
// @ts-expect-error mock fetch
|
||||||
|
global.fetch = fetchSpy;
|
||||||
|
|
||||||
|
const { createWebFetchTool } = await import("./web-tools.js");
|
||||||
|
const tool = createWebFetchTool({
|
||||||
|
config: {
|
||||||
|
tools: { web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await tool?.execute?.("call", { url: "https://example.com" });
|
||||||
|
expect(result?.details).toMatchObject({
|
||||||
|
status: 200,
|
||||||
|
extractor: "raw",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
|
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
|
import { assertPublicHostname, SsrFBlockedError } from "../../infra/net/ssrf.js";
|
||||||
import { stringEnum } from "../schema/typebox.js";
|
import { stringEnum } from "../schema/typebox.js";
|
||||||
import type { AnyAgentTool } from "./common.js";
|
import type { AnyAgentTool } from "./common.js";
|
||||||
import { jsonResult, readNumberParam, readStringParam } from "./common.js";
|
import { jsonResult, readNumberParam, readStringParam } from "./common.js";
|
||||||
@@ -29,6 +30,7 @@ export { extractReadableContent } from "./web-fetch-utils.js";
|
|||||||
const EXTRACT_MODES = ["markdown", "text"] as const;
|
const EXTRACT_MODES = ["markdown", "text"] as const;
|
||||||
|
|
||||||
const DEFAULT_FETCH_MAX_CHARS = 50_000;
|
const DEFAULT_FETCH_MAX_CHARS = 50_000;
|
||||||
|
const DEFAULT_FETCH_MAX_REDIRECTS = 3;
|
||||||
const DEFAULT_ERROR_MAX_CHARS = 4_000;
|
const DEFAULT_ERROR_MAX_CHARS = 4_000;
|
||||||
const DEFAULT_FIRECRAWL_BASE_URL = "https://api.firecrawl.dev";
|
const DEFAULT_FIRECRAWL_BASE_URL = "https://api.firecrawl.dev";
|
||||||
const DEFAULT_FIRECRAWL_MAX_AGE_MS = 172_800_000;
|
const DEFAULT_FIRECRAWL_MAX_AGE_MS = 172_800_000;
|
||||||
@@ -144,6 +146,11 @@ function resolveMaxChars(value: unknown, fallback: number): number {
|
|||||||
return Math.max(100, Math.floor(parsed));
|
return Math.max(100, Math.floor(parsed));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveMaxRedirects(value: unknown, fallback: number): number {
|
||||||
|
const parsed = typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||||
|
return Math.max(0, Math.floor(parsed));
|
||||||
|
}
|
||||||
|
|
||||||
function looksLikeHtml(value: string): boolean {
|
function looksLikeHtml(value: string): boolean {
|
||||||
const trimmed = value.trimStart();
|
const trimmed = value.trimStart();
|
||||||
if (!trimmed) return false;
|
if (!trimmed) return false;
|
||||||
@@ -151,6 +158,68 @@ function looksLikeHtml(value: string): boolean {
|
|||||||
return head.startsWith("<!doctype html") || head.startsWith("<html");
|
return head.startsWith("<!doctype html") || head.startsWith("<html");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRedirectStatus(status: number): boolean {
|
||||||
|
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWithRedirects(params: {
|
||||||
|
url: string;
|
||||||
|
maxRedirects: number;
|
||||||
|
timeoutSeconds: number;
|
||||||
|
userAgent: string;
|
||||||
|
}): Promise<{ response: Response; finalUrl: string }> {
|
||||||
|
const signal = withTimeout(undefined, params.timeoutSeconds * 1000);
|
||||||
|
const visited = new Set<string>();
|
||||||
|
let currentUrl = params.url;
|
||||||
|
let redirectCount = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
let parsedUrl: URL;
|
||||||
|
try {
|
||||||
|
parsedUrl = new URL(currentUrl);
|
||||||
|
} catch {
|
||||||
|
throw new Error("Invalid URL: must be http or https");
|
||||||
|
}
|
||||||
|
if (!["http:", "https:"].includes(parsedUrl.protocol)) {
|
||||||
|
throw new Error("Invalid URL: must be http or https");
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertPublicHostname(parsedUrl.hostname);
|
||||||
|
|
||||||
|
const res = await fetch(parsedUrl.toString(), {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Accept: "*/*",
|
||||||
|
"User-Agent": params.userAgent,
|
||||||
|
"Accept-Language": "en-US,en;q=0.9",
|
||||||
|
},
|
||||||
|
signal,
|
||||||
|
redirect: "manual",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isRedirectStatus(res.status)) {
|
||||||
|
const location = res.headers.get("location");
|
||||||
|
if (!location) {
|
||||||
|
throw new Error(`Redirect missing location header (${res.status})`);
|
||||||
|
}
|
||||||
|
redirectCount += 1;
|
||||||
|
if (redirectCount > params.maxRedirects) {
|
||||||
|
throw new Error(`Too many redirects (limit: ${params.maxRedirects})`);
|
||||||
|
}
|
||||||
|
const nextUrl = new URL(location, parsedUrl).toString();
|
||||||
|
if (visited.has(nextUrl)) {
|
||||||
|
throw new Error("Redirect loop detected");
|
||||||
|
}
|
||||||
|
visited.add(nextUrl);
|
||||||
|
void res.body?.cancel();
|
||||||
|
currentUrl = nextUrl;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { response: res, finalUrl: currentUrl };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatWebFetchErrorDetail(params: {
|
function formatWebFetchErrorDetail(params: {
|
||||||
detail: string;
|
detail: string;
|
||||||
contentType?: string | null;
|
contentType?: string | null;
|
||||||
@@ -247,6 +316,7 @@ async function runWebFetch(params: {
|
|||||||
url: string;
|
url: string;
|
||||||
extractMode: ExtractMode;
|
extractMode: ExtractMode;
|
||||||
maxChars: number;
|
maxChars: number;
|
||||||
|
maxRedirects: number;
|
||||||
timeoutSeconds: number;
|
timeoutSeconds: number;
|
||||||
cacheTtlMs: number;
|
cacheTtlMs: number;
|
||||||
userAgent: string;
|
userAgent: string;
|
||||||
@@ -278,20 +348,23 @@ async function runWebFetch(params: {
|
|||||||
|
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
let res: Response;
|
let res: Response;
|
||||||
|
let finalUrl = params.url;
|
||||||
try {
|
try {
|
||||||
res = await fetch(parsedUrl.toString(), {
|
const result = await fetchWithRedirects({
|
||||||
method: "GET",
|
url: params.url,
|
||||||
headers: {
|
maxRedirects: params.maxRedirects,
|
||||||
Accept: "*/*",
|
timeoutSeconds: params.timeoutSeconds,
|
||||||
"User-Agent": params.userAgent,
|
userAgent: params.userAgent,
|
||||||
"Accept-Language": "en-US,en;q=0.9",
|
|
||||||
},
|
|
||||||
signal: withTimeout(undefined, params.timeoutSeconds * 1000),
|
|
||||||
});
|
});
|
||||||
|
res = result.response;
|
||||||
|
finalUrl = result.finalUrl;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof SsrFBlockedError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
if (params.firecrawlEnabled && params.firecrawlApiKey) {
|
if (params.firecrawlEnabled && params.firecrawlApiKey) {
|
||||||
const firecrawl = await fetchFirecrawlContent({
|
const firecrawl = await fetchFirecrawlContent({
|
||||||
url: params.url,
|
url: finalUrl,
|
||||||
extractMode: params.extractMode,
|
extractMode: params.extractMode,
|
||||||
apiKey: params.firecrawlApiKey,
|
apiKey: params.firecrawlApiKey,
|
||||||
baseUrl: params.firecrawlBaseUrl,
|
baseUrl: params.firecrawlBaseUrl,
|
||||||
@@ -304,7 +377,7 @@ async function runWebFetch(params: {
|
|||||||
const truncated = truncateText(firecrawl.text, params.maxChars);
|
const truncated = truncateText(firecrawl.text, params.maxChars);
|
||||||
const payload = {
|
const payload = {
|
||||||
url: params.url,
|
url: params.url,
|
||||||
finalUrl: firecrawl.finalUrl || params.url,
|
finalUrl: firecrawl.finalUrl || finalUrl,
|
||||||
status: firecrawl.status ?? 200,
|
status: firecrawl.status ?? 200,
|
||||||
contentType: "text/markdown",
|
contentType: "text/markdown",
|
||||||
title: firecrawl.title,
|
title: firecrawl.title,
|
||||||
@@ -339,7 +412,7 @@ async function runWebFetch(params: {
|
|||||||
const truncated = truncateText(firecrawl.text, params.maxChars);
|
const truncated = truncateText(firecrawl.text, params.maxChars);
|
||||||
const payload = {
|
const payload = {
|
||||||
url: params.url,
|
url: params.url,
|
||||||
finalUrl: firecrawl.finalUrl || params.url,
|
finalUrl: firecrawl.finalUrl || finalUrl,
|
||||||
status: firecrawl.status ?? res.status,
|
status: firecrawl.status ?? res.status,
|
||||||
contentType: "text/markdown",
|
contentType: "text/markdown",
|
||||||
title: firecrawl.title,
|
title: firecrawl.title,
|
||||||
@@ -374,7 +447,7 @@ async function runWebFetch(params: {
|
|||||||
if (params.readabilityEnabled) {
|
if (params.readabilityEnabled) {
|
||||||
const readable = await extractReadableContent({
|
const readable = await extractReadableContent({
|
||||||
html: body,
|
html: body,
|
||||||
url: res.url || params.url,
|
url: finalUrl,
|
||||||
extractMode: params.extractMode,
|
extractMode: params.extractMode,
|
||||||
});
|
});
|
||||||
if (readable?.text) {
|
if (readable?.text) {
|
||||||
@@ -382,7 +455,7 @@ async function runWebFetch(params: {
|
|||||||
title = readable.title;
|
title = readable.title;
|
||||||
extractor = "readability";
|
extractor = "readability";
|
||||||
} else {
|
} else {
|
||||||
const firecrawl = await tryFirecrawlFallback(params);
|
const firecrawl = await tryFirecrawlFallback({ ...params, url: finalUrl });
|
||||||
if (firecrawl) {
|
if (firecrawl) {
|
||||||
text = firecrawl.text;
|
text = firecrawl.text;
|
||||||
title = firecrawl.title;
|
title = firecrawl.title;
|
||||||
@@ -411,7 +484,7 @@ async function runWebFetch(params: {
|
|||||||
const truncated = truncateText(text, params.maxChars);
|
const truncated = truncateText(text, params.maxChars);
|
||||||
const payload = {
|
const payload = {
|
||||||
url: params.url,
|
url: params.url,
|
||||||
finalUrl: res.url || params.url,
|
finalUrl,
|
||||||
status: res.status,
|
status: res.status,
|
||||||
contentType,
|
contentType,
|
||||||
title,
|
title,
|
||||||
@@ -508,6 +581,7 @@ export function createWebFetchTool(options?: {
|
|||||||
url,
|
url,
|
||||||
extractMode,
|
extractMode,
|
||||||
maxChars: resolveMaxChars(maxChars ?? fetch?.maxChars, DEFAULT_FETCH_MAX_CHARS),
|
maxChars: resolveMaxChars(maxChars ?? fetch?.maxChars, DEFAULT_FETCH_MAX_CHARS),
|
||||||
|
maxRedirects: resolveMaxRedirects(fetch?.maxRedirects, DEFAULT_FETCH_MAX_REDIRECTS),
|
||||||
timeoutSeconds: resolveTimeoutSeconds(fetch?.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS),
|
timeoutSeconds: resolveTimeoutSeconds(fetch?.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS),
|
||||||
cacheTtlMs: resolveCacheTtlMs(fetch?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES),
|
cacheTtlMs: resolveCacheTtlMs(fetch?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES),
|
||||||
userAgent,
|
userAgent,
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ const mocks = vi.hoisted(() => ({
|
|||||||
aborted: false,
|
aborted: false,
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
const diagnosticMocks = vi.hoisted(() => ({
|
||||||
|
logMessageQueued: vi.fn(),
|
||||||
|
logMessageProcessed: vi.fn(),
|
||||||
|
logSessionStateChange: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("./route-reply.js", () => ({
|
vi.mock("./route-reply.js", () => ({
|
||||||
isRoutableChannel: (channel: string | undefined) =>
|
isRoutableChannel: (channel: string | undefined) =>
|
||||||
@@ -34,6 +39,12 @@ vi.mock("./abort.js", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../logging/diagnostic.js", () => ({
|
||||||
|
logMessageQueued: diagnosticMocks.logMessageQueued,
|
||||||
|
logMessageProcessed: diagnosticMocks.logMessageProcessed,
|
||||||
|
logSessionStateChange: diagnosticMocks.logSessionStateChange,
|
||||||
|
}));
|
||||||
|
|
||||||
const { dispatchReplyFromConfig } = await import("./dispatch-from-config.js");
|
const { dispatchReplyFromConfig } = await import("./dispatch-from-config.js");
|
||||||
const { resetInboundDedupe } = await import("./inbound-dedupe.js");
|
const { resetInboundDedupe } = await import("./inbound-dedupe.js");
|
||||||
|
|
||||||
@@ -50,6 +61,9 @@ function createDispatcher(): ReplyDispatcher {
|
|||||||
describe("dispatchReplyFromConfig", () => {
|
describe("dispatchReplyFromConfig", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetInboundDedupe();
|
resetInboundDedupe();
|
||||||
|
diagnosticMocks.logMessageQueued.mockReset();
|
||||||
|
diagnosticMocks.logMessageProcessed.mockReset();
|
||||||
|
diagnosticMocks.logSessionStateChange.mockReset();
|
||||||
});
|
});
|
||||||
it("does not route when Provider matches OriginatingChannel (even if Surface is missing)", async () => {
|
it("does not route when Provider matches OriginatingChannel (even if Surface is missing)", async () => {
|
||||||
mocks.tryFastAbortFromMessage.mockResolvedValue({
|
mocks.tryFastAbortFromMessage.mockResolvedValue({
|
||||||
@@ -186,4 +200,74 @@ describe("dispatchReplyFromConfig", () => {
|
|||||||
|
|
||||||
expect(replyResolver).toHaveBeenCalledTimes(1);
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("emits diagnostics when enabled", async () => {
|
||||||
|
mocks.tryFastAbortFromMessage.mockResolvedValue({
|
||||||
|
handled: false,
|
||||||
|
aborted: false,
|
||||||
|
});
|
||||||
|
const cfg = { diagnostics: { enabled: true } } as ClawdbotConfig;
|
||||||
|
const dispatcher = createDispatcher();
|
||||||
|
const ctx = buildTestCtx({
|
||||||
|
Provider: "slack",
|
||||||
|
Surface: "slack",
|
||||||
|
SessionKey: "agent:main:main",
|
||||||
|
MessageSid: "msg-1",
|
||||||
|
To: "slack:C123",
|
||||||
|
});
|
||||||
|
|
||||||
|
const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload;
|
||||||
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
||||||
|
|
||||||
|
expect(diagnosticMocks.logMessageQueued).toHaveBeenCalledTimes(1);
|
||||||
|
expect(diagnosticMocks.logSessionStateChange).toHaveBeenCalledWith({
|
||||||
|
sessionKey: "agent:main:main",
|
||||||
|
state: "processing",
|
||||||
|
reason: "message_start",
|
||||||
|
});
|
||||||
|
expect(diagnosticMocks.logMessageProcessed).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
channel: "slack",
|
||||||
|
outcome: "completed",
|
||||||
|
sessionKey: "agent:main:main",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks diagnostics skipped for duplicate inbound messages", async () => {
|
||||||
|
mocks.tryFastAbortFromMessage.mockResolvedValue({
|
||||||
|
handled: false,
|
||||||
|
aborted: false,
|
||||||
|
});
|
||||||
|
const cfg = { diagnostics: { enabled: true } } as ClawdbotConfig;
|
||||||
|
const ctx = buildTestCtx({
|
||||||
|
Provider: "whatsapp",
|
||||||
|
OriginatingChannel: "whatsapp",
|
||||||
|
OriginatingTo: "whatsapp:+15555550123",
|
||||||
|
MessageSid: "msg-dup",
|
||||||
|
});
|
||||||
|
const replyResolver = vi.fn(async () => ({ text: "hi" }) as ReplyPayload);
|
||||||
|
|
||||||
|
await dispatchReplyFromConfig({
|
||||||
|
ctx,
|
||||||
|
cfg,
|
||||||
|
dispatcher: createDispatcher(),
|
||||||
|
replyResolver,
|
||||||
|
});
|
||||||
|
await dispatchReplyFromConfig({
|
||||||
|
ctx,
|
||||||
|
cfg,
|
||||||
|
dispatcher: createDispatcher(),
|
||||||
|
replyResolver,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
||||||
|
expect(diagnosticMocks.logMessageProcessed).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
channel: "whatsapp",
|
||||||
|
outcome: "skipped",
|
||||||
|
reason: "duplicate",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import { logVerbose } from "../../globals.js";
|
import { logVerbose } from "../../globals.js";
|
||||||
|
import { isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
|
||||||
|
import {
|
||||||
|
logMessageProcessed,
|
||||||
|
logMessageQueued,
|
||||||
|
logSessionStateChange,
|
||||||
|
} from "../../logging/diagnostic.js";
|
||||||
import { getReplyFromConfig } from "../reply.js";
|
import { getReplyFromConfig } from "../reply.js";
|
||||||
import type { FinalizedMsgContext } from "../templating.js";
|
import type { FinalizedMsgContext } from "../templating.js";
|
||||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||||
@@ -21,8 +27,55 @@ export async function dispatchReplyFromConfig(params: {
|
|||||||
replyResolver?: typeof getReplyFromConfig;
|
replyResolver?: typeof getReplyFromConfig;
|
||||||
}): Promise<DispatchFromConfigResult> {
|
}): Promise<DispatchFromConfigResult> {
|
||||||
const { ctx, cfg, dispatcher } = params;
|
const { ctx, cfg, dispatcher } = params;
|
||||||
|
const diagnosticsEnabled = isDiagnosticsEnabled(cfg);
|
||||||
|
const channel = String(ctx.Surface ?? ctx.Provider ?? "unknown").toLowerCase();
|
||||||
|
const chatId = ctx.To ?? ctx.From;
|
||||||
|
const messageId = ctx.MessageSid ?? ctx.MessageSidFirst ?? ctx.MessageSidLast;
|
||||||
|
const sessionKey = ctx.SessionKey;
|
||||||
|
const startTime = diagnosticsEnabled ? Date.now() : 0;
|
||||||
|
const canTrackSession = diagnosticsEnabled && Boolean(sessionKey);
|
||||||
|
|
||||||
|
const recordProcessed = (
|
||||||
|
outcome: "completed" | "skipped" | "error",
|
||||||
|
opts?: {
|
||||||
|
reason?: string;
|
||||||
|
error?: string;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
if (!diagnosticsEnabled) return;
|
||||||
|
logMessageProcessed({
|
||||||
|
channel,
|
||||||
|
chatId,
|
||||||
|
messageId,
|
||||||
|
sessionKey,
|
||||||
|
durationMs: Date.now() - startTime,
|
||||||
|
outcome,
|
||||||
|
reason: opts?.reason,
|
||||||
|
error: opts?.error,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const markProcessing = () => {
|
||||||
|
if (!canTrackSession || !sessionKey) return;
|
||||||
|
logMessageQueued({ sessionKey, channel, source: "dispatch" });
|
||||||
|
logSessionStateChange({
|
||||||
|
sessionKey,
|
||||||
|
state: "processing",
|
||||||
|
reason: "message_start",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const markIdle = (reason: string) => {
|
||||||
|
if (!canTrackSession || !sessionKey) return;
|
||||||
|
logSessionStateChange({
|
||||||
|
sessionKey,
|
||||||
|
state: "idle",
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (shouldSkipDuplicateInbound(ctx)) {
|
if (shouldSkipDuplicateInbound(ctx)) {
|
||||||
|
recordProcessed("skipped", { reason: "duplicate" });
|
||||||
return { queuedFinal: false, counts: dispatcher.getQueuedCounts() };
|
return { queuedFinal: false, counts: dispatcher.getQueuedCounts() };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,95 +121,107 @@ export async function dispatchReplyFromConfig(params: {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fastAbort = await tryFastAbortFromMessage({ ctx, cfg });
|
markProcessing();
|
||||||
if (fastAbort.handled) {
|
|
||||||
const payload = {
|
try {
|
||||||
text: formatAbortReplyText(fastAbort.stoppedSubagents),
|
const fastAbort = await tryFastAbortFromMessage({ ctx, cfg });
|
||||||
} satisfies ReplyPayload;
|
if (fastAbort.handled) {
|
||||||
|
const payload = {
|
||||||
|
text: formatAbortReplyText(fastAbort.stoppedSubagents),
|
||||||
|
} satisfies ReplyPayload;
|
||||||
|
let queuedFinal = false;
|
||||||
|
let routedFinalCount = 0;
|
||||||
|
if (shouldRouteToOriginating && originatingChannel && originatingTo) {
|
||||||
|
const result = await routeReply({
|
||||||
|
payload,
|
||||||
|
channel: originatingChannel,
|
||||||
|
to: originatingTo,
|
||||||
|
sessionKey: ctx.SessionKey,
|
||||||
|
accountId: ctx.AccountId,
|
||||||
|
threadId: ctx.MessageThreadId,
|
||||||
|
cfg,
|
||||||
|
});
|
||||||
|
queuedFinal = result.ok;
|
||||||
|
if (result.ok) routedFinalCount += 1;
|
||||||
|
if (!result.ok) {
|
||||||
|
logVerbose(
|
||||||
|
`dispatch-from-config: route-reply (abort) failed: ${result.error ?? "unknown error"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
queuedFinal = dispatcher.sendFinalReply(payload);
|
||||||
|
}
|
||||||
|
await dispatcher.waitForIdle();
|
||||||
|
const counts = dispatcher.getQueuedCounts();
|
||||||
|
counts.final += routedFinalCount;
|
||||||
|
recordProcessed("completed", { reason: "fast_abort" });
|
||||||
|
markIdle("message_completed");
|
||||||
|
return { queuedFinal, counts };
|
||||||
|
}
|
||||||
|
|
||||||
|
const replyResult = await (params.replyResolver ?? getReplyFromConfig)(
|
||||||
|
ctx,
|
||||||
|
{
|
||||||
|
...params.replyOptions,
|
||||||
|
onToolResult: (payload: ReplyPayload) => {
|
||||||
|
if (shouldRouteToOriginating) {
|
||||||
|
// Fire-and-forget for streaming tool results when routing.
|
||||||
|
void sendPayloadAsync(payload);
|
||||||
|
} else {
|
||||||
|
// Synchronous dispatch to preserve callback timing.
|
||||||
|
dispatcher.sendToolResult(payload);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onBlockReply: (payload: ReplyPayload, context) => {
|
||||||
|
if (shouldRouteToOriginating) {
|
||||||
|
// Await routed sends so upstream can enforce ordering/timeouts.
|
||||||
|
return sendPayloadAsync(payload, context?.abortSignal);
|
||||||
|
} else {
|
||||||
|
// Synchronous dispatch to preserve callback timing.
|
||||||
|
dispatcher.sendBlockReply(payload);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
|
||||||
|
const replies = replyResult ? (Array.isArray(replyResult) ? replyResult : [replyResult]) : [];
|
||||||
|
|
||||||
let queuedFinal = false;
|
let queuedFinal = false;
|
||||||
let routedFinalCount = 0;
|
let routedFinalCount = 0;
|
||||||
if (shouldRouteToOriginating && originatingChannel && originatingTo) {
|
for (const reply of replies) {
|
||||||
const result = await routeReply({
|
if (shouldRouteToOriginating && originatingChannel && originatingTo) {
|
||||||
payload,
|
// Route final reply to originating channel.
|
||||||
channel: originatingChannel,
|
const result = await routeReply({
|
||||||
to: originatingTo,
|
payload: reply,
|
||||||
sessionKey: ctx.SessionKey,
|
channel: originatingChannel,
|
||||||
accountId: ctx.AccountId,
|
to: originatingTo,
|
||||||
threadId: ctx.MessageThreadId,
|
sessionKey: ctx.SessionKey,
|
||||||
cfg,
|
accountId: ctx.AccountId,
|
||||||
});
|
threadId: ctx.MessageThreadId,
|
||||||
queuedFinal = result.ok;
|
cfg,
|
||||||
if (result.ok) routedFinalCount += 1;
|
});
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`dispatch-from-config: route-reply (abort) failed: ${result.error ?? "unknown error"}`,
|
`dispatch-from-config: route-reply (final) failed: ${result.error ?? "unknown error"}`,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
queuedFinal = result.ok || queuedFinal;
|
||||||
|
if (result.ok) routedFinalCount += 1;
|
||||||
|
} else {
|
||||||
|
queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
queuedFinal = dispatcher.sendFinalReply(payload);
|
|
||||||
}
|
}
|
||||||
await dispatcher.waitForIdle();
|
await dispatcher.waitForIdle();
|
||||||
|
|
||||||
const counts = dispatcher.getQueuedCounts();
|
const counts = dispatcher.getQueuedCounts();
|
||||||
counts.final += routedFinalCount;
|
counts.final += routedFinalCount;
|
||||||
|
recordProcessed("completed");
|
||||||
|
markIdle("message_completed");
|
||||||
return { queuedFinal, counts };
|
return { queuedFinal, counts };
|
||||||
|
} catch (err) {
|
||||||
|
recordProcessed("error", { error: String(err) });
|
||||||
|
markIdle("message_error");
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
const replyResult = await (params.replyResolver ?? getReplyFromConfig)(
|
|
||||||
ctx,
|
|
||||||
{
|
|
||||||
...params.replyOptions,
|
|
||||||
onToolResult: (payload: ReplyPayload) => {
|
|
||||||
if (shouldRouteToOriginating) {
|
|
||||||
// Fire-and-forget for streaming tool results when routing.
|
|
||||||
void sendPayloadAsync(payload);
|
|
||||||
} else {
|
|
||||||
// Synchronous dispatch to preserve callback timing.
|
|
||||||
dispatcher.sendToolResult(payload);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onBlockReply: (payload: ReplyPayload, context) => {
|
|
||||||
if (shouldRouteToOriginating) {
|
|
||||||
// Await routed sends so upstream can enforce ordering/timeouts.
|
|
||||||
return sendPayloadAsync(payload, context?.abortSignal);
|
|
||||||
} else {
|
|
||||||
// Synchronous dispatch to preserve callback timing.
|
|
||||||
dispatcher.sendBlockReply(payload);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
cfg,
|
|
||||||
);
|
|
||||||
|
|
||||||
const replies = replyResult ? (Array.isArray(replyResult) ? replyResult : [replyResult]) : [];
|
|
||||||
|
|
||||||
let queuedFinal = false;
|
|
||||||
let routedFinalCount = 0;
|
|
||||||
for (const reply of replies) {
|
|
||||||
if (shouldRouteToOriginating && originatingChannel && originatingTo) {
|
|
||||||
// Route final reply to originating channel.
|
|
||||||
const result = await routeReply({
|
|
||||||
payload: reply,
|
|
||||||
channel: originatingChannel,
|
|
||||||
to: originatingTo,
|
|
||||||
sessionKey: ctx.SessionKey,
|
|
||||||
accountId: ctx.AccountId,
|
|
||||||
threadId: ctx.MessageThreadId,
|
|
||||||
cfg,
|
|
||||||
});
|
|
||||||
if (!result.ok) {
|
|
||||||
logVerbose(
|
|
||||||
`dispatch-from-config: route-reply (final) failed: ${result.error ?? "unknown error"}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
queuedFinal = result.ok || queuedFinal;
|
|
||||||
if (result.ok) routedFinalCount += 1;
|
|
||||||
} else {
|
|
||||||
queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await dispatcher.waitForIdle();
|
|
||||||
|
|
||||||
const counts = dispatcher.getQueuedCounts();
|
|
||||||
counts.final += routedFinalCount;
|
|
||||||
return { queuedFinal, counts };
|
|
||||||
}
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
c1cdb1b463e70d87976d88abf13e373e774c057e6796c947227c56b459af9d77
|
d9a36b111dfd93cbbb629fddf075800690ce0ae32a3a2ef201b365f4f6d6f5d5
|
||||||
+17440
-15247
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,78 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { Command } from "commander";
|
||||||
|
|
||||||
|
const clientMocks = vi.hoisted(() => ({
|
||||||
|
browserSnapshot: vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
format: "ai",
|
||||||
|
targetId: "t1",
|
||||||
|
url: "https://example.com",
|
||||||
|
snapshot: "ok",
|
||||||
|
})),
|
||||||
|
resolveBrowserControlUrl: vi.fn(() => "http://127.0.0.1:18791"),
|
||||||
|
}));
|
||||||
|
vi.mock("../browser/client.js", () => clientMocks);
|
||||||
|
|
||||||
|
const configMocks = vi.hoisted(() => ({
|
||||||
|
loadConfig: vi.fn(() => ({ browser: {} })),
|
||||||
|
}));
|
||||||
|
vi.mock("../config/config.js", () => configMocks);
|
||||||
|
|
||||||
|
const runtime = {
|
||||||
|
log: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
exit: vi.fn(),
|
||||||
|
};
|
||||||
|
vi.mock("../runtime.js", () => ({
|
||||||
|
defaultRuntime: runtime,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("browser cli snapshot defaults", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
configMocks.loadConfig.mockReturnValue({ browser: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses config snapshot defaults when mode is not provided", async () => {
|
||||||
|
configMocks.loadConfig.mockReturnValue({
|
||||||
|
browser: { snapshotDefaults: { mode: "efficient" } },
|
||||||
|
});
|
||||||
|
const { registerBrowserInspectCommands } = await import("./browser-cli-inspect.js");
|
||||||
|
const program = new Command();
|
||||||
|
const browser = program.command("browser").option("--json", false);
|
||||||
|
registerBrowserInspectCommands(browser, () => ({}));
|
||||||
|
|
||||||
|
await program.parseAsync(["browser", "snapshot"], { from: "user" });
|
||||||
|
|
||||||
|
expect(clientMocks.browserSnapshot).toHaveBeenCalledWith(
|
||||||
|
"http://127.0.0.1:18791",
|
||||||
|
expect.objectContaining({
|
||||||
|
format: "ai",
|
||||||
|
mode: "efficient",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not apply config snapshot defaults to aria snapshots", async () => {
|
||||||
|
configMocks.loadConfig.mockReturnValue({
|
||||||
|
browser: { snapshotDefaults: { mode: "efficient" } },
|
||||||
|
});
|
||||||
|
clientMocks.browserSnapshot.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
format: "aria",
|
||||||
|
targetId: "t1",
|
||||||
|
url: "https://example.com",
|
||||||
|
nodes: [],
|
||||||
|
});
|
||||||
|
const { registerBrowserInspectCommands } = await import("./browser-cli-inspect.js");
|
||||||
|
const program = new Command();
|
||||||
|
const browser = program.command("browser").option("--json", false);
|
||||||
|
registerBrowserInspectCommands(browser, () => ({}));
|
||||||
|
|
||||||
|
await program.parseAsync(["browser", "snapshot", "--format", "aria"], { from: "user" });
|
||||||
|
|
||||||
|
expect(clientMocks.browserSnapshot).toHaveBeenCalled();
|
||||||
|
const [, opts] = clientMocks.browserSnapshot.mock.calls.at(-1) ?? [];
|
||||||
|
expect(opts?.mode).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import type { Command } from "commander";
|
|||||||
|
|
||||||
import { browserSnapshot, resolveBrowserControlUrl } from "../browser/client.js";
|
import { browserSnapshot, resolveBrowserControlUrl } from "../browser/client.js";
|
||||||
import { browserScreenshotAction } from "../browser/client-actions.js";
|
import { browserScreenshotAction } from "../browser/client-actions.js";
|
||||||
|
import { loadConfig } from "../config/config.js";
|
||||||
import { danger } from "../globals.js";
|
import { danger } from "../globals.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
||||||
@@ -62,7 +63,11 @@ export function registerBrowserInspectCommands(
|
|||||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
const profile = parent?.browserProfile;
|
const profile = parent?.browserProfile;
|
||||||
const format = opts.format === "aria" ? "aria" : "ai";
|
const format = opts.format === "aria" ? "aria" : "ai";
|
||||||
const mode = opts.efficient === true || opts.mode === "efficient" ? "efficient" : undefined;
|
const configMode =
|
||||||
|
format === "ai" && loadConfig().browser?.snapshotDefaults?.mode === "efficient"
|
||||||
|
? "efficient"
|
||||||
|
: undefined;
|
||||||
|
const mode = opts.efficient === true || opts.mode === "efficient" ? "efficient" : configMode;
|
||||||
try {
|
try {
|
||||||
const result = await browserSnapshot(baseUrl, {
|
const result = await browserSnapshot(baseUrl, {
|
||||||
format,
|
format,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
|
import { listChannelPlugins } from "../channels/plugins/index.js";
|
||||||
import {
|
import {
|
||||||
channelsAddCommand,
|
channelsAddCommand,
|
||||||
channelsCapabilitiesCommand,
|
channelsCapabilitiesCommand,
|
||||||
@@ -12,11 +13,9 @@ import { danger } from "../globals.js";
|
|||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { formatDocsLink } from "../terminal/links.js";
|
import { formatDocsLink } from "../terminal/links.js";
|
||||||
import { theme } from "../terminal/theme.js";
|
import { theme } from "../terminal/theme.js";
|
||||||
import { resolveCliChannelOptions } from "./channel-options.js";
|
|
||||||
import { runChannelLogin, runChannelLogout } from "./channel-auth.js";
|
import { runChannelLogin, runChannelLogout } from "./channel-auth.js";
|
||||||
import { runCommandWithRuntime } from "./cli-utils.js";
|
import { runCommandWithRuntime } from "./cli-utils.js";
|
||||||
import { hasExplicitOptions } from "./command-options.js";
|
import { hasExplicitOptions } from "./command-options.js";
|
||||||
import { markCommandRequiresPluginRegistry } from "./program/command-metadata.js";
|
|
||||||
|
|
||||||
const optionNamesAdd = [
|
const optionNamesAdd = [
|
||||||
"channel",
|
"channel",
|
||||||
@@ -59,7 +58,9 @@ function runChannelsCommandWithDanger(action: () => Promise<void>, label: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function registerChannelsCli(program: Command) {
|
export function registerChannelsCli(program: Command) {
|
||||||
const channelNames = resolveCliChannelOptions().join("|");
|
const channelNames = listChannelPlugins()
|
||||||
|
.map((plugin) => plugin.id)
|
||||||
|
.join("|");
|
||||||
const channels = program
|
const channels = program
|
||||||
.command("channels")
|
.command("channels")
|
||||||
.description("Manage chat channel accounts")
|
.description("Manage chat channel accounts")
|
||||||
@@ -71,7 +72,6 @@ export function registerChannelsCli(program: Command) {
|
|||||||
"docs.clawd.bot/cli/channels",
|
"docs.clawd.bot/cli/channels",
|
||||||
)}\n`,
|
)}\n`,
|
||||||
);
|
);
|
||||||
markCommandRequiresPluginRegistry(channels);
|
|
||||||
|
|
||||||
channels
|
channels
|
||||||
.command("list")
|
.command("list")
|
||||||
|
|||||||
+183
-3
@@ -173,7 +173,7 @@ describe("cron cli", () => {
|
|||||||
expect(clearPatch?.patch?.agentId).toBeNull();
|
expect(clearPatch?.patch?.agentId).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not include model/thinking when no payload change is requested", async () => {
|
it("allows model/thinking updates without --message", async () => {
|
||||||
callGatewayFromCli.mockClear();
|
callGatewayFromCli.mockClear();
|
||||||
|
|
||||||
const { registerCronCli } = await import("./cron-cli.js");
|
const { registerCronCli } = await import("./cron-cli.js");
|
||||||
@@ -186,8 +186,188 @@ describe("cron cli", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
||||||
const patch = updateCall?.[2] as { patch?: { payload?: unknown } };
|
const patch = updateCall?.[2] as {
|
||||||
|
patch?: { payload?: { kind?: string; model?: string; thinking?: string } };
|
||||||
|
};
|
||||||
|
|
||||||
expect(patch?.patch?.payload).toBeUndefined();
|
expect(patch?.patch?.payload?.kind).toBe("agentTurn");
|
||||||
|
expect(patch?.patch?.payload?.model).toBe("opus");
|
||||||
|
expect(patch?.patch?.payload?.thinking).toBe("low");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates delivery settings without requiring --message", async () => {
|
||||||
|
callGatewayFromCli.mockClear();
|
||||||
|
|
||||||
|
const { registerCronCli } = await import("./cron-cli.js");
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerCronCli(program);
|
||||||
|
|
||||||
|
await program.parseAsync(
|
||||||
|
["cron", "edit", "job-1", "--deliver", "--channel", "telegram", "--to", "19098680"],
|
||||||
|
{ from: "user" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
||||||
|
const patch = updateCall?.[2] as {
|
||||||
|
patch?: {
|
||||||
|
payload?: {
|
||||||
|
kind?: string;
|
||||||
|
message?: string;
|
||||||
|
deliver?: boolean;
|
||||||
|
channel?: string;
|
||||||
|
to?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(patch?.patch?.payload?.kind).toBe("agentTurn");
|
||||||
|
expect(patch?.patch?.payload?.deliver).toBe(true);
|
||||||
|
expect(patch?.patch?.payload?.channel).toBe("telegram");
|
||||||
|
expect(patch?.patch?.payload?.to).toBe("19098680");
|
||||||
|
expect(patch?.patch?.payload?.message).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports --no-deliver on cron edit", async () => {
|
||||||
|
callGatewayFromCli.mockClear();
|
||||||
|
|
||||||
|
const { registerCronCli } = await import("./cron-cli.js");
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerCronCli(program);
|
||||||
|
|
||||||
|
await program.parseAsync(["cron", "edit", "job-1", "--no-deliver"], { from: "user" });
|
||||||
|
|
||||||
|
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
||||||
|
const patch = updateCall?.[2] as {
|
||||||
|
patch?: { payload?: { kind?: string; deliver?: boolean } };
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(patch?.patch?.payload?.kind).toBe("agentTurn");
|
||||||
|
expect(patch?.patch?.payload?.deliver).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not include undefined delivery fields when updating message", async () => {
|
||||||
|
callGatewayFromCli.mockClear();
|
||||||
|
|
||||||
|
const { registerCronCli } = await import("./cron-cli.js");
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerCronCli(program);
|
||||||
|
|
||||||
|
// Update message without delivery flags - should NOT include undefined delivery fields
|
||||||
|
await program.parseAsync(["cron", "edit", "job-1", "--message", "Updated message"], {
|
||||||
|
from: "user",
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
||||||
|
const patch = updateCall?.[2] as {
|
||||||
|
patch?: {
|
||||||
|
payload?: {
|
||||||
|
message?: string;
|
||||||
|
deliver?: boolean;
|
||||||
|
channel?: string;
|
||||||
|
to?: string;
|
||||||
|
bestEffortDeliver?: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Should include the new message
|
||||||
|
expect(patch?.patch?.payload?.message).toBe("Updated message");
|
||||||
|
|
||||||
|
// Should NOT include delivery fields at all (to preserve existing values)
|
||||||
|
expect(patch?.patch?.payload).not.toHaveProperty("deliver");
|
||||||
|
expect(patch?.patch?.payload).not.toHaveProperty("channel");
|
||||||
|
expect(patch?.patch?.payload).not.toHaveProperty("to");
|
||||||
|
expect(patch?.patch?.payload).not.toHaveProperty("bestEffortDeliver");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes delivery fields when explicitly provided with message", async () => {
|
||||||
|
callGatewayFromCli.mockClear();
|
||||||
|
|
||||||
|
const { registerCronCli } = await import("./cron-cli.js");
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerCronCli(program);
|
||||||
|
|
||||||
|
// Update message AND delivery - should include both
|
||||||
|
await program.parseAsync(
|
||||||
|
[
|
||||||
|
"cron",
|
||||||
|
"edit",
|
||||||
|
"job-1",
|
||||||
|
"--message",
|
||||||
|
"Updated message",
|
||||||
|
"--deliver",
|
||||||
|
"--channel",
|
||||||
|
"telegram",
|
||||||
|
"--to",
|
||||||
|
"19098680",
|
||||||
|
],
|
||||||
|
{ from: "user" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
||||||
|
const patch = updateCall?.[2] as {
|
||||||
|
patch?: {
|
||||||
|
payload?: {
|
||||||
|
message?: string;
|
||||||
|
deliver?: boolean;
|
||||||
|
channel?: string;
|
||||||
|
to?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Should include everything
|
||||||
|
expect(patch?.patch?.payload?.message).toBe("Updated message");
|
||||||
|
expect(patch?.patch?.payload?.deliver).toBe(true);
|
||||||
|
expect(patch?.patch?.payload?.channel).toBe("telegram");
|
||||||
|
expect(patch?.patch?.payload?.to).toBe("19098680");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes best-effort delivery when provided with message", async () => {
|
||||||
|
callGatewayFromCli.mockClear();
|
||||||
|
|
||||||
|
const { registerCronCli } = await import("./cron-cli.js");
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerCronCli(program);
|
||||||
|
|
||||||
|
await program.parseAsync(
|
||||||
|
["cron", "edit", "job-1", "--message", "Updated message", "--best-effort-deliver"],
|
||||||
|
{ from: "user" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
||||||
|
const patch = updateCall?.[2] as {
|
||||||
|
patch?: { payload?: { message?: string; bestEffortDeliver?: boolean } };
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(patch?.patch?.payload?.message).toBe("Updated message");
|
||||||
|
expect(patch?.patch?.payload?.bestEffortDeliver).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes no-best-effort delivery when provided with message", async () => {
|
||||||
|
callGatewayFromCli.mockClear();
|
||||||
|
|
||||||
|
const { registerCronCli } = await import("./cron-cli.js");
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerCronCli(program);
|
||||||
|
|
||||||
|
await program.parseAsync(
|
||||||
|
["cron", "edit", "job-1", "--message", "Updated message", "--no-best-effort-deliver"],
|
||||||
|
{ from: "user" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
||||||
|
const patch = updateCall?.[2] as {
|
||||||
|
patch?: { payload?: { message?: string; bestEffortDeliver?: boolean } };
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(patch?.patch?.payload?.message).toBe("Updated message");
|
||||||
|
expect(patch?.patch?.payload?.bestEffortDeliver).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -10,6 +10,15 @@ import {
|
|||||||
warnIfCronSchedulerDisabled,
|
warnIfCronSchedulerDisabled,
|
||||||
} from "./shared.js";
|
} from "./shared.js";
|
||||||
|
|
||||||
|
const assignIf = (
|
||||||
|
target: Record<string, unknown>,
|
||||||
|
key: string,
|
||||||
|
value: unknown,
|
||||||
|
shouldAssign: boolean,
|
||||||
|
) => {
|
||||||
|
if (shouldAssign) target[key] = value;
|
||||||
|
};
|
||||||
|
|
||||||
export function registerCronEditCommand(cron: Command) {
|
export function registerCronEditCommand(cron: Command) {
|
||||||
addGatewayClientOptions(
|
addGatewayClientOptions(
|
||||||
cron
|
cron
|
||||||
@@ -38,14 +47,15 @@ export function registerCronEditCommand(cron: Command) {
|
|||||||
.option(
|
.option(
|
||||||
"--deliver",
|
"--deliver",
|
||||||
"Deliver agent output (required when using last-route delivery without --to)",
|
"Deliver agent output (required when using last-route delivery without --to)",
|
||||||
false,
|
|
||||||
)
|
)
|
||||||
|
.option("--no-deliver", "Disable delivery")
|
||||||
.option("--channel <channel>", `Delivery channel (${getCronChannelOptions()})`)
|
.option("--channel <channel>", `Delivery channel (${getCronChannelOptions()})`)
|
||||||
.option(
|
.option(
|
||||||
"--to <dest>",
|
"--to <dest>",
|
||||||
"Delivery destination (E.164, Telegram chatId, or Discord channel/user)",
|
"Delivery destination (E.164, Telegram chatId, or Discord channel/user)",
|
||||||
)
|
)
|
||||||
.option("--best-effort-deliver", "Do not fail job if delivery fails", false)
|
.option("--best-effort-deliver", "Do not fail job if delivery fails")
|
||||||
|
.option("--no-best-effort-deliver", "Fail job when delivery fails")
|
||||||
.option("--post-prefix <prefix>", "Prefix for summary system event")
|
.option("--post-prefix <prefix>", "Prefix for summary system event")
|
||||||
.action(async (id, opts) => {
|
.action(async (id, opts) => {
|
||||||
try {
|
try {
|
||||||
@@ -105,35 +115,50 @@ export function registerCronEditCommand(cron: Command) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const payloadChosen = [opts.systemEvent, opts.message].filter(Boolean).length;
|
const hasSystemEventPatch = typeof opts.systemEvent === "string";
|
||||||
if (payloadChosen > 1) throw new Error("Choose at most one payload change");
|
const model =
|
||||||
if (opts.systemEvent) {
|
typeof opts.model === "string" && opts.model.trim() ? opts.model.trim() : undefined;
|
||||||
|
const thinking =
|
||||||
|
typeof opts.thinking === "string" && opts.thinking.trim()
|
||||||
|
? opts.thinking.trim()
|
||||||
|
: undefined;
|
||||||
|
const timeoutSeconds = opts.timeoutSeconds
|
||||||
|
? Number.parseInt(String(opts.timeoutSeconds), 10)
|
||||||
|
: undefined;
|
||||||
|
const hasTimeoutSeconds = Boolean(timeoutSeconds && Number.isFinite(timeoutSeconds));
|
||||||
|
const hasAgentTurnPatch =
|
||||||
|
typeof opts.message === "string" ||
|
||||||
|
Boolean(model) ||
|
||||||
|
Boolean(thinking) ||
|
||||||
|
hasTimeoutSeconds ||
|
||||||
|
typeof opts.deliver === "boolean" ||
|
||||||
|
typeof opts.channel === "string" ||
|
||||||
|
typeof opts.to === "string" ||
|
||||||
|
typeof opts.bestEffortDeliver === "boolean";
|
||||||
|
if (hasSystemEventPatch && hasAgentTurnPatch) {
|
||||||
|
throw new Error("Choose at most one payload change");
|
||||||
|
}
|
||||||
|
if (hasSystemEventPatch) {
|
||||||
patch.payload = {
|
patch.payload = {
|
||||||
kind: "systemEvent",
|
kind: "systemEvent",
|
||||||
text: String(opts.systemEvent),
|
text: String(opts.systemEvent),
|
||||||
};
|
};
|
||||||
} else if (opts.message) {
|
} else if (hasAgentTurnPatch) {
|
||||||
const model =
|
const payload: Record<string, unknown> = { kind: "agentTurn" };
|
||||||
typeof opts.model === "string" && opts.model.trim() ? opts.model.trim() : undefined;
|
assignIf(payload, "message", String(opts.message), typeof opts.message === "string");
|
||||||
const thinking =
|
assignIf(payload, "model", model, Boolean(model));
|
||||||
typeof opts.thinking === "string" && opts.thinking.trim()
|
assignIf(payload, "thinking", thinking, Boolean(thinking));
|
||||||
? opts.thinking.trim()
|
assignIf(payload, "timeoutSeconds", timeoutSeconds, hasTimeoutSeconds);
|
||||||
: undefined;
|
assignIf(payload, "deliver", opts.deliver, typeof opts.deliver === "boolean");
|
||||||
const timeoutSeconds = opts.timeoutSeconds
|
assignIf(payload, "channel", opts.channel, typeof opts.channel === "string");
|
||||||
? Number.parseInt(String(opts.timeoutSeconds), 10)
|
assignIf(payload, "to", opts.to, typeof opts.to === "string");
|
||||||
: undefined;
|
assignIf(
|
||||||
patch.payload = {
|
payload,
|
||||||
kind: "agentTurn",
|
"bestEffortDeliver",
|
||||||
message: String(opts.message),
|
opts.bestEffortDeliver,
|
||||||
model,
|
typeof opts.bestEffortDeliver === "boolean",
|
||||||
thinking,
|
);
|
||||||
timeoutSeconds:
|
patch.payload = payload;
|
||||||
timeoutSeconds && Number.isFinite(timeoutSeconds) ? timeoutSeconds : undefined,
|
|
||||||
deliver: opts.deliver ? true : undefined,
|
|
||||||
channel: typeof opts.channel === "string" ? opts.channel : undefined,
|
|
||||||
to: typeof opts.to === "string" ? opts.to : undefined,
|
|
||||||
bestEffortDeliver: opts.bestEffortDeliver ? true : undefined,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof opts.postPrefix === "string") {
|
if (typeof opts.postPrefix === "string") {
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
|
import { listChannelPlugins } from "../../channels/plugins/index.js";
|
||||||
import { parseAbsoluteTimeMs } from "../../cron/parse.js";
|
import { parseAbsoluteTimeMs } from "../../cron/parse.js";
|
||||||
import type { CronJob, CronSchedule } from "../../cron/types.js";
|
import type { CronJob, CronSchedule } from "../../cron/types.js";
|
||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
import { colorize, isRich, theme } from "../../terminal/theme.js";
|
import { colorize, isRich, theme } from "../../terminal/theme.js";
|
||||||
import { resolveCliChannelOptions } from "../channel-options.js";
|
|
||||||
import type { GatewayRpcOpts } from "../gateway-rpc.js";
|
import type { GatewayRpcOpts } from "../gateway-rpc.js";
|
||||||
import { callGatewayFromCli } from "../gateway-rpc.js";
|
import { callGatewayFromCli } from "../gateway-rpc.js";
|
||||||
|
|
||||||
export const getCronChannelOptions = () => ["last", ...resolveCliChannelOptions()].join("|");
|
export const getCronChannelOptions = () =>
|
||||||
|
["last", ...listChannelPlugins().map((plugin) => plugin.id)].join("|");
|
||||||
|
|
||||||
export async function warnIfCronSchedulerDisabled(opts: GatewayRpcOpts) {
|
export async function warnIfCronSchedulerDisabled(opts: GatewayRpcOpts) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { resolveMessageChannelSelection } from "../infra/outbound/channel-select
|
|||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { formatDocsLink } from "../terminal/links.js";
|
import { formatDocsLink } from "../terminal/links.js";
|
||||||
import { theme } from "../terminal/theme.js";
|
import { theme } from "../terminal/theme.js";
|
||||||
import { markCommandRequiresPluginRegistry } from "./program/command-metadata.js";
|
|
||||||
|
|
||||||
function parseLimit(value: unknown): number | null {
|
function parseLimit(value: unknown): number | null {
|
||||||
if (typeof value === "number" && Number.isFinite(value)) {
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
@@ -43,7 +42,6 @@ export function registerDirectoryCli(program: Command) {
|
|||||||
.action(() => {
|
.action(() => {
|
||||||
directory.help({ error: true });
|
directory.help({ error: true });
|
||||||
});
|
});
|
||||||
markCommandRequiresPluginRegistry(directory);
|
|
||||||
|
|
||||||
const withChannel = (cmd: Command) =>
|
const withChannel = (cmd: Command) =>
|
||||||
cmd
|
cmd
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { Command } from "commander";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const callGatewayFromCli = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("./gateway-rpc.js", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("./gateway-rpc.js")>("./gateway-rpc.js");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
callGatewayFromCli: (...args: unknown[]) => callGatewayFromCli(...args),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("logs cli", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
callGatewayFromCli.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes output directly to stdout/stderr", async () => {
|
||||||
|
callGatewayFromCli.mockResolvedValueOnce({
|
||||||
|
file: "/tmp/clawdbot.log",
|
||||||
|
cursor: 1,
|
||||||
|
size: 123,
|
||||||
|
lines: ["raw line"],
|
||||||
|
truncated: true,
|
||||||
|
reset: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stdoutWrites: string[] = [];
|
||||||
|
const stderrWrites: string[] = [];
|
||||||
|
const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
|
||||||
|
stdoutWrites.push(String(chunk));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
|
||||||
|
stderrWrites.push(String(chunk));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { registerLogsCli } = await import("./logs-cli.js");
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerLogsCli(program);
|
||||||
|
|
||||||
|
await program.parseAsync(["logs"], { from: "user" });
|
||||||
|
|
||||||
|
stdoutSpy.mockRestore();
|
||||||
|
stderrSpy.mockRestore();
|
||||||
|
|
||||||
|
expect(stdoutWrites.join("")).toContain("Log file:");
|
||||||
|
expect(stdoutWrites.join("")).toContain("raw line");
|
||||||
|
expect(stderrWrites.join("")).toContain("Log tail truncated");
|
||||||
|
expect(stderrWrites.join("")).toContain("Log cursor reset");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns when the output pipe closes", async () => {
|
||||||
|
callGatewayFromCli.mockResolvedValueOnce({
|
||||||
|
file: "/tmp/clawdbot.log",
|
||||||
|
lines: ["line one"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const stderrWrites: string[] = [];
|
||||||
|
const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => {
|
||||||
|
const err = new Error("EPIPE") as NodeJS.ErrnoException;
|
||||||
|
err.code = "EPIPE";
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
|
||||||
|
stderrWrites.push(String(chunk));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { registerLogsCli } = await import("./logs-cli.js");
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerLogsCli(program);
|
||||||
|
|
||||||
|
await program.parseAsync(["logs"], { from: "user" });
|
||||||
|
|
||||||
|
stdoutSpy.mockRestore();
|
||||||
|
stderrSpy.mockRestore();
|
||||||
|
|
||||||
|
expect(stderrWrites.join("")).toContain("output stdout closed");
|
||||||
|
});
|
||||||
|
});
|
||||||
+97
-45
@@ -2,8 +2,9 @@ import { setTimeout as delay } from "node:timers/promises";
|
|||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
import { buildGatewayConnectionDetails } from "../gateway/call.js";
|
import { buildGatewayConnectionDetails } from "../gateway/call.js";
|
||||||
import { parseLogLine } from "../logging/parse-log-line.js";
|
import { parseLogLine } from "../logging/parse-log-line.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
|
||||||
import { formatDocsLink } from "../terminal/links.js";
|
import { formatDocsLink } from "../terminal/links.js";
|
||||||
|
import { clearActiveProgressLine } from "../terminal/progress-line.js";
|
||||||
|
import { createSafeStreamWriter } from "../terminal/stream-writer.js";
|
||||||
import { colorize, isRich, theme } from "../terminal/theme.js";
|
import { colorize, isRich, theme } from "../terminal/theme.js";
|
||||||
import { formatCliCommand } from "./command-format.js";
|
import { formatCliCommand } from "./command-format.js";
|
||||||
import { addGatewayClientOptions, callGatewayFromCli } from "./gateway-rpc.js";
|
import { addGatewayClientOptions, callGatewayFromCli } from "./gateway-rpc.js";
|
||||||
@@ -104,10 +105,28 @@ function formatLogLine(
|
|||||||
return [head, messageValue].filter(Boolean).join(" ").trim();
|
return [head, messageValue].filter(Boolean).join(" ").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitJsonLine(payload: Record<string, unknown>, toStdErr = false) {
|
function createLogWriters() {
|
||||||
const text = `${JSON.stringify(payload)}\n`;
|
const writer = createSafeStreamWriter({
|
||||||
if (toStdErr) process.stderr.write(text);
|
beforeWrite: () => clearActiveProgressLine(),
|
||||||
else process.stdout.write(text);
|
onBrokenPipe: (err, stream) => {
|
||||||
|
const code = err.code ?? "EPIPE";
|
||||||
|
const target = stream === process.stdout ? "stdout" : "stderr";
|
||||||
|
const message = `clawdbot logs: output ${target} closed (${code}). Stopping tail.`;
|
||||||
|
try {
|
||||||
|
clearActiveProgressLine();
|
||||||
|
process.stderr.write(`${message}\n`);
|
||||||
|
} catch {
|
||||||
|
// ignore secondary failures while reporting the broken pipe
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
logLine: (text: string) => writer.writeLine(process.stdout, text),
|
||||||
|
errorLine: (text: string) => writer.writeLine(process.stderr, text),
|
||||||
|
emitJsonLine: (payload: Record<string, unknown>, toStdErr = false) =>
|
||||||
|
writer.write(toStdErr ? process.stderr : process.stdout, `${JSON.stringify(payload)}\n`),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitGatewayError(
|
function emitGatewayError(
|
||||||
@@ -115,6 +134,8 @@ function emitGatewayError(
|
|||||||
opts: LogsCliOptions,
|
opts: LogsCliOptions,
|
||||||
mode: "json" | "text",
|
mode: "json" | "text",
|
||||||
rich: boolean,
|
rich: boolean,
|
||||||
|
emitJsonLine: (payload: Record<string, unknown>, toStdErr?: boolean) => boolean,
|
||||||
|
errorLine: (text: string) => boolean,
|
||||||
) {
|
) {
|
||||||
const details = buildGatewayConnectionDetails({ url: opts.url });
|
const details = buildGatewayConnectionDetails({ url: opts.url });
|
||||||
const message = "Gateway not reachable. Is it running and accessible?";
|
const message = "Gateway not reachable. Is it running and accessible?";
|
||||||
@@ -122,22 +143,26 @@ function emitGatewayError(
|
|||||||
const errorText = err instanceof Error ? err.message : String(err);
|
const errorText = err instanceof Error ? err.message : String(err);
|
||||||
|
|
||||||
if (mode === "json") {
|
if (mode === "json") {
|
||||||
emitJsonLine(
|
if (
|
||||||
{
|
!emitJsonLine(
|
||||||
type: "error",
|
{
|
||||||
message,
|
type: "error",
|
||||||
error: errorText,
|
message,
|
||||||
details,
|
error: errorText,
|
||||||
hint,
|
details,
|
||||||
},
|
hint,
|
||||||
true,
|
},
|
||||||
);
|
true,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultRuntime.error(colorize(rich, theme.error, message));
|
if (!errorLine(colorize(rich, theme.error, message))) return;
|
||||||
defaultRuntime.error(details.message);
|
if (!errorLine(details.message)) return;
|
||||||
defaultRuntime.error(colorize(rich, theme.muted, hint));
|
errorLine(colorize(rich, theme.muted, hint));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerLogsCli(program: Command) {
|
export function registerLogsCli(program: Command) {
|
||||||
@@ -159,6 +184,7 @@ export function registerLogsCli(program: Command) {
|
|||||||
addGatewayClientOptions(logs);
|
addGatewayClientOptions(logs);
|
||||||
|
|
||||||
logs.action(async (opts: LogsCliOptions) => {
|
logs.action(async (opts: LogsCliOptions) => {
|
||||||
|
const { logLine, errorLine, emitJsonLine } = createLogWriters();
|
||||||
const interval = parsePositiveInt(opts.interval, 1000);
|
const interval = parsePositiveInt(opts.interval, 1000);
|
||||||
let cursor: number | undefined;
|
let cursor: number | undefined;
|
||||||
let first = true;
|
let first = true;
|
||||||
@@ -171,58 +197,84 @@ export function registerLogsCli(program: Command) {
|
|||||||
try {
|
try {
|
||||||
payload = await fetchLogs(opts, cursor);
|
payload = await fetchLogs(opts, cursor);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
emitGatewayError(err, opts, jsonMode ? "json" : "text", rich);
|
emitGatewayError(err, opts, jsonMode ? "json" : "text", rich, emitJsonLine, errorLine);
|
||||||
defaultRuntime.exit(1);
|
process.exit(1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const lines = Array.isArray(payload.lines) ? payload.lines : [];
|
const lines = Array.isArray(payload.lines) ? payload.lines : [];
|
||||||
if (jsonMode) {
|
if (jsonMode) {
|
||||||
if (first) {
|
if (first) {
|
||||||
emitJsonLine({
|
if (
|
||||||
type: "meta",
|
!emitJsonLine({
|
||||||
file: payload.file,
|
type: "meta",
|
||||||
cursor: payload.cursor,
|
file: payload.file,
|
||||||
size: payload.size,
|
cursor: payload.cursor,
|
||||||
});
|
size: payload.size,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const parsed = parseLogLine(line);
|
const parsed = parseLogLine(line);
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
emitJsonLine({ type: "log", ...parsed });
|
if (!emitJsonLine({ type: "log", ...parsed })) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
emitJsonLine({ type: "raw", raw: line });
|
if (!emitJsonLine({ type: "raw", raw: line })) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (payload.truncated) {
|
if (payload.truncated) {
|
||||||
emitJsonLine({
|
if (
|
||||||
type: "notice",
|
!emitJsonLine({
|
||||||
message: "Log tail truncated (increase --max-bytes).",
|
type: "notice",
|
||||||
});
|
message: "Log tail truncated (increase --max-bytes).",
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (payload.reset) {
|
if (payload.reset) {
|
||||||
emitJsonLine({
|
if (
|
||||||
type: "notice",
|
!emitJsonLine({
|
||||||
message: "Log cursor reset (file rotated).",
|
type: "notice",
|
||||||
});
|
message: "Log cursor reset (file rotated).",
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (first && payload.file) {
|
if (first && payload.file) {
|
||||||
const prefix = pretty ? colorize(rich, theme.muted, "Log file:") : "Log file:";
|
const prefix = pretty ? colorize(rich, theme.muted, "Log file:") : "Log file:";
|
||||||
defaultRuntime.log(`${prefix} ${payload.file}`);
|
if (!logLine(`${prefix} ${payload.file}`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
defaultRuntime.log(
|
if (
|
||||||
formatLogLine(line, {
|
!logLine(
|
||||||
pretty,
|
formatLogLine(line, {
|
||||||
rich,
|
pretty,
|
||||||
}),
|
rich,
|
||||||
);
|
}),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (payload.truncated) {
|
if (payload.truncated) {
|
||||||
defaultRuntime.error("Log tail truncated (increase --max-bytes).");
|
if (!errorLine("Log tail truncated (increase --max-bytes).")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (payload.reset) {
|
if (payload.reset) {
|
||||||
defaultRuntime.error("Log cursor reset (file rotated).");
|
if (!errorLine("Log cursor reset (file rotated).")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cursor =
|
cursor =
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { formatAge, formatPermissions, parseNodeList, parsePairingList } from ".
|
|||||||
import { runNodesCommand } from "./cli-utils.js";
|
import { runNodesCommand } from "./cli-utils.js";
|
||||||
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
|
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
|
||||||
import type { NodesRpcOpts } from "./types.js";
|
import type { NodesRpcOpts } from "./types.js";
|
||||||
|
import { renderTable } from "../../terminal/table.js";
|
||||||
|
import { isRich, theme } from "../../terminal/theme.js";
|
||||||
|
|
||||||
function formatVersionLabel(raw: string) {
|
function formatVersionLabel(raw: string) {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
@@ -155,23 +157,65 @@ export function registerNodesStatusCommands(nodes: Command) {
|
|||||||
}
|
}
|
||||||
const { pending, paired } = parsePairingList(result);
|
const { pending, paired } = parsePairingList(result);
|
||||||
defaultRuntime.log(`Pending: ${pending.length} · Paired: ${paired.length}`);
|
defaultRuntime.log(`Pending: ${pending.length} · Paired: ${paired.length}`);
|
||||||
|
const rich = isRich();
|
||||||
|
const heading = (text: string) => (rich ? theme.heading(text) : text);
|
||||||
|
const muted = (text: string) => (rich ? theme.muted(text) : text);
|
||||||
|
const warn = (text: string) => (rich ? theme.warn(text) : text);
|
||||||
|
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
if (pending.length > 0) {
|
if (pending.length > 0) {
|
||||||
defaultRuntime.log("\nPending:");
|
const pendingRows = pending.map((r) => ({
|
||||||
for (const r of pending) {
|
Request: r.requestId,
|
||||||
const name = r.displayName || r.nodeId;
|
Node: r.displayName?.trim() ? r.displayName.trim() : r.nodeId,
|
||||||
const repair = r.isRepair ? " (repair)" : "";
|
IP: r.remoteIp ?? "",
|
||||||
const ip = r.remoteIp ? ` · ${r.remoteIp}` : "";
|
Requested:
|
||||||
const age = typeof r.ts === "number" ? ` · ${formatAge(Date.now() - r.ts)} ago` : "";
|
typeof r.ts === "number"
|
||||||
defaultRuntime.log(`- ${r.requestId}: ${name}${repair}${ip}${age}`);
|
? `${formatAge(Math.max(0, now - r.ts))} ago`
|
||||||
}
|
: muted("unknown"),
|
||||||
|
Repair: r.isRepair ? warn("yes") : "",
|
||||||
|
}));
|
||||||
|
defaultRuntime.log("");
|
||||||
|
defaultRuntime.log(heading("Pending"));
|
||||||
|
defaultRuntime.log(
|
||||||
|
renderTable({
|
||||||
|
width: tableWidth,
|
||||||
|
columns: [
|
||||||
|
{ key: "Request", header: "Request", minWidth: 8 },
|
||||||
|
{ key: "Node", header: "Node", minWidth: 14, flex: true },
|
||||||
|
{ key: "IP", header: "IP", minWidth: 10 },
|
||||||
|
{ key: "Requested", header: "Requested", minWidth: 12 },
|
||||||
|
{ key: "Repair", header: "Repair", minWidth: 6 },
|
||||||
|
],
|
||||||
|
rows: pendingRows,
|
||||||
|
}).trimEnd(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (paired.length > 0) {
|
if (paired.length > 0) {
|
||||||
defaultRuntime.log("\nPaired:");
|
const pairedRows = paired.map((n) => ({
|
||||||
for (const n of paired) {
|
Node: n.displayName?.trim() ? n.displayName.trim() : n.nodeId,
|
||||||
const name = n.displayName || n.nodeId;
|
Id: n.nodeId,
|
||||||
const ip = n.remoteIp ? ` · ${n.remoteIp}` : "";
|
IP: n.remoteIp ?? "",
|
||||||
defaultRuntime.log(`- ${n.nodeId}: ${name}${ip}`);
|
LastConnect:
|
||||||
}
|
typeof n.lastConnectedAtMs === "number"
|
||||||
|
? `${formatAge(Math.max(0, now - n.lastConnectedAtMs))} ago`
|
||||||
|
: muted("unknown"),
|
||||||
|
}));
|
||||||
|
defaultRuntime.log("");
|
||||||
|
defaultRuntime.log(heading("Paired"));
|
||||||
|
defaultRuntime.log(
|
||||||
|
renderTable({
|
||||||
|
width: tableWidth,
|
||||||
|
columns: [
|
||||||
|
{ key: "Node", header: "Node", minWidth: 14, flex: true },
|
||||||
|
{ key: "Id", header: "ID", minWidth: 10 },
|
||||||
|
{ key: "IP", header: "IP", minWidth: 10 },
|
||||||
|
{ key: "LastConnect", header: "Last Connect", minWidth: 14 },
|
||||||
|
],
|
||||||
|
rows: pairedRows,
|
||||||
|
}).trimEnd(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ export type PairedNode = {
|
|||||||
permissions?: Record<string, boolean>;
|
permissions?: Record<string, boolean>;
|
||||||
createdAtMs?: number;
|
createdAtMs?: number;
|
||||||
approvedAtMs?: number;
|
approvedAtMs?: number;
|
||||||
|
lastConnectedAtMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PairingList = {
|
export type PairingList = {
|
||||||
|
|||||||
@@ -34,16 +34,12 @@ vi.mock("../channels/plugins/index.js", () => ({
|
|||||||
normalizeChannelId,
|
normalizeChannelId,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../config/config.js", async (importOriginal) => {
|
vi.mock("../config/config.js", () => ({
|
||||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
loadConfig: vi.fn().mockReturnValue({}),
|
||||||
return {
|
}));
|
||||||
...actual,
|
|
||||||
loadConfig: vi.fn().mockReturnValue({}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("pairing cli", () => {
|
describe("pairing cli", () => {
|
||||||
it("defers pairing channel lookup until command execution", async () => {
|
it("evaluates pairing channels when registering the CLI (not at import)", async () => {
|
||||||
listPairingChannels.mockClear();
|
listPairingChannels.mockClear();
|
||||||
|
|
||||||
const { registerPairingCli } = await import("./pairing-cli.js");
|
const { registerPairingCli } = await import("./pairing-cli.js");
|
||||||
@@ -53,10 +49,6 @@ describe("pairing cli", () => {
|
|||||||
program.name("test");
|
program.name("test");
|
||||||
registerPairingCli(program);
|
registerPairingCli(program);
|
||||||
|
|
||||||
expect(listPairingChannels).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
listChannelPairingRequests.mockResolvedValueOnce([]);
|
|
||||||
await program.parseAsync(["pairing", "list", "telegram"], { from: "user" });
|
|
||||||
expect(listPairingChannels).toHaveBeenCalledTimes(1);
|
expect(listPairingChannels).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+4
-11
@@ -10,9 +10,7 @@ import {
|
|||||||
} from "../pairing/pairing-store.js";
|
} from "../pairing/pairing-store.js";
|
||||||
import { formatDocsLink } from "../terminal/links.js";
|
import { formatDocsLink } from "../terminal/links.js";
|
||||||
import { theme } from "../terminal/theme.js";
|
import { theme } from "../terminal/theme.js";
|
||||||
import { resolveCliChannelOptions } from "./channel-options.js";
|
|
||||||
import { formatCliCommand } from "./command-format.js";
|
import { formatCliCommand } from "./command-format.js";
|
||||||
import { markCommandRequiresPluginRegistry } from "./program/command-metadata.js";
|
|
||||||
|
|
||||||
/** Parse channel, allowing extension channels not in core registry. */
|
/** Parse channel, allowing extension channels not in core registry. */
|
||||||
function parseChannel(raw: unknown, channels: PairingChannel[]): PairingChannel {
|
function parseChannel(raw: unknown, channels: PairingChannel[]): PairingChannel {
|
||||||
@@ -46,9 +44,7 @@ async function notifyApproved(channel: PairingChannel, id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function registerPairingCli(program: Command) {
|
export function registerPairingCli(program: Command) {
|
||||||
const channelOptions = resolveCliChannelOptions();
|
const channels = listPairingChannels();
|
||||||
const channelHint =
|
|
||||||
channelOptions.length > 0 ? `Channel (${channelOptions.join(", ")})` : "Channel";
|
|
||||||
const pairing = program
|
const pairing = program
|
||||||
.command("pairing")
|
.command("pairing")
|
||||||
.description("Secure DM pairing (approve inbound requests)")
|
.description("Secure DM pairing (approve inbound requests)")
|
||||||
@@ -57,16 +53,14 @@ export function registerPairingCli(program: Command) {
|
|||||||
() =>
|
() =>
|
||||||
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/pairing", "docs.clawd.bot/cli/pairing")}\n`,
|
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/pairing", "docs.clawd.bot/cli/pairing")}\n`,
|
||||||
);
|
);
|
||||||
markCommandRequiresPluginRegistry(pairing);
|
|
||||||
|
|
||||||
pairing
|
pairing
|
||||||
.command("list")
|
.command("list")
|
||||||
.description("List pending pairing requests")
|
.description("List pending pairing requests")
|
||||||
.option("--channel <channel>", channelHint)
|
.option("--channel <channel>", `Channel (${channels.join(", ")})`)
|
||||||
.argument("[channel]", channelHint)
|
.argument("[channel]", `Channel (${channels.join(", ")})`)
|
||||||
.option("--json", "Print JSON", false)
|
.option("--json", "Print JSON", false)
|
||||||
.action(async (channelArg, opts) => {
|
.action(async (channelArg, opts) => {
|
||||||
const channels = listPairingChannels();
|
|
||||||
const channelRaw = opts.channel ?? channelArg;
|
const channelRaw = opts.channel ?? channelArg;
|
||||||
if (!channelRaw) {
|
if (!channelRaw) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -93,12 +87,11 @@ export function registerPairingCli(program: Command) {
|
|||||||
pairing
|
pairing
|
||||||
.command("approve")
|
.command("approve")
|
||||||
.description("Approve a pairing code and allow that sender")
|
.description("Approve a pairing code and allow that sender")
|
||||||
.option("--channel <channel>", channelHint)
|
.option("--channel <channel>", `Channel (${channels.join(", ")})`)
|
||||||
.argument("<codeOrChannel>", "Pairing code (or channel when using 2 args)")
|
.argument("<codeOrChannel>", "Pairing code (or channel when using 2 args)")
|
||||||
.argument("[code]", "Pairing code (when channel is passed as the 1st arg)")
|
.argument("[code]", "Pairing code (when channel is passed as the 1st arg)")
|
||||||
.option("--notify", "Notify the requester on the same channel", false)
|
.option("--notify", "Notify the requester on the same channel", false)
|
||||||
.action(async (codeOrChannel, code, opts) => {
|
.action(async (codeOrChannel, code, opts) => {
|
||||||
const channels = listPairingChannels();
|
|
||||||
const channelRaw = opts.channel ?? codeOrChannel;
|
const channelRaw = opts.channel ?? codeOrChannel;
|
||||||
const resolvedCode = opts.channel ? codeOrChannel : code;
|
const resolvedCode = opts.channel ? codeOrChannel : code;
|
||||||
if (!opts.channel && !code) {
|
if (!opts.channel && !code) {
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import { Command } from "commander";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import {
|
|
||||||
commandRequiresPluginRegistry,
|
|
||||||
markCommandRequiresPluginRegistry,
|
|
||||||
} from "./command-metadata.js";
|
|
||||||
|
|
||||||
describe("commandRequiresPluginRegistry", () => {
|
|
||||||
it("detects direct requirement", () => {
|
|
||||||
const program = new Command();
|
|
||||||
const cmd = program.command("message");
|
|
||||||
markCommandRequiresPluginRegistry(cmd);
|
|
||||||
expect(commandRequiresPluginRegistry(cmd)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("walks parent chain", () => {
|
|
||||||
const program = new Command();
|
|
||||||
const parent = program.command("channels");
|
|
||||||
const child = parent.command("list");
|
|
||||||
markCommandRequiresPluginRegistry(parent);
|
|
||||||
expect(commandRequiresPluginRegistry(child)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import type { Command } from "commander";
|
|
||||||
|
|
||||||
const REQUIRES_PLUGIN_REGISTRY = Symbol.for("clawdbot.requiresPluginRegistry");
|
|
||||||
|
|
||||||
type CommandWithPluginRequirement = Command & {
|
|
||||||
[REQUIRES_PLUGIN_REGISTRY]?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function markCommandRequiresPluginRegistry(command: Command): Command {
|
|
||||||
(command as CommandWithPluginRequirement)[REQUIRES_PLUGIN_REGISTRY] = true;
|
|
||||||
return command;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function commandRequiresPluginRegistry(command?: Command | null): boolean {
|
|
||||||
let current: Command | null | undefined = command;
|
|
||||||
while (current) {
|
|
||||||
if ((current as CommandWithPluginRequirement)[REQUIRES_PLUGIN_REGISTRY]) return true;
|
|
||||||
current = current.parent ?? undefined;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
@@ -86,7 +86,6 @@ const routeSessions: RouteSpec = {
|
|||||||
|
|
||||||
const routeAgentsList: RouteSpec = {
|
const routeAgentsList: RouteSpec = {
|
||||||
match: (path) => path[0] === "agents" && path[1] === "list",
|
match: (path) => path[0] === "agents" && path[1] === "list",
|
||||||
loadPlugins: true,
|
|
||||||
run: async (argv) => {
|
run: async (argv) => {
|
||||||
const json = hasFlag(argv, "--json");
|
const json = hasFlag(argv, "--json");
|
||||||
const bindings = hasFlag(argv, "--bindings");
|
const bindings = hasFlag(argv, "--bindings");
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import { Command } from "commander";
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import { markCommandRequiresPluginRegistry } from "./command-metadata.js";
|
|
||||||
|
|
||||||
vi.mock("../plugin-registry.js", () => ({
|
|
||||||
ensurePluginRegistryLoaded: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("./config-guard.js", () => ({
|
|
||||||
ensureConfigReady: vi.fn(async () => {}),
|
|
||||||
}));
|
|
||||||
vi.mock("../banner.js", () => ({
|
|
||||||
emitCliBanner: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("../argv.js", () => ({
|
|
||||||
getCommandPath: vi.fn(() => ["message"]),
|
|
||||||
hasHelpOrVersion: vi.fn(() => false),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const loadRegisterPreActionHooks = async () => {
|
|
||||||
const mod = await import("./preaction.js");
|
|
||||||
return mod.registerPreActionHooks;
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadEnsurePluginRegistryLoaded = async () => {
|
|
||||||
const mod = await import("../plugin-registry.js");
|
|
||||||
return mod.ensurePluginRegistryLoaded;
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("registerPreActionHooks", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
const ensurePluginRegistryLoaded = await loadEnsurePluginRegistryLoaded();
|
|
||||||
vi.mocked(ensurePluginRegistryLoaded).mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("loads plugins for marked commands", async () => {
|
|
||||||
const registerPreActionHooks = await loadRegisterPreActionHooks();
|
|
||||||
const ensurePluginRegistryLoaded = await loadEnsurePluginRegistryLoaded();
|
|
||||||
const program = new Command();
|
|
||||||
registerPreActionHooks(program, "test");
|
|
||||||
|
|
||||||
const message = program.command("message").action(() => {});
|
|
||||||
markCommandRequiresPluginRegistry(message);
|
|
||||||
|
|
||||||
const originalArgv = process.argv;
|
|
||||||
const argv = ["node", "clawdbot", "message"];
|
|
||||||
process.argv = argv;
|
|
||||||
try {
|
|
||||||
await program.parseAsync(argv);
|
|
||||||
} finally {
|
|
||||||
process.argv = originalArgv;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(vi.mocked(ensurePluginRegistryLoaded)).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -2,9 +2,8 @@ import type { Command } from "commander";
|
|||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
import { emitCliBanner } from "../banner.js";
|
import { emitCliBanner } from "../banner.js";
|
||||||
import { getCommandPath, hasHelpOrVersion } from "../argv.js";
|
import { getCommandPath, hasHelpOrVersion } from "../argv.js";
|
||||||
import { ensurePluginRegistryLoaded } from "../plugin-registry.js";
|
|
||||||
import { commandRequiresPluginRegistry } from "./command-metadata.js";
|
|
||||||
import { ensureConfigReady } from "./config-guard.js";
|
import { ensureConfigReady } from "./config-guard.js";
|
||||||
|
import { ensurePluginRegistryLoaded } from "../plugin-registry.js";
|
||||||
|
|
||||||
function setProcessTitleForCommand(actionCommand: Command) {
|
function setProcessTitleForCommand(actionCommand: Command) {
|
||||||
let current: Command = actionCommand;
|
let current: Command = actionCommand;
|
||||||
@@ -16,17 +15,20 @@ function setProcessTitleForCommand(actionCommand: Command) {
|
|||||||
process.title = `clawdbot-${name}`;
|
process.title = `clawdbot-${name}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Commands that need channel plugins loaded
|
||||||
|
const PLUGIN_REQUIRED_COMMANDS = new Set(["message", "channels", "directory"]);
|
||||||
|
|
||||||
export function registerPreActionHooks(program: Command, programVersion: string) {
|
export function registerPreActionHooks(program: Command, programVersion: string) {
|
||||||
program.hook("preAction", async (_thisCommand, actionCommand) => {
|
program.hook("preAction", async (_thisCommand, actionCommand) => {
|
||||||
setProcessTitleForCommand(actionCommand);
|
setProcessTitleForCommand(actionCommand);
|
||||||
emitCliBanner(programVersion);
|
emitCliBanner(programVersion);
|
||||||
const argv = process.argv;
|
const argv = process.argv;
|
||||||
if (hasHelpOrVersion(argv)) return;
|
if (hasHelpOrVersion(argv)) return;
|
||||||
const needsPlugins = commandRequiresPluginRegistry(actionCommand);
|
|
||||||
const commandPath = getCommandPath(argv, 2);
|
const commandPath = getCommandPath(argv, 2);
|
||||||
if (commandPath[0] === "doctor") return;
|
if (commandPath[0] === "doctor") return;
|
||||||
await ensureConfigReady({ runtime: defaultRuntime, commandPath });
|
await ensureConfigReady({ runtime: defaultRuntime, commandPath });
|
||||||
if (needsPlugins) {
|
// Load plugins for commands that need channel access
|
||||||
|
if (PLUGIN_REQUIRED_COMMANDS.has(commandPath[0])) {
|
||||||
ensurePluginRegistryLoaded();
|
ensurePluginRegistryLoaded();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,11 +14,10 @@ import { theme } from "../../terminal/theme.js";
|
|||||||
import { hasExplicitOptions } from "../command-options.js";
|
import { hasExplicitOptions } from "../command-options.js";
|
||||||
import { createDefaultDeps } from "../deps.js";
|
import { createDefaultDeps } from "../deps.js";
|
||||||
import { runCommandWithRuntime } from "../cli-utils.js";
|
import { runCommandWithRuntime } from "../cli-utils.js";
|
||||||
import { markCommandRequiresPluginRegistry } from "./command-metadata.js";
|
|
||||||
import { collectOption } from "./helpers.js";
|
import { collectOption } from "./helpers.js";
|
||||||
|
|
||||||
export function registerAgentCommands(program: Command, args: { agentChannelOptions: string }) {
|
export function registerAgentCommands(program: Command, args: { agentChannelOptions: string }) {
|
||||||
const agent = program
|
program
|
||||||
.command("agent")
|
.command("agent")
|
||||||
.description("Run an agent turn via the Gateway (use --local for embedded)")
|
.description("Run an agent turn via the Gateway (use --local for embedded)")
|
||||||
.requiredOption("-m, --message <text>", "Message body for the agent")
|
.requiredOption("-m, --message <text>", "Message body for the agent")
|
||||||
@@ -68,7 +67,6 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent
|
|||||||
await agentCliCommand(opts, defaultRuntime, deps);
|
await agentCliCommand(opts, defaultRuntime, deps);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
markCommandRequiresPluginRegistry(agent);
|
|
||||||
|
|
||||||
const agents = program
|
const agents = program
|
||||||
.command("agents")
|
.command("agents")
|
||||||
@@ -78,7 +76,6 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent
|
|||||||
() =>
|
() =>
|
||||||
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/agents", "docs.clawd.bot/cli/agents")}\n`,
|
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/agents", "docs.clawd.bot/cli/agents")}\n`,
|
||||||
);
|
);
|
||||||
markCommandRequiresPluginRegistry(agents);
|
|
||||||
|
|
||||||
agents
|
agents
|
||||||
.command("list")
|
.command("list")
|
||||||
|
|||||||
@@ -8,10 +8,9 @@ import { defaultRuntime } from "../../runtime.js";
|
|||||||
import { formatDocsLink } from "../../terminal/links.js";
|
import { formatDocsLink } from "../../terminal/links.js";
|
||||||
import { theme } from "../../terminal/theme.js";
|
import { theme } from "../../terminal/theme.js";
|
||||||
import { runCommandWithRuntime } from "../cli-utils.js";
|
import { runCommandWithRuntime } from "../cli-utils.js";
|
||||||
import { markCommandRequiresPluginRegistry } from "./command-metadata.js";
|
|
||||||
|
|
||||||
export function registerConfigureCommand(program: Command) {
|
export function registerConfigureCommand(program: Command) {
|
||||||
const configure = program
|
program
|
||||||
.command("configure")
|
.command("configure")
|
||||||
.description("Interactive prompt to set up credentials, devices, and agent defaults")
|
.description("Interactive prompt to set up credentials, devices, and agent defaults")
|
||||||
.addHelpText(
|
.addHelpText(
|
||||||
@@ -49,5 +48,4 @@ export function registerConfigureCommand(program: Command) {
|
|||||||
await configureCommandWithSections(sections as never, defaultRuntime);
|
await configureCommandWithSections(sections as never, defaultRuntime);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
markCommandRequiresPluginRegistry(configure);
|
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,6 @@ import type { Command } from "commander";
|
|||||||
import { formatDocsLink } from "../../terminal/links.js";
|
import { formatDocsLink } from "../../terminal/links.js";
|
||||||
import { theme } from "../../terminal/theme.js";
|
import { theme } from "../../terminal/theme.js";
|
||||||
import type { ProgramContext } from "./context.js";
|
import type { ProgramContext } from "./context.js";
|
||||||
import { markCommandRequiresPluginRegistry } from "./command-metadata.js";
|
|
||||||
import { createMessageCliHelpers } from "./message/helpers.js";
|
import { createMessageCliHelpers } from "./message/helpers.js";
|
||||||
import { registerMessageDiscordAdminCommands } from "./message/register.discord-admin.js";
|
import { registerMessageDiscordAdminCommands } from "./message/register.discord-admin.js";
|
||||||
import {
|
import {
|
||||||
@@ -40,7 +39,6 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/message", "docs.clawd.bot/cli/mes
|
|||||||
.action(() => {
|
.action(() => {
|
||||||
message.help({ error: true });
|
message.help({ error: true });
|
||||||
});
|
});
|
||||||
markCommandRequiresPluginRegistry(message);
|
|
||||||
|
|
||||||
const helpers = createMessageCliHelpers(message, ctx.messageChannelOptions);
|
const helpers = createMessageCliHelpers(message, ctx.messageChannelOptions);
|
||||||
registerMessageSendCommand(message, helpers);
|
registerMessageSendCommand(message, helpers);
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { defaultRuntime } from "../../runtime.js";
|
|||||||
import { formatDocsLink } from "../../terminal/links.js";
|
import { formatDocsLink } from "../../terminal/links.js";
|
||||||
import { theme } from "../../terminal/theme.js";
|
import { theme } from "../../terminal/theme.js";
|
||||||
import { runCommandWithRuntime } from "../cli-utils.js";
|
import { runCommandWithRuntime } from "../cli-utils.js";
|
||||||
import { markCommandRequiresPluginRegistry } from "./command-metadata.js";
|
|
||||||
|
|
||||||
function resolveInstallDaemonFlag(
|
function resolveInstallDaemonFlag(
|
||||||
command: unknown,
|
command: unknown,
|
||||||
@@ -33,7 +32,7 @@ function resolveInstallDaemonFlag(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function registerOnboardCommand(program: Command) {
|
export function registerOnboardCommand(program: Command) {
|
||||||
const onboard = program
|
program
|
||||||
.command("onboard")
|
.command("onboard")
|
||||||
.description("Interactive wizard to set up the gateway, workspace, and skills")
|
.description("Interactive wizard to set up the gateway, workspace, and skills")
|
||||||
.addHelpText(
|
.addHelpText(
|
||||||
@@ -151,5 +150,4 @@ export function registerOnboardCommand(program: Command) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
markCommandRequiresPluginRegistry(onboard);
|
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,6 @@ import { defaultRuntime } from "../../runtime.js";
|
|||||||
import { formatDocsLink } from "../../terminal/links.js";
|
import { formatDocsLink } from "../../terminal/links.js";
|
||||||
import { theme } from "../../terminal/theme.js";
|
import { theme } from "../../terminal/theme.js";
|
||||||
import { runCommandWithRuntime } from "../cli-utils.js";
|
import { runCommandWithRuntime } from "../cli-utils.js";
|
||||||
import { markCommandRequiresPluginRegistry } from "./command-metadata.js";
|
|
||||||
import { parsePositiveIntOrUndefined } from "./helpers.js";
|
import { parsePositiveIntOrUndefined } from "./helpers.js";
|
||||||
|
|
||||||
function resolveVerbose(opts: { verbose?: boolean; debug?: boolean }): boolean {
|
function resolveVerbose(opts: { verbose?: boolean; debug?: boolean }): boolean {
|
||||||
@@ -25,7 +24,7 @@ function parseTimeoutMs(timeout: unknown): number | null | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function registerStatusHealthSessionsCommands(program: Command) {
|
export function registerStatusHealthSessionsCommands(program: Command) {
|
||||||
const status = program
|
program
|
||||||
.command("status")
|
.command("status")
|
||||||
.description("Show channel health and recent session recipients")
|
.description("Show channel health and recent session recipients")
|
||||||
.option("--json", "Output JSON instead of text", false)
|
.option("--json", "Output JSON instead of text", false)
|
||||||
@@ -73,9 +72,8 @@ Examples:
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
markCommandRequiresPluginRegistry(status);
|
|
||||||
|
|
||||||
const health = program
|
program
|
||||||
.command("health")
|
.command("health")
|
||||||
.description("Fetch health from the running gateway")
|
.description("Fetch health from the running gateway")
|
||||||
.option("--json", "Output JSON instead of text", false)
|
.option("--json", "Output JSON instead of text", false)
|
||||||
@@ -105,7 +103,6 @@ Examples:
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
markCommandRequiresPluginRegistry(health);
|
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("sessions")
|
.command("sessions")
|
||||||
|
|||||||
+2
-1
@@ -7,6 +7,7 @@ import { normalizeEnv } from "../infra/env.js";
|
|||||||
import { isMainModule } from "../infra/is-main.js";
|
import { isMainModule } from "../infra/is-main.js";
|
||||||
import { ensureClawdbotCliOnPath } from "../infra/path-env.js";
|
import { ensureClawdbotCliOnPath } from "../infra/path-env.js";
|
||||||
import { assertSupportedRuntime } from "../infra/runtime-guard.js";
|
import { assertSupportedRuntime } from "../infra/runtime-guard.js";
|
||||||
|
import { formatUncaughtError } from "../infra/errors.js";
|
||||||
import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
|
import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
|
||||||
import { enableConsoleCapture } from "../logging.js";
|
import { enableConsoleCapture } from "../logging.js";
|
||||||
import { tryRouteCli } from "./route.js";
|
import { tryRouteCli } from "./route.js";
|
||||||
@@ -42,7 +43,7 @@ export async function runCli(argv: string[] = process.argv) {
|
|||||||
installUnhandledRejectionHandler();
|
installUnhandledRejectionHandler();
|
||||||
|
|
||||||
process.on("uncaughtException", (error) => {
|
process.on("uncaughtException", (error) => {
|
||||||
console.error("[clawdbot] Uncaught exception:", error.stack ?? error.message);
|
console.error("[clawdbot] Uncaught exception:", formatUncaughtError(error));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { sandboxExplainCommand } from "../commands/sandbox-explain.js";
|
|||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { formatDocsLink } from "../terminal/links.js";
|
import { formatDocsLink } from "../terminal/links.js";
|
||||||
import { theme } from "../terminal/theme.js";
|
import { theme } from "../terminal/theme.js";
|
||||||
import { markCommandRequiresPluginRegistry } from "./program/command-metadata.js";
|
|
||||||
|
|
||||||
// --- Types ---
|
// --- Types ---
|
||||||
|
|
||||||
@@ -143,7 +142,7 @@ export function registerSandboxCli(program: Command) {
|
|||||||
|
|
||||||
// --- Explain Command ---
|
// --- Explain Command ---
|
||||||
|
|
||||||
const explain = sandbox
|
sandbox
|
||||||
.command("explain")
|
.command("explain")
|
||||||
.description("Explain effective sandbox/tool policy for a session/agent")
|
.description("Explain effective sandbox/tool policy for a session/agent")
|
||||||
.option("--session <key>", "Session key to inspect (defaults to agent main)")
|
.option("--session <key>", "Session key to inspect (defaults to agent main)")
|
||||||
@@ -162,5 +161,4 @@ export function registerSandboxCli(program: Command) {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
markCommandRequiresPluginRegistry(explain);
|
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,6 @@ import { fixSecurityFootguns } from "../security/fix.js";
|
|||||||
import { formatDocsLink } from "../terminal/links.js";
|
import { formatDocsLink } from "../terminal/links.js";
|
||||||
import { isRich, theme } from "../terminal/theme.js";
|
import { isRich, theme } from "../terminal/theme.js";
|
||||||
import { formatCliCommand } from "./command-format.js";
|
import { formatCliCommand } from "./command-format.js";
|
||||||
import { markCommandRequiresPluginRegistry } from "./program/command-metadata.js";
|
|
||||||
|
|
||||||
type SecurityAuditOptions = {
|
type SecurityAuditOptions = {
|
||||||
json?: boolean;
|
json?: boolean;
|
||||||
@@ -37,7 +36,6 @@ export function registerSecurityCli(program: Command) {
|
|||||||
() =>
|
() =>
|
||||||
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/security", "docs.clawd.bot/cli/security")}\n`,
|
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/security", "docs.clawd.bot/cli/security")}\n`,
|
||||||
);
|
);
|
||||||
markCommandRequiresPluginRegistry(security);
|
|
||||||
|
|
||||||
security
|
security
|
||||||
.command("audit")
|
.command("audit")
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.js";
|
|
||||||
import type { ClientToolDefinition } from "../../agents/pi-embedded-runner/run/params.js";
|
import type { ClientToolDefinition } from "../../agents/pi-embedded-runner/run/params.js";
|
||||||
|
import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.js";
|
||||||
|
|
||||||
/** Image content block for Claude API multimodal messages. */
|
/** Image content block for Claude API multimodal messages. */
|
||||||
export type ImageContent = {
|
export type ImageContent = {
|
||||||
|
|||||||
+9
-2
@@ -57,6 +57,7 @@ const SHELL_ENV_EXPECTED_KEYS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const CONFIG_BACKUP_COUNT = 5;
|
const CONFIG_BACKUP_COUNT = 5;
|
||||||
|
const loggedInvalidConfigs = new Set<string>();
|
||||||
|
|
||||||
export type ParseConfigJson5Result = { ok: true; parsed: unknown } | { ok: false; error: string };
|
export type ParseConfigJson5Result = { ok: true; parsed: unknown } | { ok: false; error: string };
|
||||||
|
|
||||||
@@ -244,8 +245,14 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
|||||||
const details = validated.issues
|
const details = validated.issues
|
||||||
.map((iss) => `- ${iss.path || "<root>"}: ${iss.message}`)
|
.map((iss) => `- ${iss.path || "<root>"}: ${iss.message}`)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
deps.logger.error(`Invalid config:\\n${details}`);
|
if (!loggedInvalidConfigs.has(configPath)) {
|
||||||
throw new Error("Invalid config");
|
loggedInvalidConfigs.add(configPath);
|
||||||
|
deps.logger.error(`Invalid config:\\n${details}`);
|
||||||
|
}
|
||||||
|
const error = new Error("Invalid config");
|
||||||
|
(error as { code?: string; details?: string }).code = "INVALID_CONFIG";
|
||||||
|
(error as { code?: string; details?: string }).details = details;
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
if (validated.warnings.length > 0) {
|
if (validated.warnings.length > 0) {
|
||||||
const details = validated.warnings
|
const details = validated.warnings
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
"tools.web.fetch.maxChars": "Web Fetch Max Chars",
|
"tools.web.fetch.maxChars": "Web Fetch Max Chars",
|
||||||
"tools.web.fetch.timeoutSeconds": "Web Fetch Timeout (sec)",
|
"tools.web.fetch.timeoutSeconds": "Web Fetch Timeout (sec)",
|
||||||
"tools.web.fetch.cacheTtlMinutes": "Web Fetch Cache TTL (min)",
|
"tools.web.fetch.cacheTtlMinutes": "Web Fetch Cache TTL (min)",
|
||||||
|
"tools.web.fetch.maxRedirects": "Web Fetch Max Redirects",
|
||||||
"tools.web.fetch.userAgent": "Web Fetch User-Agent",
|
"tools.web.fetch.userAgent": "Web Fetch User-Agent",
|
||||||
"gateway.controlUi.basePath": "Control UI Base Path",
|
"gateway.controlUi.basePath": "Control UI Base Path",
|
||||||
"gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint",
|
"gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint",
|
||||||
@@ -249,6 +250,8 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
"commands.useAccessGroups": "Use Access Groups",
|
"commands.useAccessGroups": "Use Access Groups",
|
||||||
"ui.seamColor": "Accent Color",
|
"ui.seamColor": "Accent Color",
|
||||||
"browser.controlUrl": "Browser Control URL",
|
"browser.controlUrl": "Browser Control URL",
|
||||||
|
"browser.snapshotDefaults": "Browser Snapshot Defaults",
|
||||||
|
"browser.snapshotDefaults.mode": "Browser Snapshot Mode",
|
||||||
"browser.remoteCdpTimeoutMs": "Remote CDP Timeout (ms)",
|
"browser.remoteCdpTimeoutMs": "Remote CDP Timeout (ms)",
|
||||||
"browser.remoteCdpHandshakeTimeoutMs": "Remote CDP Handshake Timeout (ms)",
|
"browser.remoteCdpHandshakeTimeoutMs": "Remote CDP Handshake Timeout (ms)",
|
||||||
"session.dmScope": "DM Session Scope",
|
"session.dmScope": "DM Session Scope",
|
||||||
@@ -378,6 +381,7 @@ const FIELD_HELP: Record<string, string> = {
|
|||||||
"tools.web.fetch.maxChars": "Max characters returned by web_fetch (truncated).",
|
"tools.web.fetch.maxChars": "Max characters returned by web_fetch (truncated).",
|
||||||
"tools.web.fetch.timeoutSeconds": "Timeout in seconds for web_fetch requests.",
|
"tools.web.fetch.timeoutSeconds": "Timeout in seconds for web_fetch requests.",
|
||||||
"tools.web.fetch.cacheTtlMinutes": "Cache TTL in minutes for web_fetch results.",
|
"tools.web.fetch.cacheTtlMinutes": "Cache TTL in minutes for web_fetch results.",
|
||||||
|
"tools.web.fetch.maxRedirects": "Maximum redirects allowed for web_fetch (default: 3).",
|
||||||
"tools.web.fetch.userAgent": "Override User-Agent header for web_fetch requests.",
|
"tools.web.fetch.userAgent": "Override User-Agent header for web_fetch requests.",
|
||||||
"tools.web.fetch.readability":
|
"tools.web.fetch.readability":
|
||||||
"Use Readability to extract main content from HTML (fallbacks to basic HTML cleanup).",
|
"Use Readability to extract main content from HTML (fallbacks to basic HTML cleanup).",
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ export type BrowserProfileConfig = {
|
|||||||
/** Profile color (hex). Auto-assigned at creation. */
|
/** Profile color (hex). Auto-assigned at creation. */
|
||||||
color: string;
|
color: string;
|
||||||
};
|
};
|
||||||
|
export type BrowserSnapshotDefaults = {
|
||||||
|
/** Default snapshot mode (applies when mode is not provided). */
|
||||||
|
mode?: "efficient";
|
||||||
|
};
|
||||||
export type BrowserConfig = {
|
export type BrowserConfig = {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
/** Base URL of the clawd browser control server. Default: http://127.0.0.1:18791 */
|
/** Base URL of the clawd browser control server. Default: http://127.0.0.1:18791 */
|
||||||
@@ -39,4 +43,6 @@ export type BrowserConfig = {
|
|||||||
defaultProfile?: string;
|
defaultProfile?: string;
|
||||||
/** Named browser profiles with explicit CDP ports or URLs. */
|
/** Named browser profiles with explicit CDP ports or URLs. */
|
||||||
profiles?: Record<string, BrowserProfileConfig>;
|
profiles?: Record<string, BrowserProfileConfig>;
|
||||||
|
/** Default snapshot options (applied by the browser tool/CLI when unset). */
|
||||||
|
snapshotDefaults?: BrowserSnapshotDefaults;
|
||||||
};
|
};
|
||||||
@@ -309,6 +309,8 @@ export type ToolsConfig = {
|
|||||||
timeoutSeconds?: number;
|
timeoutSeconds?: number;
|
||||||
/** Cache TTL in minutes for fetched content. */
|
/** Cache TTL in minutes for fetched content. */
|
||||||
cacheTtlMinutes?: number;
|
cacheTtlMinutes?: number;
|
||||||
|
/** Maximum number of redirects to follow (default: 3). */
|
||||||
|
maxRedirects?: number;
|
||||||
/** Override User-Agent header for fetch requests. */
|
/** Override User-Agent header for fetch requests. */
|
||||||
userAgent?: string;
|
userAgent?: string;
|
||||||
/** Use Readability to extract main content (default: true). */
|
/** Use Readability to extract main content (default: true). */
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ export const ToolsWebFetchSchema = z
|
|||||||
maxChars: z.number().int().positive().optional(),
|
maxChars: z.number().int().positive().optional(),
|
||||||
timeoutSeconds: z.number().int().positive().optional(),
|
timeoutSeconds: z.number().int().positive().optional(),
|
||||||
cacheTtlMinutes: z.number().nonnegative().optional(),
|
cacheTtlMinutes: z.number().nonnegative().optional(),
|
||||||
|
maxRedirects: z.number().int().nonnegative().optional(),
|
||||||
userAgent: z.string().optional(),
|
userAgent: z.string().optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ import { HookMappingSchema, HooksGmailSchema, InternalHooksSchema } from "./zod-
|
|||||||
import { ChannelsSchema } from "./zod-schema.providers.js";
|
import { ChannelsSchema } from "./zod-schema.providers.js";
|
||||||
import { CommandsSchema, MessagesSchema, SessionSchema } from "./zod-schema.session.js";
|
import { CommandsSchema, MessagesSchema, SessionSchema } from "./zod-schema.session.js";
|
||||||
|
|
||||||
|
const BrowserSnapshotDefaultsSchema = z
|
||||||
|
.object({
|
||||||
|
mode: z.literal("efficient").optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional();
|
||||||
|
|
||||||
export const ClawdbotSchema = z
|
export const ClawdbotSchema = z
|
||||||
.object({
|
.object({
|
||||||
meta: z
|
meta: z
|
||||||
@@ -113,6 +120,7 @@ export const ClawdbotSchema = z
|
|||||||
noSandbox: z.boolean().optional(),
|
noSandbox: z.boolean().optional(),
|
||||||
attachOnly: z.boolean().optional(),
|
attachOnly: z.boolean().optional(),
|
||||||
defaultProfile: z.string().optional(),
|
defaultProfile: z.string().optional(),
|
||||||
|
snapshotDefaults: BrowserSnapshotDefaultsSchema,
|
||||||
profiles: z
|
profiles: z
|
||||||
.record(
|
.record(
|
||||||
z
|
z
|
||||||
|
|||||||
@@ -51,12 +51,6 @@ function coerceSchedule(schedule: UnknownRecord) {
|
|||||||
|
|
||||||
function coercePayload(payload: UnknownRecord) {
|
function coercePayload(payload: UnknownRecord) {
|
||||||
const next: UnknownRecord = { ...payload };
|
const next: UnknownRecord = { ...payload };
|
||||||
const kind = typeof payload.kind === "string" ? payload.kind : undefined;
|
|
||||||
if (!kind) {
|
|
||||||
if (typeof payload.text === "string") next.kind = "systemEvent";
|
|
||||||
else if (typeof payload.message === "string") next.kind = "agentTurn";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Back-compat: older configs used `provider` for delivery channel.
|
// Back-compat: older configs used `provider` for delivery channel.
|
||||||
migrateLegacyCronPayload(next);
|
migrateLegacyCronPayload(next);
|
||||||
return next;
|
return next;
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
import { computeNextRunAtMs } from "../schedule.js";
|
import { computeNextRunAtMs } from "../schedule.js";
|
||||||
import type { CronJob, CronJobCreate, CronJobPatch } from "../types.js";
|
import type {
|
||||||
|
CronJob,
|
||||||
|
CronJobCreate,
|
||||||
|
CronJobPatch,
|
||||||
|
CronPayload,
|
||||||
|
CronPayloadPatch,
|
||||||
|
} from "../types.js";
|
||||||
import {
|
import {
|
||||||
normalizeOptionalAgentId,
|
normalizeOptionalAgentId,
|
||||||
normalizeOptionalText,
|
normalizeOptionalText,
|
||||||
@@ -103,7 +109,7 @@ export function applyJobPatch(job: CronJob, patch: CronJobPatch) {
|
|||||||
if (patch.schedule) job.schedule = patch.schedule;
|
if (patch.schedule) job.schedule = patch.schedule;
|
||||||
if (patch.sessionTarget) job.sessionTarget = patch.sessionTarget;
|
if (patch.sessionTarget) job.sessionTarget = patch.sessionTarget;
|
||||||
if (patch.wakeMode) job.wakeMode = patch.wakeMode;
|
if (patch.wakeMode) job.wakeMode = patch.wakeMode;
|
||||||
if (patch.payload) job.payload = patch.payload;
|
if (patch.payload) job.payload = mergeCronPayload(job.payload, patch.payload);
|
||||||
if (patch.isolation) job.isolation = patch.isolation;
|
if (patch.isolation) job.isolation = patch.isolation;
|
||||||
if (patch.state) job.state = { ...job.state, ...patch.state };
|
if (patch.state) job.state = { ...job.state, ...patch.state };
|
||||||
if ("agentId" in patch) {
|
if ("agentId" in patch) {
|
||||||
@@ -112,6 +118,62 @@ export function applyJobPatch(job: CronJob, patch: CronJobPatch) {
|
|||||||
assertSupportedJobSpec(job);
|
assertSupportedJobSpec(job);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeCronPayload(existing: CronPayload, patch: CronPayloadPatch): CronPayload {
|
||||||
|
if (patch.kind !== existing.kind) {
|
||||||
|
return buildPayloadFromPatch(patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch.kind === "systemEvent") {
|
||||||
|
if (existing.kind !== "systemEvent") {
|
||||||
|
return buildPayloadFromPatch(patch);
|
||||||
|
}
|
||||||
|
const text = typeof patch.text === "string" ? patch.text : existing.text;
|
||||||
|
return { kind: "systemEvent", text };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.kind !== "agentTurn") {
|
||||||
|
return buildPayloadFromPatch(patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
const next: Extract<CronPayload, { kind: "agentTurn" }> = { ...existing };
|
||||||
|
if (typeof patch.message === "string") next.message = patch.message;
|
||||||
|
if (typeof patch.model === "string") next.model = patch.model;
|
||||||
|
if (typeof patch.thinking === "string") next.thinking = patch.thinking;
|
||||||
|
if (typeof patch.timeoutSeconds === "number") next.timeoutSeconds = patch.timeoutSeconds;
|
||||||
|
if (typeof patch.deliver === "boolean") next.deliver = patch.deliver;
|
||||||
|
if (typeof patch.channel === "string") next.channel = patch.channel;
|
||||||
|
if (typeof patch.to === "string") next.to = patch.to;
|
||||||
|
if (typeof patch.bestEffortDeliver === "boolean") {
|
||||||
|
next.bestEffortDeliver = patch.bestEffortDeliver;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayloadFromPatch(patch: CronPayloadPatch): CronPayload {
|
||||||
|
if (patch.kind === "systemEvent") {
|
||||||
|
if (typeof patch.text !== "string" || patch.text.length === 0) {
|
||||||
|
throw new Error('cron.update payload.kind="systemEvent" requires text');
|
||||||
|
}
|
||||||
|
return { kind: "systemEvent", text: patch.text };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof patch.message !== "string" || patch.message.length === 0) {
|
||||||
|
throw new Error('cron.update payload.kind="agentTurn" requires message');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "agentTurn",
|
||||||
|
message: patch.message,
|
||||||
|
model: patch.model,
|
||||||
|
thinking: patch.thinking,
|
||||||
|
timeoutSeconds: patch.timeoutSeconds,
|
||||||
|
deliver: patch.deliver,
|
||||||
|
channel: patch.channel,
|
||||||
|
to: patch.to,
|
||||||
|
bestEffortDeliver: patch.bestEffortDeliver,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function isJobDue(job: CronJob, nowMs: number, opts: { forced: boolean }) {
|
export function isJobDue(job: CronJob, nowMs: number, opts: { forced: boolean }) {
|
||||||
if (opts.forced) return true;
|
if (opts.forced) return true;
|
||||||
return job.enabled && typeof job.state.nextRunAtMs === "number" && nowMs >= job.state.nextRunAtMs;
|
return job.enabled && typeof job.state.nextRunAtMs === "number" && nowMs >= job.state.nextRunAtMs;
|
||||||
|
|||||||
+18
-3
@@ -25,6 +25,20 @@ export type CronPayload =
|
|||||||
bestEffortDeliver?: boolean;
|
bestEffortDeliver?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CronPayloadPatch =
|
||||||
|
| { kind: "systemEvent"; text?: string }
|
||||||
|
| {
|
||||||
|
kind: "agentTurn";
|
||||||
|
message?: string;
|
||||||
|
model?: string;
|
||||||
|
thinking?: string;
|
||||||
|
timeoutSeconds?: number;
|
||||||
|
deliver?: boolean;
|
||||||
|
channel?: CronMessageChannel;
|
||||||
|
to?: string;
|
||||||
|
bestEffortDeliver?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type CronIsolation = {
|
export type CronIsolation = {
|
||||||
postToMainPrefix?: string;
|
postToMainPrefix?: string;
|
||||||
/**
|
/**
|
||||||
@@ -72,6 +86,7 @@ export type CronJobCreate = Omit<CronJob, "id" | "createdAtMs" | "updatedAtMs" |
|
|||||||
state?: Partial<CronJobState>;
|
state?: Partial<CronJobState>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CronJobPatch = Partial<
|
export type CronJobPatch = Partial<Omit<CronJob, "id" | "createdAtMs" | "state" | "payload">> & {
|
||||||
Omit<CronJob, "id" | "createdAtMs" | "state"> & { state: CronJobState }
|
payload?: CronPayloadPatch;
|
||||||
>;
|
state?: Partial<CronJobState>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
const testTailnetIPv4 = { value: undefined as string | undefined };
|
||||||
|
const testTailnetIPv6 = { value: undefined as string | undefined };
|
||||||
|
|
||||||
|
vi.mock("../infra/tailnet.js", () => ({
|
||||||
|
pickPrimaryTailnetIPv4: () => testTailnetIPv4.value,
|
||||||
|
pickPrimaryTailnetIPv6: () => testTailnetIPv6.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { isLocalGatewayAddress } from "./net.js";
|
||||||
|
|
||||||
|
describe("gateway net", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
testTailnetIPv4.value = undefined;
|
||||||
|
testTailnetIPv6.value = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("treats loopback as local", () => {
|
||||||
|
expect(isLocalGatewayAddress("127.0.0.1")).toBe(true);
|
||||||
|
expect(isLocalGatewayAddress("127.0.1.1")).toBe(true);
|
||||||
|
expect(isLocalGatewayAddress("::1")).toBe(true);
|
||||||
|
expect(isLocalGatewayAddress("::ffff:127.0.0.1")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("treats local tailnet IPv4 as local", () => {
|
||||||
|
testTailnetIPv4.value = "100.64.0.1";
|
||||||
|
expect(isLocalGatewayAddress("100.64.0.1")).toBe(true);
|
||||||
|
expect(isLocalGatewayAddress("::ffff:100.64.0.1")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignores non-matching tailnet IPv4", () => {
|
||||||
|
testTailnetIPv4.value = "100.64.0.1";
|
||||||
|
expect(isLocalGatewayAddress("100.64.0.2")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("treats local tailnet IPv6 as local", () => {
|
||||||
|
testTailnetIPv6.value = "fd7a:115c:a1e0::123";
|
||||||
|
expect(isLocalGatewayAddress("fd7a:115c:a1e0::123")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
+17
-1
@@ -1,6 +1,6 @@
|
|||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
|
|
||||||
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
|
import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js";
|
||||||
|
|
||||||
export function isLoopbackAddress(ip: string | undefined): boolean {
|
export function isLoopbackAddress(ip: string | undefined): boolean {
|
||||||
if (!ip) return false;
|
if (!ip) return false;
|
||||||
@@ -11,6 +11,22 @@ export function isLoopbackAddress(ip: string | undefined): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeIPv4MappedAddress(ip: string): string {
|
||||||
|
if (ip.startsWith("::ffff:")) return ip.slice("::ffff:".length);
|
||||||
|
return ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLocalGatewayAddress(ip: string | undefined): boolean {
|
||||||
|
if (isLoopbackAddress(ip)) return true;
|
||||||
|
if (!ip) return false;
|
||||||
|
const normalized = normalizeIPv4MappedAddress(ip.trim().toLowerCase());
|
||||||
|
const tailnetIPv4 = pickPrimaryTailnetIPv4();
|
||||||
|
if (tailnetIPv4 && normalized === tailnetIPv4.toLowerCase()) return true;
|
||||||
|
const tailnetIPv6 = pickPrimaryTailnetIPv6();
|
||||||
|
if (tailnetIPv6 && ip.trim().toLowerCase() === tailnetIPv6.toLowerCase()) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves gateway bind host with fallback strategy.
|
* Resolves gateway bind host with fallback strategy.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { ErrorObject } from "ajv";
|
||||||
|
|
||||||
|
import { formatValidationErrors } from "./index.js";
|
||||||
|
|
||||||
|
const makeError = (overrides: Partial<ErrorObject>): ErrorObject => ({
|
||||||
|
keyword: "type",
|
||||||
|
instancePath: "",
|
||||||
|
schemaPath: "#/",
|
||||||
|
params: {},
|
||||||
|
message: "validation error",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatValidationErrors", () => {
|
||||||
|
it("returns unknown validation error when missing errors", () => {
|
||||||
|
expect(formatValidationErrors(undefined)).toBe("unknown validation error");
|
||||||
|
expect(formatValidationErrors(null)).toBe("unknown validation error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns unknown validation error when errors list is empty", () => {
|
||||||
|
expect(formatValidationErrors([])).toBe("unknown validation error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats additionalProperties at root", () => {
|
||||||
|
const err = makeError({
|
||||||
|
keyword: "additionalProperties",
|
||||||
|
params: { additionalProperty: "token" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(formatValidationErrors([err])).toBe("at root: unexpected property 'token'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats additionalProperties with instancePath", () => {
|
||||||
|
const err = makeError({
|
||||||
|
keyword: "additionalProperties",
|
||||||
|
instancePath: "/auth",
|
||||||
|
params: { additionalProperty: "token" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(formatValidationErrors([err])).toBe("at /auth: unexpected property 'token'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats message with path for other errors", () => {
|
||||||
|
const err = makeError({
|
||||||
|
keyword: "required",
|
||||||
|
instancePath: "/auth",
|
||||||
|
message: "must have required property 'token'",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(formatValidationErrors([err])).toBe("at /auth: must have required property 'token'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("de-dupes repeated entries", () => {
|
||||||
|
const err = makeError({
|
||||||
|
keyword: "required",
|
||||||
|
instancePath: "/auth",
|
||||||
|
message: "must have required property 'token'",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(formatValidationErrors([err, err])).toBe(
|
||||||
|
"at /auth: must have required property 'token'",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -310,8 +310,37 @@ export const validateWebLoginStartParams =
|
|||||||
export const validateWebLoginWaitParams = ajv.compile<WebLoginWaitParams>(WebLoginWaitParamsSchema);
|
export const validateWebLoginWaitParams = ajv.compile<WebLoginWaitParams>(WebLoginWaitParamsSchema);
|
||||||
|
|
||||||
export function formatValidationErrors(errors: ErrorObject[] | null | undefined) {
|
export function formatValidationErrors(errors: ErrorObject[] | null | undefined) {
|
||||||
if (!errors) return "unknown validation error";
|
if (!errors?.length) return "unknown validation error";
|
||||||
return ajv.errorsText(errors, { separator: "; " });
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
for (const err of errors) {
|
||||||
|
const keyword = typeof err?.keyword === "string" ? err.keyword : "";
|
||||||
|
const instancePath = typeof err?.instancePath === "string" ? err.instancePath : "";
|
||||||
|
|
||||||
|
if (keyword === "additionalProperties") {
|
||||||
|
const params = err?.params as { additionalProperty?: unknown } | undefined;
|
||||||
|
const additionalProperty = params?.additionalProperty;
|
||||||
|
if (typeof additionalProperty === "string" && additionalProperty.trim()) {
|
||||||
|
const where = instancePath ? `at ${instancePath}` : "at root";
|
||||||
|
parts.push(`${where}: unexpected property '${additionalProperty}'`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const message =
|
||||||
|
typeof err?.message === "string" && err.message.trim() ? err.message : "validation error";
|
||||||
|
const where = instancePath ? `at ${instancePath}: ` : "";
|
||||||
|
parts.push(`${where}${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// De-dupe while preserving order.
|
||||||
|
const unique = Array.from(new Set(parts.filter((part) => part.trim())));
|
||||||
|
if (!unique.length) {
|
||||||
|
const fallback = ajv.errorsText(errors, { separator: "; " });
|
||||||
|
return fallback || "unknown validation error";
|
||||||
|
}
|
||||||
|
return unique.join("; ");
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -52,6 +52,30 @@ export const CronPayloadSchema = Type.Union([
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export const CronPayloadPatchSchema = Type.Union([
|
||||||
|
Type.Object(
|
||||||
|
{
|
||||||
|
kind: Type.Literal("systemEvent"),
|
||||||
|
text: Type.Optional(NonEmptyString),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
),
|
||||||
|
Type.Object(
|
||||||
|
{
|
||||||
|
kind: Type.Literal("agentTurn"),
|
||||||
|
message: Type.Optional(NonEmptyString),
|
||||||
|
model: Type.Optional(Type.String()),
|
||||||
|
thinking: Type.Optional(Type.String()),
|
||||||
|
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||||
|
deliver: Type.Optional(Type.Boolean()),
|
||||||
|
channel: Type.Optional(Type.Union([Type.Literal("last"), NonEmptyString])),
|
||||||
|
to: Type.Optional(Type.String()),
|
||||||
|
bestEffortDeliver: Type.Optional(Type.Boolean()),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
export const CronIsolationSchema = Type.Object(
|
export const CronIsolationSchema = Type.Object(
|
||||||
{
|
{
|
||||||
postToMainPrefix: Type.Optional(Type.String()),
|
postToMainPrefix: Type.Optional(Type.String()),
|
||||||
@@ -120,18 +144,35 @@ export const CronAddParamsSchema = Type.Object(
|
|||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const CronJobPatchSchema = Type.Object(
|
||||||
|
{
|
||||||
|
name: Type.Optional(NonEmptyString),
|
||||||
|
agentId: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
|
description: Type.Optional(Type.String()),
|
||||||
|
enabled: Type.Optional(Type.Boolean()),
|
||||||
|
deleteAfterRun: Type.Optional(Type.Boolean()),
|
||||||
|
schedule: Type.Optional(CronScheduleSchema),
|
||||||
|
sessionTarget: Type.Optional(Type.Union([Type.Literal("main"), Type.Literal("isolated")])),
|
||||||
|
wakeMode: Type.Optional(Type.Union([Type.Literal("next-heartbeat"), Type.Literal("now")])),
|
||||||
|
payload: Type.Optional(CronPayloadPatchSchema),
|
||||||
|
isolation: Type.Optional(CronIsolationSchema),
|
||||||
|
state: Type.Optional(Type.Partial(CronJobStateSchema)),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
);
|
||||||
|
|
||||||
export const CronUpdateParamsSchema = Type.Union([
|
export const CronUpdateParamsSchema = Type.Union([
|
||||||
Type.Object(
|
Type.Object(
|
||||||
{
|
{
|
||||||
id: NonEmptyString,
|
id: NonEmptyString,
|
||||||
patch: Type.Partial(CronAddParamsSchema),
|
patch: CronJobPatchSchema,
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
),
|
),
|
||||||
Type.Object(
|
Type.Object(
|
||||||
{
|
{
|
||||||
jobId: NonEmptyString,
|
jobId: NonEmptyString,
|
||||||
patch: Type.Partial(CronAddParamsSchema),
|
patch: CronJobPatchSchema,
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,92 +3,92 @@ import { Type } from "@sinclair/typebox";
|
|||||||
import { NonEmptyString, SessionLabelString } from "./primitives.js";
|
import { NonEmptyString, SessionLabelString } from "./primitives.js";
|
||||||
|
|
||||||
export const SessionsListParamsSchema = Type.Object(
|
export const SessionsListParamsSchema = Type.Object(
|
||||||
{
|
{
|
||||||
limit: Type.Optional(Type.Integer({ minimum: 1 })),
|
limit: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||||
activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })),
|
activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||||
includeGlobal: Type.Optional(Type.Boolean()),
|
includeGlobal: Type.Optional(Type.Boolean()),
|
||||||
includeUnknown: Type.Optional(Type.Boolean()),
|
includeUnknown: Type.Optional(Type.Boolean()),
|
||||||
/**
|
/**
|
||||||
* Read first 8KB of each session transcript to derive title from first user message.
|
* Read first 8KB of each session transcript to derive title from first user message.
|
||||||
* Performs a file read per session - use `limit` to bound result set on large stores.
|
* Performs a file read per session - use `limit` to bound result set on large stores.
|
||||||
*/
|
*/
|
||||||
includeDerivedTitles: Type.Optional(Type.Boolean()),
|
includeDerivedTitles: Type.Optional(Type.Boolean()),
|
||||||
/**
|
/**
|
||||||
* Read last 16KB of each session transcript to extract most recent message preview.
|
* Read last 16KB of each session transcript to extract most recent message preview.
|
||||||
* Performs a file read per session - use `limit` to bound result set on large stores.
|
* Performs a file read per session - use `limit` to bound result set on large stores.
|
||||||
*/
|
*/
|
||||||
includeLastMessage: Type.Optional(Type.Boolean()),
|
includeLastMessage: Type.Optional(Type.Boolean()),
|
||||||
label: Type.Optional(SessionLabelString),
|
label: Type.Optional(SessionLabelString),
|
||||||
spawnedBy: Type.Optional(NonEmptyString),
|
spawnedBy: Type.Optional(NonEmptyString),
|
||||||
agentId: Type.Optional(NonEmptyString),
|
agentId: Type.Optional(NonEmptyString),
|
||||||
search: Type.Optional(Type.String()),
|
search: Type.Optional(Type.String()),
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
export const SessionsResolveParamsSchema = Type.Object(
|
export const SessionsResolveParamsSchema = Type.Object(
|
||||||
{
|
{
|
||||||
key: Type.Optional(NonEmptyString),
|
key: Type.Optional(NonEmptyString),
|
||||||
label: Type.Optional(SessionLabelString),
|
label: Type.Optional(SessionLabelString),
|
||||||
agentId: Type.Optional(NonEmptyString),
|
agentId: Type.Optional(NonEmptyString),
|
||||||
spawnedBy: Type.Optional(NonEmptyString),
|
spawnedBy: Type.Optional(NonEmptyString),
|
||||||
includeGlobal: Type.Optional(Type.Boolean()),
|
includeGlobal: Type.Optional(Type.Boolean()),
|
||||||
includeUnknown: Type.Optional(Type.Boolean()),
|
includeUnknown: Type.Optional(Type.Boolean()),
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
export const SessionsPatchParamsSchema = Type.Object(
|
export const SessionsPatchParamsSchema = Type.Object(
|
||||||
{
|
{
|
||||||
key: NonEmptyString,
|
key: NonEmptyString,
|
||||||
label: Type.Optional(Type.Union([SessionLabelString, Type.Null()])),
|
label: Type.Optional(Type.Union([SessionLabelString, Type.Null()])),
|
||||||
thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
reasoningLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
reasoningLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
responseUsage: Type.Optional(
|
responseUsage: Type.Optional(
|
||||||
Type.Union([
|
Type.Union([
|
||||||
Type.Literal("off"),
|
Type.Literal("off"),
|
||||||
Type.Literal("tokens"),
|
Type.Literal("tokens"),
|
||||||
Type.Literal("full"),
|
Type.Literal("full"),
|
||||||
// Backward compat with older clients/stores.
|
// Backward compat with older clients/stores.
|
||||||
Type.Literal("on"),
|
Type.Literal("on"),
|
||||||
Type.Null(),
|
Type.Null(),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
execHost: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
execHost: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
execSecurity: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
execSecurity: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
execAsk: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
execAsk: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
execNode: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
execNode: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
sendPolicy: Type.Optional(
|
sendPolicy: Type.Optional(
|
||||||
Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]),
|
Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]),
|
||||||
),
|
),
|
||||||
groupActivation: Type.Optional(
|
groupActivation: Type.Optional(
|
||||||
Type.Union([Type.Literal("mention"), Type.Literal("always"), Type.Null()]),
|
Type.Union([Type.Literal("mention"), Type.Literal("always"), Type.Null()]),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
export const SessionsResetParamsSchema = Type.Object(
|
export const SessionsResetParamsSchema = Type.Object(
|
||||||
{ key: NonEmptyString },
|
{ key: NonEmptyString },
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
export const SessionsDeleteParamsSchema = Type.Object(
|
export const SessionsDeleteParamsSchema = Type.Object(
|
||||||
{
|
{
|
||||||
key: NonEmptyString,
|
key: NonEmptyString,
|
||||||
deleteTranscript: Type.Optional(Type.Boolean()),
|
deleteTranscript: Type.Optional(Type.Boolean()),
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
export const SessionsCompactParamsSchema = Type.Object(
|
export const SessionsCompactParamsSchema = Type.Object(
|
||||||
{
|
{
|
||||||
key: NonEmptyString,
|
key: NonEmptyString,
|
||||||
maxLines: Type.Optional(Type.Integer({ minimum: 1 })),
|
maxLines: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
@@ -3,6 +3,7 @@ import type { WebSocketServer } from "ws";
|
|||||||
import type { CanvasHostHandler, CanvasHostServer } from "../canvas-host/server.js";
|
import type { CanvasHostHandler, CanvasHostServer } from "../canvas-host/server.js";
|
||||||
import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js";
|
import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js";
|
||||||
import { stopGmailWatcher } from "../hooks/gmail-watcher.js";
|
import { stopGmailWatcher } from "../hooks/gmail-watcher.js";
|
||||||
|
import type { HeartbeatRunner } from "../infra/heartbeat-runner.js";
|
||||||
import type { PluginServicesHandle } from "../plugins/services.js";
|
import type { PluginServicesHandle } from "../plugins/services.js";
|
||||||
|
|
||||||
export function createGatewayCloseHandler(params: {
|
export function createGatewayCloseHandler(params: {
|
||||||
@@ -13,7 +14,7 @@ export function createGatewayCloseHandler(params: {
|
|||||||
stopChannel: (name: ChannelId, accountId?: string) => Promise<void>;
|
stopChannel: (name: ChannelId, accountId?: string) => Promise<void>;
|
||||||
pluginServices: PluginServicesHandle | null;
|
pluginServices: PluginServicesHandle | null;
|
||||||
cron: { stop: () => void };
|
cron: { stop: () => void };
|
||||||
heartbeatRunner: { stop: () => void };
|
heartbeatRunner: HeartbeatRunner;
|
||||||
nodePresenceTimers: Map<string, ReturnType<typeof setInterval>>;
|
nodePresenceTimers: Map<string, ReturnType<typeof setInterval>>;
|
||||||
broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void;
|
broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void;
|
||||||
tickInterval: ReturnType<typeof setInterval>;
|
tickInterval: ReturnType<typeof setInterval>;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { CliDeps } from "../cli/deps.js";
|
import type { CliDeps } from "../cli/deps.js";
|
||||||
import type { loadConfig } from "../config/config.js";
|
import type { loadConfig } from "../config/config.js";
|
||||||
import { startGmailWatcher, stopGmailWatcher } from "../hooks/gmail-watcher.js";
|
import { startGmailWatcher, stopGmailWatcher } from "../hooks/gmail-watcher.js";
|
||||||
import { startHeartbeatRunner } from "../infra/heartbeat-runner.js";
|
import type { HeartbeatRunner } from "../infra/heartbeat-runner.js";
|
||||||
import { resetDirectoryCache } from "../infra/outbound/target-resolver.js";
|
import { resetDirectoryCache } from "../infra/outbound/target-resolver.js";
|
||||||
import {
|
import {
|
||||||
authorizeGatewaySigusr1Restart,
|
authorizeGatewaySigusr1Restart,
|
||||||
@@ -18,7 +18,7 @@ import { buildGatewayCronService, type GatewayCronState } from "./server-cron.js
|
|||||||
|
|
||||||
type GatewayHotReloadState = {
|
type GatewayHotReloadState = {
|
||||||
hooksConfig: ReturnType<typeof resolveHooksConfig>;
|
hooksConfig: ReturnType<typeof resolveHooksConfig>;
|
||||||
heartbeatRunner: { stop: () => void };
|
heartbeatRunner: HeartbeatRunner;
|
||||||
cronState: GatewayCronState;
|
cronState: GatewayCronState;
|
||||||
browserControl: Awaited<ReturnType<typeof startBrowserControlServerIfEnabled>> | null;
|
browserControl: Awaited<ReturnType<typeof startBrowserControlServerIfEnabled>> | null;
|
||||||
};
|
};
|
||||||
@@ -57,8 +57,7 @@ export function createGatewayReloadHandlers(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (plan.restartHeartbeat) {
|
if (plan.restartHeartbeat) {
|
||||||
state.heartbeatRunner.stop();
|
nextState.heartbeatRunner.updateConfig(nextConfig);
|
||||||
nextState.heartbeatRunner = startHeartbeatRunner({ cfg: nextConfig });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resetDirectoryCache();
|
resetDirectoryCache();
|
||||||
|
|||||||
@@ -28,9 +28,30 @@ export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const threadMarker = ":thread:";
|
||||||
|
const threadIndex = sessionKey.lastIndexOf(threadMarker);
|
||||||
|
const baseSessionKey = threadIndex === -1 ? sessionKey : sessionKey.slice(0, threadIndex);
|
||||||
|
const threadIdRaw =
|
||||||
|
threadIndex === -1 ? undefined : sessionKey.slice(threadIndex + threadMarker.length);
|
||||||
|
const sessionThreadId = threadIdRaw?.trim() || undefined;
|
||||||
|
|
||||||
const { cfg, entry } = loadSessionEntry(sessionKey);
|
const { cfg, entry } = loadSessionEntry(sessionKey);
|
||||||
const parsedTarget = resolveAnnounceTargetFromKey(sessionKey);
|
const parsedTarget = resolveAnnounceTargetFromKey(baseSessionKey);
|
||||||
const origin = mergeDeliveryContext(deliveryContextFromSession(entry), parsedTarget ?? undefined);
|
|
||||||
|
// Prefer delivery context from sentinel (captured at restart) over session store
|
||||||
|
// Handles race condition where store wasn't flushed before restart
|
||||||
|
const sentinelContext = payload.deliveryContext;
|
||||||
|
let sessionDeliveryContext = deliveryContextFromSession(entry);
|
||||||
|
if (!sessionDeliveryContext && threadIndex !== -1 && baseSessionKey) {
|
||||||
|
const { entry: baseEntry } = loadSessionEntry(baseSessionKey);
|
||||||
|
sessionDeliveryContext = deliveryContextFromSession(baseEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
const origin = mergeDeliveryContext(
|
||||||
|
sentinelContext,
|
||||||
|
mergeDeliveryContext(sessionDeliveryContext, parsedTarget ?? undefined),
|
||||||
|
);
|
||||||
|
|
||||||
const channelRaw = origin?.channel;
|
const channelRaw = origin?.channel;
|
||||||
const channel = channelRaw ? normalizeChannelId(channelRaw) : null;
|
const channel = channelRaw ? normalizeChannelId(channelRaw) : null;
|
||||||
const to = origin?.to;
|
const to = origin?.to;
|
||||||
@@ -51,6 +72,11 @@ export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const threadId =
|
||||||
|
payload.threadId ??
|
||||||
|
sessionThreadId ??
|
||||||
|
(origin?.threadId != null ? String(origin.threadId) : undefined);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await agentCommand(
|
await agentCommand(
|
||||||
{
|
{
|
||||||
@@ -61,6 +87,7 @@ export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) {
|
|||||||
deliver: true,
|
deliver: true,
|
||||||
bestEffortDeliver: true,
|
bestEffortDeliver: true,
|
||||||
messageChannel: channel,
|
messageChannel: channel,
|
||||||
|
threadId,
|
||||||
},
|
},
|
||||||
defaultRuntime,
|
defaultRuntime,
|
||||||
params.deps,
|
params.deps,
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ describe("gateway server cron", () => {
|
|||||||
data: {
|
data: {
|
||||||
name: "wrapped",
|
name: "wrapped",
|
||||||
schedule: { atMs },
|
schedule: { atMs },
|
||||||
payload: { text: "hello" },
|
payload: { kind: "systemEvent", text: "hello" },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(addRes.ok).toBe(true);
|
expect(addRes.ok).toBe(true);
|
||||||
@@ -166,7 +166,7 @@ describe("gateway server cron", () => {
|
|||||||
id: jobId,
|
id: jobId,
|
||||||
patch: {
|
patch: {
|
||||||
schedule: { atMs },
|
schedule: { atMs },
|
||||||
payload: { text: "updated" },
|
payload: { kind: "systemEvent", text: "updated" },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(updateRes.ok).toBe(true);
|
expect(updateRes.ok).toBe(true);
|
||||||
@@ -182,6 +182,96 @@ describe("gateway server cron", () => {
|
|||||||
testState.cronStorePath = undefined;
|
testState.cronStorePath = undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("merges agentTurn payload patches", async () => {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-"));
|
||||||
|
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
|
||||||
|
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
|
||||||
|
await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] }));
|
||||||
|
|
||||||
|
const { server, ws } = await startServerWithClient();
|
||||||
|
await connectOk(ws);
|
||||||
|
|
||||||
|
const addRes = await rpcReq(ws, "cron.add", {
|
||||||
|
name: "patch merge",
|
||||||
|
enabled: true,
|
||||||
|
schedule: { kind: "every", everyMs: 60_000 },
|
||||||
|
sessionTarget: "isolated",
|
||||||
|
wakeMode: "next-heartbeat",
|
||||||
|
payload: { kind: "agentTurn", message: "hello", model: "opus" },
|
||||||
|
});
|
||||||
|
expect(addRes.ok).toBe(true);
|
||||||
|
const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id;
|
||||||
|
const jobId = typeof jobIdValue === "string" ? jobIdValue : "";
|
||||||
|
expect(jobId.length > 0).toBe(true);
|
||||||
|
|
||||||
|
const updateRes = await rpcReq(ws, "cron.update", {
|
||||||
|
id: jobId,
|
||||||
|
patch: {
|
||||||
|
payload: { kind: "agentTurn", deliver: true, channel: "telegram", to: "19098680" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(updateRes.ok).toBe(true);
|
||||||
|
const updated = updateRes.payload as
|
||||||
|
| {
|
||||||
|
payload?: {
|
||||||
|
kind?: unknown;
|
||||||
|
message?: unknown;
|
||||||
|
model?: unknown;
|
||||||
|
deliver?: unknown;
|
||||||
|
channel?: unknown;
|
||||||
|
to?: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
expect(updated?.payload?.kind).toBe("agentTurn");
|
||||||
|
expect(updated?.payload?.message).toBe("hello");
|
||||||
|
expect(updated?.payload?.model).toBe("opus");
|
||||||
|
expect(updated?.payload?.deliver).toBe(true);
|
||||||
|
expect(updated?.payload?.channel).toBe("telegram");
|
||||||
|
expect(updated?.payload?.to).toBe("19098680");
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
await server.close();
|
||||||
|
await rmTempDir(dir);
|
||||||
|
testState.cronStorePath = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects payload kind changes without required fields", async () => {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-"));
|
||||||
|
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
|
||||||
|
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
|
||||||
|
await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] }));
|
||||||
|
|
||||||
|
const { server, ws } = await startServerWithClient();
|
||||||
|
await connectOk(ws);
|
||||||
|
|
||||||
|
const addRes = await rpcReq(ws, "cron.add", {
|
||||||
|
name: "patch reject",
|
||||||
|
enabled: true,
|
||||||
|
schedule: { kind: "every", everyMs: 60_000 },
|
||||||
|
sessionTarget: "main",
|
||||||
|
wakeMode: "next-heartbeat",
|
||||||
|
payload: { kind: "systemEvent", text: "hello" },
|
||||||
|
});
|
||||||
|
expect(addRes.ok).toBe(true);
|
||||||
|
const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id;
|
||||||
|
const jobId = typeof jobIdValue === "string" ? jobIdValue : "";
|
||||||
|
expect(jobId.length > 0).toBe(true);
|
||||||
|
|
||||||
|
const updateRes = await rpcReq(ws, "cron.update", {
|
||||||
|
id: jobId,
|
||||||
|
patch: {
|
||||||
|
payload: { kind: "agentTurn", deliver: true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(updateRes.ok).toBe(false);
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
await server.close();
|
||||||
|
await rmTempDir(dir);
|
||||||
|
testState.cronStorePath = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
test("accepts jobId for cron.update", async () => {
|
test("accepts jobId for cron.update", async () => {
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-"));
|
||||||
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
|
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
|
||||||
@@ -210,7 +300,7 @@ describe("gateway server cron", () => {
|
|||||||
jobId,
|
jobId,
|
||||||
patch: {
|
patch: {
|
||||||
schedule: { atMs },
|
schedule: { atMs },
|
||||||
payload: { text: "updated" },
|
payload: { kind: "systemEvent", text: "updated" },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(updateRes.ok).toBe(true);
|
expect(updateRes.ok).toBe(true);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
readConfigFileSnapshot,
|
readConfigFileSnapshot,
|
||||||
writeConfigFile,
|
writeConfigFile,
|
||||||
} from "../config/config.js";
|
} from "../config/config.js";
|
||||||
|
import { isDiagnosticsEnabled } from "../infra/diagnostic-events.js";
|
||||||
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
|
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
|
||||||
import { clearAgentRunContext, onAgentEvent } from "../infra/agent-events.js";
|
import { clearAgentRunContext, onAgentEvent } from "../infra/agent-events.js";
|
||||||
import { onHeartbeatEvent } from "../infra/heartbeat-events.js";
|
import { onHeartbeatEvent } from "../infra/heartbeat-events.js";
|
||||||
@@ -26,6 +27,7 @@ import {
|
|||||||
} from "../infra/skills-remote.js";
|
} from "../infra/skills-remote.js";
|
||||||
import { scheduleGatewayUpdateCheck } from "../infra/update-startup.js";
|
import { scheduleGatewayUpdateCheck } from "../infra/update-startup.js";
|
||||||
import { setGatewaySigusr1RestartPolicy } from "../infra/restart.js";
|
import { setGatewaySigusr1RestartPolicy } from "../infra/restart.js";
|
||||||
|
import { startDiagnosticHeartbeat, stopDiagnosticHeartbeat } from "../logging/diagnostic.js";
|
||||||
import { createSubsystemLogger, runtimeForLogger } from "../logging/subsystem.js";
|
import { createSubsystemLogger, runtimeForLogger } from "../logging/subsystem.js";
|
||||||
import type { PluginServicesHandle } from "../plugins/services.js";
|
import type { PluginServicesHandle } from "../plugins/services.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
@@ -198,6 +200,10 @@ export async function startGatewayServer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cfgAtStart = loadConfig();
|
const cfgAtStart = loadConfig();
|
||||||
|
const diagnosticsEnabled = isDiagnosticsEnabled(cfgAtStart);
|
||||||
|
if (diagnosticsEnabled) {
|
||||||
|
startDiagnosticHeartbeat();
|
||||||
|
}
|
||||||
setGatewaySigusr1RestartPolicy({ allowExternal: cfgAtStart.commands?.restart === true });
|
setGatewaySigusr1RestartPolicy({ allowExternal: cfgAtStart.commands?.restart === true });
|
||||||
initSubagentRegistry();
|
initSubagentRegistry();
|
||||||
const defaultAgentId = resolveDefaultAgentId(cfgAtStart);
|
const defaultAgentId = resolveDefaultAgentId(cfgAtStart);
|
||||||
@@ -533,5 +539,12 @@ export async function startGatewayServer(
|
|||||||
httpServer,
|
httpServer,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { close };
|
return {
|
||||||
|
close: async (opts) => {
|
||||||
|
if (diagnosticsEnabled) {
|
||||||
|
stopDiagnosticHeartbeat();
|
||||||
|
}
|
||||||
|
await close(opts);
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -21,7 +21,11 @@ const hoisted = vi.hoisted(() => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const heartbeatStop = vi.fn();
|
const heartbeatStop = vi.fn();
|
||||||
const startHeartbeatRunner = vi.fn(() => ({ stop: heartbeatStop }));
|
const heartbeatUpdateConfig = vi.fn();
|
||||||
|
const startHeartbeatRunner = vi.fn(() => ({
|
||||||
|
stop: heartbeatStop,
|
||||||
|
updateConfig: heartbeatUpdateConfig,
|
||||||
|
}));
|
||||||
|
|
||||||
const startGmailWatcher = vi.fn(async () => ({ started: true }));
|
const startGmailWatcher = vi.fn(async () => ({ started: true }));
|
||||||
const stopGmailWatcher = vi.fn(async () => {});
|
const stopGmailWatcher = vi.fn(async () => {});
|
||||||
@@ -116,6 +120,7 @@ const hoisted = vi.hoisted(() => {
|
|||||||
browserStop,
|
browserStop,
|
||||||
startBrowserControlServerIfEnabled,
|
startBrowserControlServerIfEnabled,
|
||||||
heartbeatStop,
|
heartbeatStop,
|
||||||
|
heartbeatUpdateConfig,
|
||||||
startHeartbeatRunner,
|
startHeartbeatRunner,
|
||||||
startGmailWatcher,
|
startGmailWatcher,
|
||||||
stopGmailWatcher,
|
stopGmailWatcher,
|
||||||
@@ -237,8 +242,9 @@ describe("gateway hot reload", () => {
|
|||||||
expect(hoisted.browserStop).toHaveBeenCalledTimes(1);
|
expect(hoisted.browserStop).toHaveBeenCalledTimes(1);
|
||||||
expect(hoisted.startBrowserControlServerIfEnabled).toHaveBeenCalledTimes(2);
|
expect(hoisted.startBrowserControlServerIfEnabled).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
expect(hoisted.startHeartbeatRunner).toHaveBeenCalledTimes(2);
|
expect(hoisted.startHeartbeatRunner).toHaveBeenCalledTimes(1);
|
||||||
expect(hoisted.heartbeatStop).toHaveBeenCalledTimes(1);
|
expect(hoisted.heartbeatUpdateConfig).toHaveBeenCalledTimes(1);
|
||||||
|
expect(hoisted.heartbeatUpdateConfig).toHaveBeenCalledWith(nextConfig);
|
||||||
|
|
||||||
expect(hoisted.cronInstances.length).toBe(2);
|
expect(hoisted.cronInstances.length).toBe(2);
|
||||||
expect(hoisted.cronInstances[0].stop).toHaveBeenCalledTimes(1);
|
expect(hoisted.cronInstances[0].stop).toHaveBeenCalledTimes(1);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
updatePairedDeviceMetadata,
|
updatePairedDeviceMetadata,
|
||||||
verifyDeviceToken,
|
verifyDeviceToken,
|
||||||
} from "../../../infra/device-pairing.js";
|
} from "../../../infra/device-pairing.js";
|
||||||
|
import { updatePairedNodeMetadata } from "../../../infra/node-pairing.js";
|
||||||
import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../../../infra/skills-remote.js";
|
import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../../../infra/skills-remote.js";
|
||||||
import { loadVoiceWakeConfig } from "../../../infra/voicewake.js";
|
import { loadVoiceWakeConfig } from "../../../infra/voicewake.js";
|
||||||
import { upsertPresence } from "../../../infra/system-presence.js";
|
import { upsertPresence } from "../../../infra/system-presence.js";
|
||||||
@@ -25,7 +26,7 @@ import type { ResolvedGatewayAuth } from "../../auth.js";
|
|||||||
import { authorizeGatewayConnect } from "../../auth.js";
|
import { authorizeGatewayConnect } from "../../auth.js";
|
||||||
import { loadConfig } from "../../../config/config.js";
|
import { loadConfig } from "../../../config/config.js";
|
||||||
import { buildDeviceAuthPayload } from "../../device-auth.js";
|
import { buildDeviceAuthPayload } from "../../device-auth.js";
|
||||||
import { isLoopbackAddress } from "../../net.js";
|
import { isLocalGatewayAddress } from "../../net.js";
|
||||||
import { resolveNodeCommandAllowlist } from "../../node-command-policy.js";
|
import { resolveNodeCommandAllowlist } from "../../node-command-policy.js";
|
||||||
import {
|
import {
|
||||||
type ConnectParams,
|
type ConnectParams,
|
||||||
@@ -292,7 +293,9 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
|
|
||||||
const device = connectParams.device;
|
const device = connectParams.device;
|
||||||
let devicePublicKey: string | null = null;
|
let devicePublicKey: string | null = null;
|
||||||
if (!device) {
|
// Allow token-authenticated connections (e.g., control-ui) to skip device identity
|
||||||
|
const hasTokenAuth = !!connectParams.auth?.token;
|
||||||
|
if (!device && !hasTokenAuth) {
|
||||||
setHandshakeState("failed");
|
setHandshakeState("failed");
|
||||||
setCloseCause("device-required", {
|
setCloseCause("device-required", {
|
||||||
client: connectParams.client.id,
|
client: connectParams.client.id,
|
||||||
@@ -347,7 +350,7 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
close(1008, "device signature expired");
|
close(1008, "device signature expired");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nonceRequired = !isLoopbackAddress(remoteAddr);
|
const nonceRequired = !isLocalGatewayAddress(remoteAddr);
|
||||||
const providedNonce = typeof device.nonce === "string" ? device.nonce.trim() : "";
|
const providedNonce = typeof device.nonce === "string" ? device.nonce.trim() : "";
|
||||||
if (nonceRequired && !providedNonce) {
|
if (nonceRequired && !providedNonce) {
|
||||||
setHandshakeState("failed");
|
setHandshakeState("failed");
|
||||||
@@ -465,7 +468,7 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
});
|
});
|
||||||
let authOk = authResult.ok;
|
let authOk = authResult.ok;
|
||||||
let authMethod = authResult.method ?? "none";
|
let authMethod = authResult.method ?? "none";
|
||||||
if (!authOk && connectParams.auth?.token) {
|
if (!authOk && connectParams.auth?.token && device) {
|
||||||
const tokenCheck = await verifyDeviceToken({
|
const tokenCheck = await verifyDeviceToken({
|
||||||
deviceId: device.id,
|
deviceId: device.id,
|
||||||
token: connectParams.auth.token,
|
token: connectParams.auth.token,
|
||||||
@@ -524,7 +527,7 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
role,
|
role,
|
||||||
scopes,
|
scopes,
|
||||||
remoteIp: remoteAddr,
|
remoteIp: remoteAddr,
|
||||||
silent: isLoopbackAddress(remoteAddr),
|
silent: isLocalGatewayAddress(remoteAddr),
|
||||||
});
|
});
|
||||||
const context = buildRequestContext();
|
const context = buildRequestContext();
|
||||||
if (pairing.request.silent === true) {
|
if (pairing.request.silent === true) {
|
||||||
@@ -656,7 +659,7 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
if (presenceKey) {
|
if (presenceKey) {
|
||||||
upsertPresence(presenceKey, {
|
upsertPresence(presenceKey, {
|
||||||
host: connectParams.client.displayName ?? connectParams.client.id ?? os.hostname(),
|
host: connectParams.client.displayName ?? connectParams.client.id ?? os.hostname(),
|
||||||
ip: isLoopbackAddress(remoteAddr) ? undefined : remoteAddr,
|
ip: isLocalGatewayAddress(remoteAddr) ? undefined : remoteAddr,
|
||||||
version: connectParams.client.version,
|
version: connectParams.client.version,
|
||||||
platform: connectParams.client.platform,
|
platform: connectParams.client.platform,
|
||||||
deviceFamily: connectParams.client.deviceFamily,
|
deviceFamily: connectParams.client.deviceFamily,
|
||||||
@@ -716,6 +719,13 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
if (role === "node") {
|
if (role === "node") {
|
||||||
const context = buildRequestContext();
|
const context = buildRequestContext();
|
||||||
const nodeSession = context.nodeRegistry.register(nextClient, { remoteIp: remoteAddr });
|
const nodeSession = context.nodeRegistry.register(nextClient, { remoteIp: remoteAddr });
|
||||||
|
void updatePairedNodeMetadata(nodeSession.nodeId, {
|
||||||
|
lastConnectedAtMs: nodeSession.connectedAtMs,
|
||||||
|
}).catch((err) =>
|
||||||
|
logGateway.warn(
|
||||||
|
`failed to record last connect for ${nodeSession.nodeId}: ${formatForLog(err)}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
recordRemoteNodeInfo({
|
recordRemoteNodeInfo({
|
||||||
nodeId: nodeSession.nodeId,
|
nodeId: nodeSession.nodeId,
|
||||||
displayName: nodeSession.displayName,
|
displayName: nodeSession.displayName,
|
||||||
|
|||||||
+3
-5
@@ -28,6 +28,7 @@ import {
|
|||||||
PortInUseError,
|
PortInUseError,
|
||||||
} from "./infra/ports.js";
|
} from "./infra/ports.js";
|
||||||
import { assertSupportedRuntime } from "./infra/runtime-guard.js";
|
import { assertSupportedRuntime } from "./infra/runtime-guard.js";
|
||||||
|
import { formatUncaughtError } from "./infra/errors.js";
|
||||||
import { installUnhandledRejectionHandler } from "./infra/unhandled-rejections.js";
|
import { installUnhandledRejectionHandler } from "./infra/unhandled-rejections.js";
|
||||||
import { enableConsoleCapture } from "./logging.js";
|
import { enableConsoleCapture } from "./logging.js";
|
||||||
import { runCommandWithTimeout, runExec } from "./process/exec.js";
|
import { runCommandWithTimeout, runExec } from "./process/exec.js";
|
||||||
@@ -82,15 +83,12 @@ if (isMain) {
|
|||||||
installUnhandledRejectionHandler();
|
installUnhandledRejectionHandler();
|
||||||
|
|
||||||
process.on("uncaughtException", (error) => {
|
process.on("uncaughtException", (error) => {
|
||||||
console.error("[clawdbot] Uncaught exception:", error.stack ?? error.message);
|
console.error("[clawdbot] Uncaught exception:", formatUncaughtError(error));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
void program.parseAsync(process.argv).catch((err) => {
|
void program.parseAsync(process.argv).catch((err) => {
|
||||||
console.error(
|
console.error("[clawdbot] CLI failed:", formatUncaughtError(err));
|
||||||
"[clawdbot] CLI failed:",
|
|
||||||
err instanceof Error ? (err.stack ?? err.message) : err,
|
|
||||||
);
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -141,9 +141,11 @@ export type DiagnosticEventPayload =
|
|||||||
| DiagnosticRunAttemptEvent
|
| DiagnosticRunAttemptEvent
|
||||||
| DiagnosticHeartbeatEvent;
|
| DiagnosticHeartbeatEvent;
|
||||||
|
|
||||||
type DiagnosticEventInput<T extends DiagnosticEventPayload = DiagnosticEventPayload> =
|
export type DiagnosticEventInput = DiagnosticEventPayload extends infer Event
|
||||||
T extends DiagnosticEventPayload ? Omit<T, "seq" | "ts"> : never;
|
? Event extends DiagnosticEventPayload
|
||||||
|
? Omit<Event, "seq" | "ts">
|
||||||
|
: never
|
||||||
|
: never;
|
||||||
let seq = 0;
|
let seq = 0;
|
||||||
const listeners = new Set<(evt: DiagnosticEventPayload) => void>();
|
const listeners = new Set<(evt: DiagnosticEventPayload) => void>();
|
||||||
|
|
||||||
@@ -151,14 +153,12 @@ export function isDiagnosticsEnabled(config?: ClawdbotConfig): boolean {
|
|||||||
return config?.diagnostics?.enabled === true;
|
return config?.diagnostics?.enabled === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emitDiagnosticEvent<T extends DiagnosticEventPayload>(
|
export function emitDiagnosticEvent(event: DiagnosticEventInput) {
|
||||||
event: DiagnosticEventInput<T>,
|
|
||||||
) {
|
|
||||||
const enriched = {
|
const enriched = {
|
||||||
...event,
|
...event,
|
||||||
seq: (seq += 1),
|
seq: (seq += 1),
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
} as DiagnosticEventPayload;
|
} satisfies DiagnosticEventPayload;
|
||||||
for (const listener of listeners) {
|
for (const listener of listeners) {
|
||||||
try {
|
try {
|
||||||
listener(enriched);
|
listener(enriched);
|
||||||
|
|||||||
@@ -20,3 +20,13 @@ export function formatErrorMessage(err: unknown): string {
|
|||||||
return Object.prototype.toString.call(err);
|
return Object.prototype.toString.call(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatUncaughtError(err: unknown): string {
|
||||||
|
if (extractErrorCode(err) === "INVALID_CONFIG") {
|
||||||
|
return formatErrorMessage(err);
|
||||||
|
}
|
||||||
|
if (err instanceof Error) {
|
||||||
|
return err.stack ?? err.message ?? err.name;
|
||||||
|
}
|
||||||
|
return formatErrorMessage(err);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import { startHeartbeatRunner } from "./heartbeat-runner.js";
|
||||||
|
|
||||||
|
describe("startHeartbeatRunner", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates scheduling when config changes without restart", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date(0));
|
||||||
|
|
||||||
|
const runSpy = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 });
|
||||||
|
|
||||||
|
const runner = startHeartbeatRunner({
|
||||||
|
cfg: {
|
||||||
|
agents: { defaults: { heartbeat: { every: "30m" } } },
|
||||||
|
} as ClawdbotConfig,
|
||||||
|
runOnce: runSpy,
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(30 * 60_000 + 1_000);
|
||||||
|
|
||||||
|
expect(runSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(runSpy.mock.calls[0]?.[0]).toEqual(
|
||||||
|
expect.objectContaining({ agentId: "main", reason: "interval" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
runner.updateConfig({
|
||||||
|
agents: {
|
||||||
|
defaults: { heartbeat: { every: "30m" } },
|
||||||
|
list: [
|
||||||
|
{ id: "main", heartbeat: { every: "10m" } },
|
||||||
|
{ id: "ops", heartbeat: { every: "15m" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as ClawdbotConfig);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(10 * 60_000 + 1_000);
|
||||||
|
|
||||||
|
expect(runSpy).toHaveBeenCalledTimes(2);
|
||||||
|
expect(runSpy.mock.calls[1]?.[0]).toEqual(
|
||||||
|
expect.objectContaining({ agentId: "main", heartbeat: { every: "10m" } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(5 * 60_000 + 1_000);
|
||||||
|
|
||||||
|
expect(runSpy).toHaveBeenCalledTimes(3);
|
||||||
|
expect(runSpy.mock.calls[2]?.[0]).toEqual(
|
||||||
|
expect.objectContaining({ agentId: "ops", heartbeat: { every: "15m" } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
runner.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
+112
-34
@@ -70,6 +70,19 @@ export type HeartbeatSummary = {
|
|||||||
|
|
||||||
const DEFAULT_HEARTBEAT_TARGET = "last";
|
const DEFAULT_HEARTBEAT_TARGET = "last";
|
||||||
|
|
||||||
|
type HeartbeatAgentState = {
|
||||||
|
agentId: string;
|
||||||
|
heartbeat?: HeartbeatConfig;
|
||||||
|
intervalMs: number;
|
||||||
|
lastRunMs?: number;
|
||||||
|
nextDueMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HeartbeatRunner = {
|
||||||
|
stop: () => void;
|
||||||
|
updateConfig: (cfg: ClawdbotConfig) => void;
|
||||||
|
};
|
||||||
|
|
||||||
function hasExplicitHeartbeatAgents(cfg: ClawdbotConfig) {
|
function hasExplicitHeartbeatAgents(cfg: ClawdbotConfig) {
|
||||||
const list = cfg.agents?.list ?? [];
|
const list = cfg.agents?.list ?? [];
|
||||||
return list.some((entry) => Boolean(entry?.heartbeat));
|
return list.some((entry) => Boolean(entry?.heartbeat));
|
||||||
@@ -539,24 +552,97 @@ export function startHeartbeatRunner(opts: {
|
|||||||
cfg?: ClawdbotConfig;
|
cfg?: ClawdbotConfig;
|
||||||
runtime?: RuntimeEnv;
|
runtime?: RuntimeEnv;
|
||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
}) {
|
runOnce?: typeof runHeartbeatOnce;
|
||||||
const cfg = opts.cfg ?? loadConfig();
|
}): HeartbeatRunner {
|
||||||
const heartbeatAgents = resolveHeartbeatAgents(cfg);
|
|
||||||
const intervals = heartbeatAgents
|
|
||||||
.map((agent) => resolveHeartbeatIntervalMs(cfg, undefined, agent.heartbeat))
|
|
||||||
.filter((value): value is number => typeof value === "number");
|
|
||||||
const intervalMs = intervals.length > 0 ? Math.min(...intervals) : null;
|
|
||||||
if (!intervalMs) {
|
|
||||||
log.info("heartbeat: disabled", { enabled: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
const runtime = opts.runtime ?? defaultRuntime;
|
const runtime = opts.runtime ?? defaultRuntime;
|
||||||
const lastRunByAgent = new Map<string, number>();
|
const runOnce = opts.runOnce ?? runHeartbeatOnce;
|
||||||
|
const state = {
|
||||||
|
cfg: opts.cfg ?? loadConfig(),
|
||||||
|
runtime,
|
||||||
|
agents: new Map<string, HeartbeatAgentState>(),
|
||||||
|
timer: null as NodeJS.Timeout | null,
|
||||||
|
stopped: false,
|
||||||
|
};
|
||||||
|
let initialized = false;
|
||||||
|
|
||||||
|
const resolveNextDue = (now: number, intervalMs: number, prevState?: HeartbeatAgentState) => {
|
||||||
|
if (typeof prevState?.lastRunMs === "number") {
|
||||||
|
return prevState.lastRunMs + intervalMs;
|
||||||
|
}
|
||||||
|
if (prevState && prevState.intervalMs === intervalMs && prevState.nextDueMs > now) {
|
||||||
|
return prevState.nextDueMs;
|
||||||
|
}
|
||||||
|
return now + intervalMs;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleNext = () => {
|
||||||
|
if (state.stopped) return;
|
||||||
|
if (state.timer) {
|
||||||
|
clearTimeout(state.timer);
|
||||||
|
state.timer = null;
|
||||||
|
}
|
||||||
|
if (state.agents.size === 0) return;
|
||||||
|
const now = Date.now();
|
||||||
|
let nextDue = Number.POSITIVE_INFINITY;
|
||||||
|
for (const agent of state.agents.values()) {
|
||||||
|
if (agent.nextDueMs < nextDue) nextDue = agent.nextDueMs;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(nextDue)) return;
|
||||||
|
const delay = Math.max(0, nextDue - now);
|
||||||
|
state.timer = setTimeout(() => {
|
||||||
|
requestHeartbeatNow({ reason: "interval", coalesceMs: 0 });
|
||||||
|
}, delay);
|
||||||
|
state.timer.unref?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateConfig = (cfg: ClawdbotConfig) => {
|
||||||
|
if (state.stopped) return;
|
||||||
|
const now = Date.now();
|
||||||
|
const prevAgents = state.agents;
|
||||||
|
const prevEnabled = prevAgents.size > 0;
|
||||||
|
const nextAgents = new Map<string, HeartbeatAgentState>();
|
||||||
|
const intervals: number[] = [];
|
||||||
|
for (const agent of resolveHeartbeatAgents(cfg)) {
|
||||||
|
const intervalMs = resolveHeartbeatIntervalMs(cfg, undefined, agent.heartbeat);
|
||||||
|
if (!intervalMs) continue;
|
||||||
|
intervals.push(intervalMs);
|
||||||
|
const prevState = prevAgents.get(agent.agentId);
|
||||||
|
const nextDueMs = resolveNextDue(now, intervalMs, prevState);
|
||||||
|
nextAgents.set(agent.agentId, {
|
||||||
|
agentId: agent.agentId,
|
||||||
|
heartbeat: agent.heartbeat,
|
||||||
|
intervalMs,
|
||||||
|
lastRunMs: prevState?.lastRunMs,
|
||||||
|
nextDueMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
state.cfg = cfg;
|
||||||
|
state.agents = nextAgents;
|
||||||
|
const nextEnabled = nextAgents.size > 0;
|
||||||
|
if (!initialized) {
|
||||||
|
if (!nextEnabled) {
|
||||||
|
log.info("heartbeat: disabled", { enabled: false });
|
||||||
|
} else {
|
||||||
|
log.info("heartbeat: started", { intervalMs: Math.min(...intervals) });
|
||||||
|
}
|
||||||
|
initialized = true;
|
||||||
|
} else if (prevEnabled !== nextEnabled) {
|
||||||
|
if (!nextEnabled) {
|
||||||
|
log.info("heartbeat: disabled", { enabled: false });
|
||||||
|
} else {
|
||||||
|
log.info("heartbeat: started", { intervalMs: Math.min(...intervals) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleNext();
|
||||||
|
};
|
||||||
|
|
||||||
const run: HeartbeatWakeHandler = async (params) => {
|
const run: HeartbeatWakeHandler = async (params) => {
|
||||||
if (!heartbeatsEnabled) {
|
if (!heartbeatsEnabled) {
|
||||||
return { status: "skipped", reason: "disabled" } satisfies HeartbeatRunResult;
|
return { status: "skipped", reason: "disabled" } satisfies HeartbeatRunResult;
|
||||||
}
|
}
|
||||||
if (heartbeatAgents.length === 0) {
|
if (state.agents.size === 0) {
|
||||||
return { status: "skipped", reason: "disabled" } satisfies HeartbeatRunResult;
|
return { status: "skipped", reason: "disabled" } satisfies HeartbeatRunResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -566,52 +652,44 @@ export function startHeartbeatRunner(opts: {
|
|||||||
const now = startedAt;
|
const now = startedAt;
|
||||||
let ran = false;
|
let ran = false;
|
||||||
|
|
||||||
for (const agent of heartbeatAgents) {
|
for (const agent of state.agents.values()) {
|
||||||
const agentIntervalMs = resolveHeartbeatIntervalMs(cfg, undefined, agent.heartbeat);
|
if (isInterval && now < agent.nextDueMs) {
|
||||||
if (!agentIntervalMs) continue;
|
|
||||||
const lastRun = lastRunByAgent.get(agent.agentId);
|
|
||||||
if (isInterval && typeof lastRun === "number" && now - lastRun < agentIntervalMs) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await runHeartbeatOnce({
|
const res = await runOnce({
|
||||||
cfg,
|
cfg: state.cfg,
|
||||||
agentId: agent.agentId,
|
agentId: agent.agentId,
|
||||||
heartbeat: agent.heartbeat,
|
heartbeat: agent.heartbeat,
|
||||||
reason,
|
reason,
|
||||||
deps: { runtime },
|
deps: { runtime: state.runtime },
|
||||||
});
|
});
|
||||||
if (res.status === "skipped" && res.reason === "requests-in-flight") {
|
if (res.status === "skipped" && res.reason === "requests-in-flight") {
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
if (res.status !== "skipped" || res.reason !== "disabled") {
|
if (res.status !== "skipped" || res.reason !== "disabled") {
|
||||||
lastRunByAgent.set(agent.agentId, now);
|
agent.lastRunMs = now;
|
||||||
|
agent.nextDueMs = now + agent.intervalMs;
|
||||||
}
|
}
|
||||||
if (res.status === "ran") ran = true;
|
if (res.status === "ran") ran = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scheduleNext();
|
||||||
if (ran) return { status: "ran", durationMs: Date.now() - startedAt };
|
if (ran) return { status: "ran", durationMs: Date.now() - startedAt };
|
||||||
return { status: "skipped", reason: isInterval ? "not-due" : "disabled" };
|
return { status: "skipped", reason: isInterval ? "not-due" : "disabled" };
|
||||||
};
|
};
|
||||||
|
|
||||||
setHeartbeatWakeHandler(async (params) => run({ reason: params.reason }));
|
setHeartbeatWakeHandler(async (params) => run({ reason: params.reason }));
|
||||||
|
updateConfig(state.cfg);
|
||||||
let timer: NodeJS.Timeout | null = null;
|
|
||||||
if (intervalMs) {
|
|
||||||
timer = setInterval(() => {
|
|
||||||
requestHeartbeatNow({ reason: "interval", coalesceMs: 0 });
|
|
||||||
}, intervalMs);
|
|
||||||
timer.unref?.();
|
|
||||||
log.info("heartbeat: started", { intervalMs });
|
|
||||||
}
|
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
|
state.stopped = true;
|
||||||
setHeartbeatWakeHandler(null);
|
setHeartbeatWakeHandler(null);
|
||||||
if (timer) clearInterval(timer);
|
if (state.timer) clearTimeout(state.timer);
|
||||||
timer = null;
|
state.timer = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
opts.abortSignal?.addEventListener("abort", cleanup, { once: true });
|
opts.abortSignal?.addEventListener("abort", cleanup, { once: true });
|
||||||
|
|
||||||
return { stop: cleanup };
|
return { stop: cleanup, updateConfig };
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { lookup as dnsLookup } from "node:dns/promises";
|
||||||
|
|
||||||
|
export class SsrFBlockedError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "SsrFBlockedError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type LookupFn = typeof dnsLookup;
|
||||||
|
|
||||||
|
const PRIVATE_IPV6_PREFIXES = ["fe80:", "fec0:", "fc", "fd"];
|
||||||
|
const BLOCKED_HOSTNAMES = new Set(["localhost", "metadata.google.internal"]);
|
||||||
|
|
||||||
|
function normalizeHostname(hostname: string): string {
|
||||||
|
const normalized = hostname.trim().toLowerCase().replace(/\.$/, "");
|
||||||
|
if (normalized.startsWith("[") && normalized.endsWith("]")) {
|
||||||
|
return normalized.slice(1, -1);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIpv4(address: string): number[] | null {
|
||||||
|
const parts = address.split(".");
|
||||||
|
if (parts.length !== 4) return null;
|
||||||
|
const numbers = parts.map((part) => Number.parseInt(part, 10));
|
||||||
|
if (numbers.some((value) => Number.isNaN(value) || value < 0 || value > 255)) return null;
|
||||||
|
return numbers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIpv4FromMappedIpv6(mapped: string): number[] | null {
|
||||||
|
if (mapped.includes(".")) {
|
||||||
|
return parseIpv4(mapped);
|
||||||
|
}
|
||||||
|
const parts = mapped.split(":").filter(Boolean);
|
||||||
|
if (parts.length === 1) {
|
||||||
|
const value = Number.parseInt(parts[0], 16);
|
||||||
|
if (Number.isNaN(value) || value < 0 || value > 0xffff_ffff) return null;
|
||||||
|
return [(value >>> 24) & 0xff, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff];
|
||||||
|
}
|
||||||
|
if (parts.length !== 2) return null;
|
||||||
|
const high = Number.parseInt(parts[0], 16);
|
||||||
|
const low = Number.parseInt(parts[1], 16);
|
||||||
|
if (
|
||||||
|
Number.isNaN(high) ||
|
||||||
|
Number.isNaN(low) ||
|
||||||
|
high < 0 ||
|
||||||
|
low < 0 ||
|
||||||
|
high > 0xffff ||
|
||||||
|
low > 0xffff
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const value = (high << 16) + low;
|
||||||
|
return [(value >>> 24) & 0xff, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrivateIpv4(parts: number[]): boolean {
|
||||||
|
const [octet1, octet2] = parts;
|
||||||
|
if (octet1 === 0) return true;
|
||||||
|
if (octet1 === 10) return true;
|
||||||
|
if (octet1 === 127) return true;
|
||||||
|
if (octet1 === 169 && octet2 === 254) return true;
|
||||||
|
if (octet1 === 172 && octet2 >= 16 && octet2 <= 31) return true;
|
||||||
|
if (octet1 === 192 && octet2 === 168) return true;
|
||||||
|
if (octet1 === 100 && octet2 >= 64 && octet2 <= 127) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPrivateIpAddress(address: string): boolean {
|
||||||
|
let normalized = address.trim().toLowerCase();
|
||||||
|
if (normalized.startsWith("[") && normalized.endsWith("]")) {
|
||||||
|
normalized = normalized.slice(1, -1);
|
||||||
|
}
|
||||||
|
if (!normalized) return false;
|
||||||
|
|
||||||
|
if (normalized.startsWith("::ffff:")) {
|
||||||
|
const mapped = normalized.slice("::ffff:".length);
|
||||||
|
const ipv4 = parseIpv4FromMappedIpv6(mapped);
|
||||||
|
if (ipv4) return isPrivateIpv4(ipv4);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.includes(":")) {
|
||||||
|
if (normalized === "::" || normalized === "::1") return true;
|
||||||
|
return PRIVATE_IPV6_PREFIXES.some((prefix) => normalized.startsWith(prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
const ipv4 = parseIpv4(normalized);
|
||||||
|
if (!ipv4) return false;
|
||||||
|
return isPrivateIpv4(ipv4);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBlockedHostname(hostname: string): boolean {
|
||||||
|
const normalized = normalizeHostname(hostname);
|
||||||
|
if (!normalized) return false;
|
||||||
|
if (BLOCKED_HOSTNAMES.has(normalized)) return true;
|
||||||
|
return (
|
||||||
|
normalized.endsWith(".localhost") ||
|
||||||
|
normalized.endsWith(".local") ||
|
||||||
|
normalized.endsWith(".internal")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertPublicHostname(
|
||||||
|
hostname: string,
|
||||||
|
lookupFn: LookupFn = dnsLookup,
|
||||||
|
): Promise<void> {
|
||||||
|
const normalized = normalizeHostname(hostname);
|
||||||
|
if (!normalized) {
|
||||||
|
throw new Error("Invalid hostname");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBlockedHostname(normalized)) {
|
||||||
|
throw new SsrFBlockedError(`Blocked hostname: ${hostname}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPrivateIpAddress(normalized)) {
|
||||||
|
throw new SsrFBlockedError("Blocked: private/internal IP address");
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await lookupFn(normalized, { all: true });
|
||||||
|
if (results.length === 0) {
|
||||||
|
throw new Error(`Unable to resolve hostname: ${hostname}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of results) {
|
||||||
|
if (isPrivateIpAddress(entry.address)) {
|
||||||
|
throw new SsrFBlockedError("Blocked: resolves to private/internal IP address");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ export type NodePairingPairedNode = {
|
|||||||
remoteIp?: string;
|
remoteIp?: string;
|
||||||
createdAtMs: number;
|
createdAtMs: number;
|
||||||
approvedAtMs: number;
|
approvedAtMs: number;
|
||||||
|
lastConnectedAtMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NodePairingList = {
|
export type NodePairingList = {
|
||||||
@@ -295,6 +296,7 @@ export async function updatePairedNodeMetadata(
|
|||||||
commands: patch.commands ?? existing.commands,
|
commands: patch.commands ?? existing.commands,
|
||||||
bins: patch.bins ?? existing.bins,
|
bins: patch.bins ?? existing.bins,
|
||||||
permissions: patch.permissions ?? existing.permissions,
|
permissions: patch.permissions ?? existing.permissions,
|
||||||
|
lastConnectedAtMs: patch.lastConnectedAtMs ?? existing.lastConnectedAtMs,
|
||||||
};
|
};
|
||||||
|
|
||||||
state.pairedByNodeId[normalized] = next;
|
state.pairedByNodeId[normalized] = next;
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ export type RestartSentinelPayload = {
|
|||||||
status: "ok" | "error" | "skipped";
|
status: "ok" | "error" | "skipped";
|
||||||
ts: number;
|
ts: number;
|
||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
|
/** Delivery context captured at restart time to ensure channel routing survives restart. */
|
||||||
|
deliveryContext?: {
|
||||||
|
channel?: string;
|
||||||
|
to?: string;
|
||||||
|
accountId?: string;
|
||||||
|
};
|
||||||
|
/** Thread ID for reply threading (e.g., Slack thread_ts). */
|
||||||
|
threadId?: string;
|
||||||
message?: string | null;
|
message?: string | null;
|
||||||
doctorHint?: string | null;
|
doctorHint?: string | null;
|
||||||
stats?: RestartSentinelStats | null;
|
stats?: RestartSentinelStats | null;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import process from "node:process";
|
import process from "node:process";
|
||||||
|
|
||||||
|
import { formatUncaughtError } from "./errors.js";
|
||||||
|
|
||||||
type UnhandledRejectionHandler = (reason: unknown) => boolean;
|
type UnhandledRejectionHandler = (reason: unknown) => boolean;
|
||||||
|
|
||||||
const handlers = new Set<UnhandledRejectionHandler>();
|
const handlers = new Set<UnhandledRejectionHandler>();
|
||||||
@@ -28,10 +30,7 @@ export function isUnhandledRejectionHandled(reason: unknown): boolean {
|
|||||||
export function installUnhandledRejectionHandler(): void {
|
export function installUnhandledRejectionHandler(): void {
|
||||||
process.on("unhandledRejection", (reason, _promise) => {
|
process.on("unhandledRejection", (reason, _promise) => {
|
||||||
if (isUnhandledRejectionHandled(reason)) return;
|
if (isUnhandledRejectionHandled(reason)) return;
|
||||||
console.error(
|
console.error("[clawdbot] Unhandled promise rejection:", formatUncaughtError(reason));
|
||||||
"[clawdbot] Unhandled promise rejection:",
|
|
||||||
reason instanceof Error ? (reason.stack ?? reason.message) : reason,
|
|
||||||
);
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -338,6 +338,7 @@ export function startDiagnosticHeartbeat() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
heartbeatInterval.unref?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopDiagnosticHeartbeat() {
|
export function stopDiagnosticHeartbeat() {
|
||||||
|
|||||||
+2
-1
@@ -55,6 +55,7 @@ async function main() {
|
|||||||
|
|
||||||
const { assertSupportedRuntime } = await import("../infra/runtime-guard.js");
|
const { assertSupportedRuntime } = await import("../infra/runtime-guard.js");
|
||||||
assertSupportedRuntime();
|
assertSupportedRuntime();
|
||||||
|
const { formatUncaughtError } = await import("../infra/errors.js");
|
||||||
const { installUnhandledRejectionHandler } = await import("../infra/unhandled-rejections.js");
|
const { installUnhandledRejectionHandler } = await import("../infra/unhandled-rejections.js");
|
||||||
|
|
||||||
const { buildProgram } = await import("../cli/program.js");
|
const { buildProgram } = await import("../cli/program.js");
|
||||||
@@ -63,7 +64,7 @@ async function main() {
|
|||||||
installUnhandledRejectionHandler();
|
installUnhandledRejectionHandler();
|
||||||
|
|
||||||
process.on("uncaughtException", (error) => {
|
process.on("uncaughtException", (error) => {
|
||||||
console.error("[clawdbot] Uncaught exception:", error.stack ?? error.message);
|
console.error("[clawdbot] Uncaught exception:", formatUncaughtError(error));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { lookup } from "node:dns/promises";
|
|
||||||
import { logWarn } from "../logger.js";
|
import { logWarn } from "../logger.js";
|
||||||
|
import { assertPublicHostname } from "../infra/net/ssrf.js";
|
||||||
|
|
||||||
type CanvasModule = typeof import("@napi-rs/canvas");
|
type CanvasModule = typeof import("@napi-rs/canvas");
|
||||||
type PdfJsModule = typeof import("pdfjs-dist/legacy/build/pdf.mjs");
|
type PdfJsModule = typeof import("pdfjs-dist/legacy/build/pdf.mjs");
|
||||||
@@ -107,50 +107,6 @@ export const DEFAULT_INPUT_PDF_MAX_PAGES = 4;
|
|||||||
export const DEFAULT_INPUT_PDF_MAX_PIXELS = 4_000_000;
|
export const DEFAULT_INPUT_PDF_MAX_PIXELS = 4_000_000;
|
||||||
export const DEFAULT_INPUT_PDF_MIN_TEXT_CHARS = 200;
|
export const DEFAULT_INPUT_PDF_MIN_TEXT_CHARS = 200;
|
||||||
|
|
||||||
const PRIVATE_IPV4_PATTERNS = [
|
|
||||||
/^127\./,
|
|
||||||
/^10\./,
|
|
||||||
/^192\.168\./,
|
|
||||||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./,
|
|
||||||
/^0\./,
|
|
||||||
];
|
|
||||||
const PRIVATE_IPV6_PREFIXES = ["::1", "fe80:", "fec0:", "fc", "fd"];
|
|
||||||
|
|
||||||
function isPrivateIpAddress(address: string): boolean {
|
|
||||||
if (address.includes(":")) {
|
|
||||||
const lower = address.toLowerCase();
|
|
||||||
if (lower === "::1") return true;
|
|
||||||
return PRIVATE_IPV6_PREFIXES.some((prefix) => lower.startsWith(prefix));
|
|
||||||
}
|
|
||||||
return PRIVATE_IPV4_PATTERNS.some((pattern) => pattern.test(address));
|
|
||||||
}
|
|
||||||
|
|
||||||
function isBlockedHostname(hostname: string): boolean {
|
|
||||||
const lower = hostname.toLowerCase();
|
|
||||||
return (
|
|
||||||
lower === "localhost" ||
|
|
||||||
lower.endsWith(".localhost") ||
|
|
||||||
lower.endsWith(".local") ||
|
|
||||||
lower.endsWith(".internal")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function assertPublicHostname(hostname: string): Promise<void> {
|
|
||||||
if (isBlockedHostname(hostname)) {
|
|
||||||
throw new Error(`Blocked hostname: ${hostname}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await lookup(hostname, { all: true });
|
|
||||||
if (results.length === 0) {
|
|
||||||
throw new Error(`Unable to resolve hostname: ${hostname}`);
|
|
||||||
}
|
|
||||||
for (const entry of results) {
|
|
||||||
if (isPrivateIpAddress(entry.address)) {
|
|
||||||
throw new Error(`Private IP addresses are not allowed: ${entry.address}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRedirectStatus(status: number): boolean {
|
function isRedirectStatus(status: number): boolean {
|
||||||
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
|
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
|
||||||
}
|
}
|
||||||
|
|||||||
Loaded 100 of 131 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user