Compare commits
66
Commits
No files matched your search
@@ -21,3 +21,4 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
configuration-path: .github/labeler.yml
|
configuration-path: .github/labeler.yml
|
||||||
repo-token: ${{ steps.app-token.outputs.token }}
|
repo-token: ${{ steps.app-token.outputs.token }}
|
||||||
|
sync-labels: true
|
||||||
@@ -40,6 +40,8 @@ apps/ios/*.xcfilelist
|
|||||||
|
|
||||||
# Vendor build artifacts
|
# Vendor build artifacts
|
||||||
vendor/a2ui/renderers/lit/dist/
|
vendor/a2ui/renderers/lit/dist/
|
||||||
|
src/canvas-host/a2ui/*.bundle.js
|
||||||
|
src/canvas-host/a2ui/*.map
|
||||||
.bundle.hash
|
.bundle.hash
|
||||||
|
|
||||||
# fastlane (iOS)
|
# fastlane (iOS)
|
||||||
|
|||||||
+18
-2
@@ -6,6 +6,9 @@ Docs: https://docs.clawd.bot
|
|||||||
Status: unreleased.
|
Status: unreleased.
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
- macOS: limit project-local `node_modules/.bin` PATH preference to debug builds (reduce PATH hijacking risk).
|
||||||
|
- Tools: add per-sender group tool policies and fix precedence. (#1757) Thanks @adam91holt.
|
||||||
|
- Agents: summarize dropped messages during compaction safeguard pruning. (#2509) Thanks @jogi47.
|
||||||
- Skills: add multi-image input support to Nano Banana Pro skill. (#1958) Thanks @tyler6204.
|
- Skills: add multi-image input support to Nano Banana Pro skill. (#1958) Thanks @tyler6204.
|
||||||
- Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281)
|
- Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281)
|
||||||
- Matrix: switch plugin SDK to @vector-im/matrix-bot-sdk.
|
- Matrix: switch plugin SDK to @vector-im/matrix-bot-sdk.
|
||||||
@@ -35,10 +38,14 @@ Status: unreleased.
|
|||||||
- CI: increase Node heap size for macOS checks. (#1890) Thanks @realZachi.
|
- CI: increase Node heap size for macOS checks. (#1890) Thanks @realZachi.
|
||||||
- macOS: avoid crash when rendering code blocks by bumping Textual to 0.3.1. (#2033) Thanks @garricn.
|
- macOS: avoid crash when rendering code blocks by bumping Textual to 0.3.1. (#2033) Thanks @garricn.
|
||||||
- Browser: fall back to URL matching for extension relay target resolution. (#1999) Thanks @jonit-dev.
|
- Browser: fall back to URL matching for extension relay target resolution. (#1999) Thanks @jonit-dev.
|
||||||
|
- Browser: route browser control via gateway/node; remove standalone browser control command and control URL config.
|
||||||
|
- Browser: route `browser.request` via node proxies when available; honor proxy timeouts; derive browser ports from `gateway.port`.
|
||||||
- Update: ignore dist/control-ui for dirty checks and restore after ui builds. (#1976) Thanks @Glucksberg.
|
- Update: ignore dist/control-ui for dirty checks and restore after ui builds. (#1976) Thanks @Glucksberg.
|
||||||
|
- Build: bundle A2UI assets during build and stop tracking generated bundles. (#2455) Thanks @0oAstro.
|
||||||
- Telegram: allow caption param for media sends. (#1888) Thanks @mguellsegarra.
|
- Telegram: allow caption param for media sends. (#1888) Thanks @mguellsegarra.
|
||||||
- Telegram: support plugin sendPayload channelData (media/buttons) and validate plugin commands. (#1917) Thanks @JoshuaLelon.
|
- Telegram: support plugin sendPayload channelData (media/buttons) and validate plugin commands. (#1917) Thanks @JoshuaLelon.
|
||||||
- Telegram: avoid block replies when streaming is disabled. (#1885) Thanks @ivancasco.
|
- Telegram: avoid block replies when streaming is disabled. (#1885) Thanks @ivancasco.
|
||||||
|
- Docs: keep docs header sticky so navbar stays visible while scrolling. (#2445) Thanks @chenyuan99.
|
||||||
- Security: use Windows ACLs for permission audits and fixes on Windows. (#1957)
|
- Security: use Windows ACLs for permission audits and fixes on Windows. (#1957)
|
||||||
- Auth: show copyable Google auth URL after ASCII prompt. (#1787) Thanks @robbyczgw-cla.
|
- Auth: show copyable Google auth URL after ASCII prompt. (#1787) Thanks @robbyczgw-cla.
|
||||||
- Routing: precompile session key regexes. (#1697) Thanks @Ray0907.
|
- Routing: precompile session key regexes. (#1697) Thanks @Ray0907.
|
||||||
@@ -46,6 +53,7 @@ Status: unreleased.
|
|||||||
- Telegram: keep topic IDs in restart sentinel notifications. (#1807) Thanks @hsrvc.
|
- Telegram: keep topic IDs in restart sentinel notifications. (#1807) Thanks @hsrvc.
|
||||||
- Telegram: add optional silent send flag (disable notifications). (#2382) Thanks @Suksham-sharma.
|
- Telegram: add optional silent send flag (disable notifications). (#2382) Thanks @Suksham-sharma.
|
||||||
- Telegram: support editing sent messages via message(action="edit"). (#2394) Thanks @marcelomar21.
|
- Telegram: support editing sent messages via message(action="edit"). (#2394) Thanks @marcelomar21.
|
||||||
|
- Telegram: add sticker receive/send with vision caching. (#2548) Thanks @longjos.
|
||||||
- Config: apply config.env before ${VAR} substitution. (#1813) Thanks @spanishflu-est1918.
|
- Config: apply config.env before ${VAR} substitution. (#1813) Thanks @spanishflu-est1918.
|
||||||
- Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999.
|
- Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999.
|
||||||
- macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal.
|
- macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal.
|
||||||
@@ -54,14 +62,22 @@ Status: unreleased.
|
|||||||
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
|
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
- Memory Search: keep auto provider model defaults and only include remote when configured. (#2576) Thanks @papago2355.
|
||||||
|
- macOS: auto-scroll to bottom when sending a new message while scrolled up. (#2471) Thanks @kennyklee.
|
||||||
|
- Gateway: suppress AbortError and transient network errors in unhandled rejections. (#2451) Thanks @Glucksberg.
|
||||||
|
- TTS: keep /tts status replies on text-only commands and avoid duplicate block-stream audio. (#2451) Thanks @Glucksberg.
|
||||||
- Security: pin npm overrides to keep tar@7.5.4 for install toolchains.
|
- Security: pin npm overrides to keep tar@7.5.4 for install toolchains.
|
||||||
- Security: properly test Windows ACL audit for config includes. (#2403) Thanks @dominicnunez.
|
- Security: properly test Windows ACL audit for config includes. (#2403) Thanks @dominicnunez.
|
||||||
|
- CLI: recognize versioned Node executables when parsing argv. (#2490) Thanks @David-Marsh-Photo.
|
||||||
- BlueBubbles: coalesce inbound URL link preview messages. (#1981) Thanks @tyler6204.
|
- BlueBubbles: coalesce inbound URL link preview messages. (#1981) Thanks @tyler6204.
|
||||||
- Cron: allow payloads containing "heartbeat" in event filter. (#2219) Thanks @dwfinkelstein.
|
- Cron: allow payloads containing "heartbeat" in event filter. (#2219) Thanks @dwfinkelstein.
|
||||||
- CLI: avoid loading config for global help/version while registering plugin commands. (#2212) Thanks @dial481.
|
- CLI: avoid loading config for global help/version while registering plugin commands. (#2212) Thanks @dial481.
|
||||||
- Agents: include memory.md when bootstrapping memory context. (#2318) Thanks @czekaj.
|
- Agents: include memory.md when bootstrapping memory context. (#2318) Thanks @czekaj.
|
||||||
|
- Agents: release session locks on process termination and cover more signals. (#2483) Thanks @janeexai.
|
||||||
|
- Agents: skip cooldowned providers during model failover. (#2143) Thanks @YiWang24.
|
||||||
- Telegram: harden polling + retry behavior for transient network errors and Node 22 transport issues. (#2420) Thanks @techboss.
|
- Telegram: harden polling + retry behavior for transient network errors and Node 22 transport issues. (#2420) Thanks @techboss.
|
||||||
- Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos.
|
- Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos.
|
||||||
|
- Telegram: centralize API error logging for delivery and bot calls. (#2492) Thanks @altryne.
|
||||||
- Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default.
|
- Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default.
|
||||||
- Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers.
|
- Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers.
|
||||||
- Build: align memory-core peer dependency with lockfile.
|
- Build: align memory-core peer dependency with lockfile.
|
||||||
@@ -697,7 +713,7 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
|||||||
|
|
||||||
### Highlights
|
### Highlights
|
||||||
- Web search: `web_search`/`web_fetch` tools (Brave API) + first-time setup in onboarding/configure.
|
- Web search: `web_search`/`web_fetch` tools (Brave API) + first-time setup in onboarding/configure.
|
||||||
- Browser control: Chrome extension relay takeover mode + remote browser control via `clawdbot browser serve`.
|
- Browser control: Chrome extension relay takeover mode + remote browser control support.
|
||||||
- Plugins: channel plugins (gateway HTTP hooks) + Zalo plugin + onboarding install flow. (#854) — thanks @longmaba.
|
- Plugins: channel plugins (gateway HTTP hooks) + Zalo plugin + onboarding install flow. (#854) — thanks @longmaba.
|
||||||
- Security: expanded `clawdbot security audit` (+ `--fix`), detect-secrets CI scan, and a `SECURITY.md` reporting policy.
|
- Security: expanded `clawdbot security audit` (+ `--fix`), detect-secrets CI scan, and a `SECURITY.md` reporting policy.
|
||||||
|
|
||||||
@@ -715,7 +731,7 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
|||||||
- Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.
|
- Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.
|
||||||
- Tools: add `web_search`/`web_fetch` (Brave API), auto-enable `web_fetch` for sandboxed sessions, and remove the `brave-search` skill.
|
- Tools: add `web_search`/`web_fetch` (Brave API), auto-enable `web_fetch` for sandboxed sessions, and remove the `brave-search` skill.
|
||||||
- CLI/Docs: add a web tools configure section for storing Brave API keys and update onboarding tips.
|
- CLI/Docs: add a web tools configure section for storing Brave API keys and update onboarding tips.
|
||||||
- Browser: add Chrome extension relay takeover mode (toolbar button), plus `clawdbot browser extension install/path` and remote browser control via `clawdbot browser serve` + `browser.controlToken`.
|
- Browser: add Chrome extension relay takeover mode (toolbar button), plus `clawdbot browser extension install/path` and remote browser control (standalone server + token auth).
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Sessions: refactor session store updates to lock + mutate per-entry, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
|
- Sessions: refactor session store updates to lock + mutate per-entry, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
|
||||||
|
|||||||
@@ -384,7 +384,6 @@ Browser control (optional):
|
|||||||
{
|
{
|
||||||
browser: {
|
browser: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
controlUrl: "http://127.0.0.1:18791",
|
|
||||||
color: "#FF4500"
|
color: "#FF4500"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -479,36 +478,34 @@ 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/plum-dawg"><img src="https://avatars.githubusercontent.com/u/5909950?v=4&s=48" width="48" height="48" alt="plum-dawg" title="plum-dawg"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></a> <a href="https://github.com/jaydenfyi"><img src="https://avatars.githubusercontent.com/u/213395523?v=4&s=48" width="48" height="48" alt="jaydenfyi" title="jaydenfyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/Glucksberg"><img src="https://avatars.githubusercontent.com/u/80581902?v=4&s=48" width="48" height="48" alt="Glucksberg" title="Glucksberg"/></a>
|
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/plum-dawg"><img src="https://avatars.githubusercontent.com/u/5909950?v=4&s=48" width="48" height="48" alt="plum-dawg" title="plum-dawg"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></a> <a href="https://github.com/jaydenfyi"><img src="https://avatars.githubusercontent.com/u/213395523?v=4&s=48" width="48" height="48" alt="jaydenfyi" title="jaydenfyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/Glucksberg"><img src="https://avatars.githubusercontent.com/u/80581902?v=4&s=48" width="48" height="48" alt="Glucksberg" title="Glucksberg"/></a>
|
||||||
<a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a>
|
<a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a>
|
||||||
<a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="patelhiren" title="patelhiren"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/jonisjongithub"><img src="https://avatars.githubusercontent.com/u/86072337?v=4&s=48" width="48" height="48" alt="jonisjongithub" title="jonisjongithub"/></a>
|
<a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/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/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="patelhiren" title="patelhiren"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a>
|
||||||
<a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></a> <a href="https://github.com/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/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></a>
|
<a href="https://github.com/jonisjongithub"><img src="https://avatars.githubusercontent.com/u/86072337?v=4&s=48" width="48" height="48" alt="jonisjongithub" title="jonisjongithub"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></a>
|
||||||
<a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/mousberg"><img src="https://avatars.githubusercontent.com/u/57605064?v=4&s=48" width="48" height="48" alt="mousberg" title="mousberg"/></a> <a href="https://github.com/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/hirefrank"><img src="https://avatars.githubusercontent.com/u/183158?v=4&s=48" width="48" height="48" alt="hirefrank" title="hirefrank"/></a> <a href="https://github.com/joeynyc"><img src="https://avatars.githubusercontent.com/u/17919866?v=4&s=48" width="48" height="48" alt="joeynyc" title="joeynyc"/></a> <a href="https://github.com/orlyjamie"><img src="https://avatars.githubusercontent.com/u/6668807?v=4&s=48" width="48" height="48" alt="orlyjamie" title="orlyjamie"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a>
|
<a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/mousberg"><img src="https://avatars.githubusercontent.com/u/57605064?v=4&s=48" width="48" height="48" alt="mousberg" title="mousberg"/></a> <a href="https://github.com/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/hirefrank"><img src="https://avatars.githubusercontent.com/u/183158?v=4&s=48" width="48" height="48" alt="hirefrank" title="hirefrank"/></a> <a href="https://github.com/joeynyc"><img src="https://avatars.githubusercontent.com/u/17919866?v=4&s=48" width="48" height="48" alt="joeynyc" title="joeynyc"/></a> <a href="https://github.com/orlyjamie"><img src="https://avatars.githubusercontent.com/u/6668807?v=4&s=48" width="48" height="48" alt="orlyjamie" title="orlyjamie"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a>
|
||||||
<a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/rohannagpal"><img src="https://avatars.githubusercontent.com/u/4009239?v=4&s=48" width="48" height="48" alt="rohannagpal" title="rohannagpal"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/f-trycua"><img src="https://avatars.githubusercontent.com/u/195596869?v=4&s=48" width="48" height="48" alt="f-trycua" title="f-trycua"/></a> <a href="https://github.com/benostein"><img src="https://avatars.githubusercontent.com/u/31802821?v=4&s=48" width="48" height="48" alt="benostein" title="benostein"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a>
|
<a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/rohannagpal"><img src="https://avatars.githubusercontent.com/u/4009239?v=4&s=48" width="48" height="48" alt="rohannagpal" title="rohannagpal"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/f-trycua"><img src="https://avatars.githubusercontent.com/u/195596869?v=4&s=48" width="48" height="48" alt="f-trycua" title="f-trycua"/></a> <a href="https://github.com/benostein"><img src="https://avatars.githubusercontent.com/u/31802821?v=4&s=48" width="48" height="48" alt="benostein" title="benostein"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a>
|
||||||
<a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a>
|
<a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a>
|
||||||
<a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/denysvitali"><img src="https://avatars.githubusercontent.com/u/4939519?v=4&s=48" width="48" height="48" alt="denysvitali" title="denysvitali"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/shakkernerd"><img src="https://avatars.githubusercontent.com/u/165377636?v=4&s=48" width="48" height="48" alt="shakkernerd" title="shakkernerd"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a>
|
<a href="https://github.com/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/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/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/denysvitali"><img src="https://avatars.githubusercontent.com/u/4939519?v=4&s=48" width="48" height="48" alt="denysvitali" title="denysvitali"/></a> <a href="https://github.com/shakkernerd"><img src="https://avatars.githubusercontent.com/u/165377636?v=4&s=48" width="48" height="48" alt="shakkernerd" title="shakkernerd"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/dominicnunez"><img src="https://avatars.githubusercontent.com/u/43616264?v=4&s=48" width="48" height="48" alt="dominicnunez" title="dominicnunez"/></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/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/AdeboyeDN"><img src="https://avatars.githubusercontent.com/u/65312338?v=4&s=48" width="48" height="48" alt="AdeboyeDN" title="AdeboyeDN"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/rhuanssauro"><img src="https://avatars.githubusercontent.com/u/164682191?v=4&s=48" width="48" height="48" alt="rhuanssauro" title="rhuanssauro"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a>
|
<a href="https://github.com/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/AdeboyeDN"><img src="https://avatars.githubusercontent.com/u/65312338?v=4&s=48" width="48" height="48" alt="AdeboyeDN" title="AdeboyeDN"/></a> <a href="https://github.com/Alg0rix"><img src="https://avatars.githubusercontent.com/u/53804949?v=4&s=48" width="48" height="48" alt="Alg0rix" title="Alg0rix"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/rhuanssauro"><img src="https://avatars.githubusercontent.com/u/164682191?v=4&s=48" width="48" height="48" alt="rhuanssauro" title="rhuanssauro"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a>
|
||||||
<a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a>
|
<a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a>
|
||||||
<a href="https://github.com/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/kyleok"><img src="https://avatars.githubusercontent.com/u/58307870?v=4&s=48" width="48" height="48" alt="kyleok" title="kyleok"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/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/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/uos-status"><img src="https://avatars.githubusercontent.com/u/255712580?v=4&s=48" width="48" height="48" alt="uos-status" title="uos-status"/></a>
|
<a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/kyleok"><img src="https://avatars.githubusercontent.com/u/58307870?v=4&s=48" width="48" height="48" alt="kyleok" title="kyleok"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/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/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/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/JonUleis"><img src="https://avatars.githubusercontent.com/u/7644941?v=4&s=48" width="48" height="48" alt="JonUleis" title="JonUleis"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/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/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/uos-status"><img src="https://avatars.githubusercontent.com/u/255712580?v=4&s=48" width="48" height="48" alt="uos-status" title="uos-status"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/JonUleis"><img src="https://avatars.githubusercontent.com/u/7644941?v=4&s=48" width="48" height="48" alt="JonUleis" title="JonUleis"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a>
|
||||||
<a href="https://github.com/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/pookNast"><img src="https://avatars.githubusercontent.com/u/14242552?v=4&s=48" width="48" height="48" alt="pookNast" title="pookNast"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a>
|
<a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/pookNast"><img src="https://avatars.githubusercontent.com/u/14242552?v=4&s=48" width="48" height="48" alt="pookNast" title="pookNast"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a>
|
||||||
<a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/dial481"><img src="https://avatars.githubusercontent.com/u/248182468?v=4&s=48" width="48" height="48" alt="dial481" title="dial481"/></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/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a>
|
||||||
|
<a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a>
|
||||||
<a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a>
|
<a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a>
|
||||||
<a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a>
|
<a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/search?q=Joshua%20Mitchell"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Joshua Mitchell" title="Joshua Mitchell"/></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/search?q=Joshua%20Mitchell"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Joshua Mitchell" title="Joshua Mitchell"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a>
|
<a href="https://github.com/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a>
|
||||||
<a href="https://github.com/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/dguido"><img src="https://avatars.githubusercontent.com/u/294844?v=4&s=48" width="48" height="48" alt="dguido" title="dguido"/></a> <a href="https://github.com/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/dguido"><img src="https://avatars.githubusercontent.com/u/294844?v=4&s=48" width="48" height="48" alt="dguido" title="dguido"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/rmorse"><img src="https://avatars.githubusercontent.com/u/853547?v=4&s=48" width="48" height="48" alt="rmorse" title="rmorse"/></a>
|
||||||
<a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/rmorse"><img src="https://avatars.githubusercontent.com/u/853547?v=4&s=48" width="48" height="48" alt="rmorse" title="rmorse"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a>
|
<a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/andreabadesso"><img src="https://avatars.githubusercontent.com/u/3586068?v=4&s=48" width="48" height="48" alt="andreabadesso" title="andreabadesso"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/EnzeD"><img src="https://avatars.githubusercontent.com/u/9866900?v=4&s=48" width="48" height="48" alt="EnzeD" title="EnzeD"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a>
|
||||||
<a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/andreabadesso"><img src="https://avatars.githubusercontent.com/u/3586068?v=4&s=48" width="48" height="48" alt="andreabadesso" title="andreabadesso"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/EnzeD"><img src="https://avatars.githubusercontent.com/u/9866900?v=4&s=48" width="48" height="48" alt="EnzeD" title="EnzeD"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a>
|
<a href="https://github.com/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/itsjaydesu"><img src="https://avatars.githubusercontent.com/u/220390?v=4&s=48" width="48" height="48" alt="itsjaydesu" title="itsjaydesu"/></a> <a href="https://github.com/ivancasco"><img src="https://avatars.githubusercontent.com/u/2452858?v=4&s=48" width="48" height="48" alt="ivancasco" title="ivancasco"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a>
|
||||||
<a href="https://github.com/itsjaydesu"><img src="https://avatars.githubusercontent.com/u/220390?v=4&s=48" width="48" height="48" alt="itsjaydesu" title="itsjaydesu"/></a> <a href="https://github.com/ivancasco"><img src="https://avatars.githubusercontent.com/u/2452858?v=4&s=48" width="48" height="48" alt="ivancasco" title="ivancasco"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a>
|
<a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/search?q=Pocket%20Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Pocket Clawd" title="Pocket 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/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></a>
|
||||||
<a href="https://github.com/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/search?q=Pocket%20Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Pocket Clawd" title="Pocket 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/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></a>
|
<a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/abhaymundhara"><img src="https://avatars.githubusercontent.com/u/62872231?v=4&s=48" width="48" height="48" alt="abhaymundhara" title="abhaymundhara"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/Alex-Alaniz"><img src="https://avatars.githubusercontent.com/u/88956822?v=4&s=48" width="48" height="48" alt="Alex-Alaniz" title="Alex-Alaniz"/></a> <a href="https://github.com/alexstyl"><img src="https://avatars.githubusercontent.com/u/1665273?v=4&s=48" width="48" height="48" alt="alexstyl" title="alexstyl"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/arthyn"><img src="https://avatars.githubusercontent.com/u/5466421?v=4&s=48" width="48" height="48" alt="arthyn" title="arthyn"/></a>
|
||||||
<a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/abhaymundhara"><img src="https://avatars.githubusercontent.com/u/62872231?v=4&s=48" width="48" height="48" alt="abhaymundhara" title="abhaymundhara"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/Alex-Alaniz"><img src="https://avatars.githubusercontent.com/u/88956822?v=4&s=48" width="48" height="48" alt="Alex-Alaniz" title="Alex-Alaniz"/></a> <a href="https://github.com/alexstyl"><img src="https://avatars.githubusercontent.com/u/1665273?v=4&s=48" width="48" height="48" alt="alexstyl" title="alexstyl"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/arthyn"><img src="https://avatars.githubusercontent.com/u/5466421?v=4&s=48" width="48" height="48" alt="arthyn" title="arthyn"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a>
|
<a href="https://github.com/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/chenyuan99"><img src="https://avatars.githubusercontent.com/u/25518100?v=4&s=48" width="48" height="48" alt="chenyuan99" title="chenyuan99"/></a> <a href="https://github.com/search?q=Clawdbot%20Maintainers"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawdbot Maintainers" title="Clawdbot Maintainers"/></a> <a href="https://github.com/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/dasilva333"><img src="https://avatars.githubusercontent.com/u/947827?v=4&s=48" width="48" height="48" alt="dasilva333" title="dasilva333"/></a> <a href="https://github.com/David-Marsh-Photo"><img src="https://avatars.githubusercontent.com/u/228404527?v=4&s=48" width="48" height="48" alt="David-Marsh-Photo" title="David-Marsh-Photo"/></a> <a href="https://github.com/search?q=Developer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Developer" title="Developer"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a>
|
||||||
<a href="https://github.com/search?q=Clawdbot%20Maintainers"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawdbot Maintainers" title="Clawdbot Maintainers"/></a> <a href="https://github.com/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/dasilva333"><img src="https://avatars.githubusercontent.com/u/947827?v=4&s=48" width="48" height="48" alt="dasilva333" title="dasilva333"/></a> <a href="https://github.com/search?q=Developer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Developer" title="Developer"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/foeken"><img src="https://avatars.githubusercontent.com/u/13864?v=4&s=48" width="48" height="48" alt="foeken" title="foeken"/></a> <a href="https://github.com/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a>
|
<a href="https://github.com/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/foeken"><img src="https://avatars.githubusercontent.com/u/13864?v=4&s=48" width="48" height="48" alt="foeken" title="foeken"/></a> <a href="https://github.com/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/grrowl"><img src="https://avatars.githubusercontent.com/u/907140?v=4&s=48" width="48" height="48" alt="grrowl" title="grrowl"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a>
|
||||||
<a href="https://github.com/grrowl"><img src="https://avatars.githubusercontent.com/u/907140?v=4&s=48" width="48" height="48" alt="grrowl" title="grrowl"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/kentaro"><img src="https://avatars.githubusercontent.com/u/3458?v=4&s=48" width="48" height="48" alt="kentaro" title="kentaro"/></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/search?q=Jane"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jane" title="Jane"/></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/kentaro"><img src="https://avatars.githubusercontent.com/u/3458?v=4&s=48" width="48" height="48" alt="kentaro" title="kentaro"/></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/Kiwitwitter"><img src="https://avatars.githubusercontent.com/u/25277769?v=4&s=48" width="48" height="48" alt="Kiwitwitter" title="Kiwitwitter"/></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/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/louzhixian"><img src="https://avatars.githubusercontent.com/u/7994361?v=4&s=48" width="48" height="48" alt="louzhixian" title="louzhixian"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a> <a href="https://github.com/mertcicekci0"><img src="https://avatars.githubusercontent.com/u/179321902?v=4&s=48" width="48" height="48" alt="mertcicekci0" title="mertcicekci0"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a>
|
<a href="https://github.com/louzhixian"><img src="https://avatars.githubusercontent.com/u/7994361?v=4&s=48" width="48" height="48" alt="louzhixian" title="louzhixian"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a> <a href="https://github.com/mertcicekci0"><img src="https://avatars.githubusercontent.com/u/179321902?v=4&s=48" width="48" height="48" alt="mertcicekci0" title="mertcicekci0"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a>
|
||||||
<a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/Noctivoro"><img src="https://avatars.githubusercontent.com/u/183974570?v=4&s=48" width="48" height="48" alt="Noctivoro" title="Noctivoro"/></a> <a href="https://github.com/ppamment"><img src="https://avatars.githubusercontent.com/u/2122919?v=4&s=48" width="48" height="48" alt="ppamment" title="ppamment"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a>
|
<a href="https://github.com/Noctivoro"><img src="https://avatars.githubusercontent.com/u/183974570?v=4&s=48" width="48" height="48" alt="Noctivoro" title="Noctivoro"/></a> <a href="https://github.com/ppamment"><img src="https://avatars.githubusercontent.com/u/2122919?v=4&s=48" width="48" height="48" alt="ppamment" title="ppamment"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/senoldogann"><img src="https://avatars.githubusercontent.com/u/45736551?v=4&s=48" width="48" height="48" alt="senoldogann" title="senoldogann"/></a>
|
||||||
<a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/senoldogann"><img src="https://avatars.githubusercontent.com/u/45736551?v=4&s=48" width="48" height="48" alt="senoldogann" title="senoldogann"/></a> <a href="https://github.com/Seredeep"><img src="https://avatars.githubusercontent.com/u/22802816?v=4&s=48" width="48" height="48" alt="Seredeep" title="Seredeep"/></a> <a href="https://github.com/sergical"><img src="https://avatars.githubusercontent.com/u/3760543?v=4&s=48" width="48" height="48" alt="sergical" title="sergical"/></a> <a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></a> <a href="https://github.com/shiyuanhai"><img src="https://avatars.githubusercontent.com/u/1187370?v=4&s=48" width="48" height="48" alt="shiyuanhai" title="shiyuanhai"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a>
|
<a href="https://github.com/Seredeep"><img src="https://avatars.githubusercontent.com/u/22802816?v=4&s=48" width="48" height="48" alt="Seredeep" title="Seredeep"/></a> <a href="https://github.com/sergical"><img src="https://avatars.githubusercontent.com/u/3760543?v=4&s=48" width="48" height="48" alt="sergical" title="sergical"/></a> <a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></a> <a href="https://github.com/shiyuanhai"><img src="https://avatars.githubusercontent.com/u/1187370?v=4&s=48" width="48" height="48" alt="shiyuanhai" title="shiyuanhai"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/Suksham-sharma"><img src="https://avatars.githubusercontent.com/u/94667656?v=4&s=48" width="48" height="48" alt="Suksham-sharma" title="Suksham-sharma"/></a> <a href="https://github.com/search?q=techboss"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="techboss" title="techboss"/></a> <a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a>
|
||||||
<a href="https://github.com/Suksham-sharma"><img src="https://avatars.githubusercontent.com/u/94667656?v=4&s=48" width="48" height="48" alt="Suksham-sharma" title="Suksham-sharma"/></a> <a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a>
|
<a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/search?q=wolfred"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="wolfred" title="wolfred"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/search?q=ymat19"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ymat19" title="ymat19"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a>
|
||||||
<a href="https://github.com/search?q=ymat19"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ymat19" title="ymat19"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/0xJonHoldsCrypto"><img src="https://avatars.githubusercontent.com/u/81202085?v=4&s=48" width="48" height="48" alt="0xJonHoldsCrypto" title="0xJonHoldsCrypto"/></a> <a href="https://github.com/aaronn"><img src="https://avatars.githubusercontent.com/u/1653630?v=4&s=48" width="48" height="48" alt="aaronn" title="aaronn"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/atalovesyou"><img src="https://avatars.githubusercontent.com/u/3534502?v=4&s=48" width="48" height="48" alt="atalovesyou" title="atalovesyou"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a>
|
<a href="https://github.com/0xJonHoldsCrypto"><img src="https://avatars.githubusercontent.com/u/81202085?v=4&s=48" width="48" height="48" alt="0xJonHoldsCrypto" title="0xJonHoldsCrypto"/></a> <a href="https://github.com/aaronn"><img src="https://avatars.githubusercontent.com/u/1653630?v=4&s=48" width="48" height="48" alt="aaronn" title="aaronn"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/atalovesyou"><img src="https://avatars.githubusercontent.com/u/3534502?v=4&s=48" width="48" height="48" alt="atalovesyou" title="atalovesyou"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/hougangdev"><img src="https://avatars.githubusercontent.com/u/105773686?v=4&s=48" width="48" height="48" alt="hougangdev" title="hougangdev"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a>
|
||||||
<a href="https://github.com/hougangdev"><img src="https://avatars.githubusercontent.com/u/105773686?v=4&s=48" width="48" height="48" alt="hougangdev" title="hougangdev"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a>
|
<a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||||
<a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
@@ -13,6 +13,10 @@ For threat model + hardening guidance (including `clawdbot security audit --deep
|
|||||||
|
|
||||||
- `https://docs.clawd.bot/gateway/security`
|
- `https://docs.clawd.bot/gateway/security`
|
||||||
|
|
||||||
|
### Web Interface Safety
|
||||||
|
|
||||||
|
Clawdbot's web interface is intended for local use only. Do **not** bind it to the public internet; it is not hardened for public exposure.
|
||||||
|
|
||||||
## Runtime Requirements
|
## Runtime Requirements
|
||||||
|
|
||||||
### Node.js Version
|
### Node.js Version
|
||||||
|
|||||||
@@ -83,7 +83,10 @@ enum CommandResolver {
|
|||||||
"/usr/bin",
|
"/usr/bin",
|
||||||
"/bin",
|
"/bin",
|
||||||
]
|
]
|
||||||
|
#if DEBUG
|
||||||
|
// Dev-only convenience. Avoid project-local PATH hijacking in release builds.
|
||||||
extras.insert(projectRoot.appendingPathComponent("node_modules/.bin").path, at: 0)
|
extras.insert(projectRoot.appendingPathComponent("node_modules/.bin").path, at: 0)
|
||||||
|
#endif
|
||||||
let clawdbotPaths = self.clawdbotManagedPaths(home: home)
|
let clawdbotPaths = self.clawdbotManagedPaths(home: home)
|
||||||
if !clawdbotPaths.isEmpty {
|
if !clawdbotPaths.isEmpty {
|
||||||
extras.insert(contentsOf: clawdbotPaths, at: 1)
|
extras.insert(contentsOf: clawdbotPaths, at: 1)
|
||||||
@@ -189,9 +192,13 @@ enum CommandResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func projectClawdbotExecutable(projectRoot: URL? = nil) -> String? {
|
static func projectClawdbotExecutable(projectRoot: URL? = nil) -> String? {
|
||||||
|
#if DEBUG
|
||||||
let root = projectRoot ?? self.projectRoot()
|
let root = projectRoot ?? self.projectRoot()
|
||||||
let candidate = root.appendingPathComponent("node_modules/.bin").appendingPathComponent(self.helperName).path
|
let candidate = root.appendingPathComponent("node_modules/.bin").appendingPathComponent(self.helperName).path
|
||||||
return FileManager().isExecutableFile(atPath: candidate) ? candidate : nil
|
return FileManager().isExecutableFile(atPath: candidate) ? candidate : nil
|
||||||
|
#else
|
||||||
|
return nil
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
static func nodeCliPath() -> String? {
|
static func nodeCliPath() -> String? {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public struct ClawdbotChatView: View {
|
|||||||
@State private var showSessions = false
|
@State private var showSessions = false
|
||||||
@State private var hasPerformedInitialScroll = false
|
@State private var hasPerformedInitialScroll = false
|
||||||
@State private var isPinnedToBottom = true
|
@State private var isPinnedToBottom = true
|
||||||
|
@State private var lastUserMessageID: UUID?
|
||||||
private let showsSessionSwitcher: Bool
|
private let showsSessionSwitcher: Bool
|
||||||
private let style: Style
|
private let style: Style
|
||||||
private let markdownVariant: ChatMarkdownVariant
|
private let markdownVariant: ChatMarkdownVariant
|
||||||
@@ -132,8 +133,28 @@ public struct ClawdbotChatView: View {
|
|||||||
self.hasPerformedInitialScroll = false
|
self.hasPerformedInitialScroll = false
|
||||||
self.isPinnedToBottom = true
|
self.isPinnedToBottom = true
|
||||||
}
|
}
|
||||||
|
.onChange(of: self.viewModel.isSending) { _, isSending in
|
||||||
|
// Scroll to bottom when user sends a message, even if scrolled up.
|
||||||
|
guard isSending, self.hasPerformedInitialScroll else { return }
|
||||||
|
self.isPinnedToBottom = true
|
||||||
|
withAnimation(.snappy(duration: 0.22)) {
|
||||||
|
self.scrollPosition = self.scrollerBottomID
|
||||||
|
}
|
||||||
|
}
|
||||||
.onChange(of: self.viewModel.messages.count) { _, _ in
|
.onChange(of: self.viewModel.messages.count) { _, _ in
|
||||||
guard self.hasPerformedInitialScroll, self.isPinnedToBottom else { return }
|
guard self.hasPerformedInitialScroll else { return }
|
||||||
|
if let lastMessage = self.viewModel.messages.last,
|
||||||
|
lastMessage.role.lowercased() == "user",
|
||||||
|
lastMessage.id != self.lastUserMessageID {
|
||||||
|
self.lastUserMessageID = lastMessage.id
|
||||||
|
self.isPinnedToBottom = true
|
||||||
|
withAnimation(.snappy(duration: 0.22)) {
|
||||||
|
self.scrollPosition = self.scrollerBottomID
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard self.isPinnedToBottom else { return }
|
||||||
withAnimation(.snappy(duration: 0.22)) {
|
withAnimation(.snappy(duration: 0.22)) {
|
||||||
self.scrollPosition = self.scrollerBottomID
|
self.scrollPosition = self.scrollerBottomID
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export default defineConfig({
|
|||||||
output: {
|
output: {
|
||||||
file: outputFile,
|
file: outputFile,
|
||||||
format: "esm",
|
format: "esm",
|
||||||
inlineDynamicImports: true,
|
codeSplitting: false,
|
||||||
sourcemap: false,
|
sourcemap: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -168,8 +168,7 @@
|
|||||||
<h2>Getting started</h2>
|
<h2>Getting started</h2>
|
||||||
<p>
|
<p>
|
||||||
If you see a red <code>!</code> badge on the extension icon, the relay server is not reachable.
|
If you see a red <code>!</code> badge on the extension icon, the relay server is not reachable.
|
||||||
Start Clawdbot’s browser relay on this machine (Gateway or <code>clawdbot browser serve</code>),
|
Start Clawdbot’s browser relay on this machine (Gateway or node host), then click the toolbar button again.
|
||||||
then click the toolbar button again.
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Full guide (install, remote Gateway, security): <a href="https://docs.clawd.bot/tools/chrome-extension" target="_blank" rel="noreferrer">docs.clawd.bot/tools/chrome-extension</a>
|
Full guide (install, remote Gateway, security): <a href="https://docs.clawd.bot/tools/chrome-extension" target="_blank" rel="noreferrer">docs.clawd.bot/tools/chrome-extension</a>
|
||||||
|
|||||||
-1
@@ -1 +0,0 @@
|
|||||||
@import"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap";:root{--bg: #0c0d12;--bg-accent: #0d0e14;--bg-elevated: #181a21;--bg-hover: #252830;--bg-muted: #252830;--card: #13151c;--card-foreground: #f8fafc;--card-highlight: rgba(255, 255, 255, .04);--popover: #13151c;--popover-foreground: #f8fafc;--panel: #0c0d12;--panel-strong: #181a21;--panel-hover: #252830;--chrome: rgba(12, 13, 18, .95);--chrome-strong: rgba(12, 13, 18, .98);--text: #f8fafc;--text-strong: #ffffff;--chat-text: #f8fafc;--muted: #94a3b8;--muted-strong: #64748b;--muted-foreground: #94a3b8;--border: #333842;--border-strong: #454d5c;--border-hover: #5a6373;--input: #333842;--ring: #ff4d4d;--accent: #ff4d4d;--accent-hover: #ff6666;--accent-muted: #ff4d4d;--accent-subtle: rgba(255, 77, 77, .12);--accent-foreground: #f8fafc;--primary: #ff4d4d;--primary-foreground: #ffffff;--secondary: #252830;--secondary-foreground: #f8fafc;--accent-2: #3b82f6;--accent-2-muted: rgba(59, 130, 246, .7);--ok: #22c55e;--ok-muted: rgba(34, 197, 94, .7);--ok-subtle: rgba(34, 197, 94, .1);--destructive: #ef4444;--destructive-foreground: #fafafa;--warn: #eab308;--warn-muted: rgba(234, 179, 8, .7);--warn-subtle: rgba(234, 179, 8, .1);--danger: #ef4444;--danger-muted: rgba(239, 68, 68, .7);--danger-subtle: rgba(239, 68, 68, .1);--info: #3b82f6;--focus: rgba(255, 77, 77, .2);--focus-ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring);--grid-line: rgba(255, 255, 255, .03);--theme-switch-x: 50%;--theme-switch-y: 50%;--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;--font-body: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;--font-display: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, .05);--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, .1), 0 2px 4px -2px rgba(0, 0, 0, .1);--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, .1), 0 4px 6px -4px rgba(0, 0, 0, .1);--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, .1), 0 8px 10px -6px rgba(0, 0, 0, .1);--radius-sm: 4px;--radius-md: 6px;--radius-lg: 8px;--radius-xl: 12px;--radius-full: 9999px;--radius: 6px;--ease-out: cubic-bezier(.16, 1, .3, 1);--ease-in-out: cubic-bezier(.4, 0, .2, 1);--duration-fast: .15s;--duration-normal: .2s;--duration-slow: .3s;color-scheme:dark}:root[data-theme=light]{--bg: #f8f8f7;--bg-accent: #f3f2f0;--bg-elevated: #ffffff;--bg-hover: #eae8e6;--bg-muted: #eae8e6;--bg-content: #f0efed;--card: #ffffff;--card-foreground: #1c1917;--card-highlight: rgba(0, 0, 0, .04);--popover: #ffffff;--popover-foreground: #1c1917;--panel: #f8f8f7;--panel-strong: #f0efed;--panel-hover: #e5e3e1;--chrome: rgba(248, 248, 247, .95);--chrome-strong: rgba(248, 248, 247, .98);--text: #44403c;--text-strong: #292524;--chat-text: #44403c;--muted: #5c5856;--muted-strong: #44403c;--muted-foreground: #5c5856;--border: #e0dedc;--border-strong: #d6d3d1;--border-hover: #a8a5a0;--input: #e0dedc;--accent: #b91c1c;--accent-hover: #dc2626;--accent-muted: #b91c1c;--accent-subtle: rgba(185, 28, 28, .18);--accent-foreground: #ffffff;--primary: #b91c1c;--primary-foreground: #ffffff;--secondary: #eae8e6;--secondary-foreground: #44403c;--ok: #15803d;--ok-muted: rgba(21, 128, 61, .75);--ok-subtle: rgba(21, 128, 61, .12);--destructive: #b91c1c;--destructive-foreground: #fafafa;--warn: #a16207;--warn-muted: rgba(161, 98, 7, .75);--warn-subtle: rgba(161, 98, 7, .12);--danger: #b91c1c;--danger-muted: rgba(185, 28, 28, .75);--danger-subtle: rgba(185, 28, 28, .12);--info: #1d4ed8;--focus: rgba(185, 28, 28, .25);--grid-line: rgba(0, 0, 0, .06);color-scheme:light}*{box-sizing:border-box}html,body{height:100%}body{margin:0;font:400 14px/1.5 var(--font-body);letter-spacing:-.011em;background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}@keyframes theme-circle-transition{0%{clip-path:circle(0% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}to{clip-path:circle(150% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}}html.theme-transition{view-transition-name:theme}html.theme-transition::view-transition-old(theme){mix-blend-mode:normal;animation:none;z-index:1}html.theme-transition::view-transition-new(theme){mix-blend-mode:normal;z-index:2;animation:theme-circle-transition .4s var(--ease-out) forwards}@media(prefers-reduced-motion:reduce){html.theme-transition::view-transition-old(theme),html.theme-transition::view-transition-new(theme){animation:none!important}}clawdbot-app{display:block;position:relative;z-index:1;min-height:100vh}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}button,input,textarea,select{font:inherit;color:inherit}::selection{background:var(--accent-subtle);color:var(--text-strong)}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:var(--radius-full)}::-webLine truncated
|
|
||||||
-3119
File diff suppressed because it is too large.
Load diff
@@ -1 +0,0 @@
|
|||||||
{"version":3,"file":"index-DQcOTEYz.js","sources":["../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/css-tag.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/reactive-element.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/lit-html.js","../../../node_modules/.pnpm/lit-element@4.2.2/node_modules/lit-element/lit-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/custom-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/property.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/state.js","../../../ui/src/ui/assistant-identity.ts","../../../ui/src/ui/storage.ts","../../../src/sessions/session-key-utils.ts","../../../ui/src/ui/navigation.ts","../../../ui/src/ui/icons.ts","../../../src/shared/text/reasoning-tags.ts","../../../ui/src/ui/format.ts","../../../ui/src/ui/chat/message-extract.ts","../../../ui/src/ui/uuid.ts","../../../ui/src/ui/controllers/chat.ts","../../../ui/src/ui/controllers/sessions.ts","../../../ui/src/ui/app-tool-stream.ts","../../../ui/src/ui/app-scroll.ts","../../../ui/src/ui/controllers/config/form-utils.ts","../../../ui/src/ui/controllers/config.ts","../../../ui/src/ui/controllers/cron.ts","../../../ui/src/ui/controllers/channels.ts","../../../ui/src/ui/controllers/debug.ts","../../../ui/src/ui/controllers/logs.ts","../../../node_modules/.pnpm/@noble+ed25519@3.0.0/node_modules/@noble/ed25519/index.js","../../../ui/src/ui/device-identity.ts","../../../ui/src/ui/device-auth.ts","../../../ui/src/ui/controllers/devices.ts","../../../ui/src/ui/controllers/nodes.ts","../../../ui/src/ui/controllers/exec-approvals.ts","../../../ui/src/ui/controllers/presence.ts","../../../ui/src/ui/controllers/skills.ts","../../../ui/src/ui/theme.ts","../../../ui/src/ui/theme-transition.ts","../../../ui/src/ui/app-polling.ts","../../../ui/src/ui/app-settings.ts","../../../ui/src/ui/app-chat.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive-helpers.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/repeat.js","../../../ui/src/ui/chat/message-normalizer.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/unsafe-html.js","../../../node_modules/.pnpm/dompurify@3.3.1/node_modules/dompurify/dist/purify.es.mjs","../../../node_modules/.pnpm/marked@17.0.1/node_modules/marked/lib/marked.esm.js","../../../ui/src/ui/markdown.ts","../../../ui/src/ui/chat/copy-as-markdown.ts","../../../ui/src/ui/tool-display.ts","../../../ui/src/ui/chat/constants.ts","../../../ui/src/ui/chat/tool-helpers.ts","../../../ui/src/ui/chat/tool-cards.ts","../../../ui/src/ui/chat/grouped-render.ts","../../../ui/src/ui/views/markdown-sidebar.ts","../../../ui/src/ui/components/resizable-divider.ts","../../../ui/src/ui/views/chat.ts","../../../ui/src/ui/views/config-form.shared.ts","../../../ui/src/ui/views/config-form.node.ts","../../../ui/src/ui/views/config-form.render.ts","../../../ui/src/ui/views/config-form.analyze.ts","../../../ui/src/ui/views/config.ts","../../../ui/src/ui/views/channels.shared.ts","../../../ui/src/ui/views/channels.config.ts","../../../ui/src/ui/views/channels.discord.ts","../../../ui/src/ui/views/channels.googlechat.ts","../../../ui/src/ui/views/channels.imessage.ts","../../../ui/src/ui/views/channels.nostr-profile-form.ts","../../../ui/src/ui/views/channels.nostr.ts","../../../ui/src/ui/views/channels.signal.ts","../../../ui/src/ui/views/channels.slack.ts","../../../ui/src/ui/views/channels.telegram.ts","../../../ui/src/ui/views/channels.whatsapp.ts","../../../ui/src/ui/views/channels.ts","../../../ui/src/ui/presenter.ts","../../../ui/src/ui/views/cron.ts","../../../ui/src/ui/views/debug.ts","../../../ui/src/ui/views/instances.ts","../../../ui/src/ui/views/logs.ts","../../../ui/src/ui/views/nodes.ts","../../../ui/src/ui/views/overview.ts","../../../ui/src/ui/views/sessions.ts","../../../ui/src/ui/views/exec-approval.ts","../../../ui/src/ui/views/skills.ts","../../../ui/src/ui/app-render.helpers.ts","../../../ui/src/ui/app-render.ts","../../../ui/src/ui/app-defaults.ts","../../../ui/src/ui/controllers/agents.ts","../../../src/gateway/protocol/client-info.ts","../../../src/gateway/device-auth.ts","../../../ui/src/ui/gateway.ts","../../../ui/src/ui/controllers/exec-approval.ts","../../../ui/src/ui/controllers/assistant-identity.ts","../../../ui/src/ui/app-gateway.ts","../../../ui/src/ui/app-lifecycle.ts","../../../ui/src/ui/app-channels.ts","../../../ui/src/ui/app.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */\nconst t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&\"adoptedStyLine truncated
|
|
||||||
Vendored
-15
@@ -1,15 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Clawdbot Control</title>
|
|
||||||
<meta name="color-scheme" content="dark light" />
|
|
||||||
<link rel="icon" href="./favicon.ico" sizes="any" />
|
|
||||||
<script type="module" crossorigin src="./assets/index-DQcOTEYz.js"></script>
|
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-08nzABV3.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<clawdbot-app></clawdbot-app>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -115,6 +115,9 @@ body::after {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.shell {
|
.shell {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
padding: 22px 16px 10px;
|
padding: 22px 16px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -218,6 +218,7 @@ Prefer `chat_guid` for stable routing:
|
|||||||
## Security
|
## Security
|
||||||
- Webhook requests are authenticated by comparing `guid`/`password` query params or headers against `channels.bluebubbles.password`. Requests from `localhost` are also accepted.
|
- Webhook requests are authenticated by comparing `guid`/`password` query params or headers against `channels.bluebubbles.password`. Requests from `localhost` are also accepted.
|
||||||
- Keep the API password and webhook endpoint secret (treat them like credentials).
|
- Keep the API password and webhook endpoint secret (treat them like credentials).
|
||||||
|
- Localhost trust means a same-host reverse proxy can unintentionally bypass the password. If you proxy the gateway, require auth at the proxy and configure `gateway.trustedProxies`. See [Gateway security](/gateway/security#reverse-proxy-configuration).
|
||||||
- Enable HTTPS + firewall rules on the BlueBubbles server if exposing it outside your LAN.
|
- Enable HTTPS + firewall rules on the BlueBubbles server if exposing it outside your LAN.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|||||||
@@ -298,8 +298,12 @@ ack reaction after the bot replies.
|
|||||||
- `guilds."*"`: default per-guild settings applied when no explicit entry exists.
|
- `guilds."*"`: default per-guild settings applied when no explicit entry exists.
|
||||||
- `guilds.<id>.slug`: optional friendly slug used for display names.
|
- `guilds.<id>.slug`: optional friendly slug used for display names.
|
||||||
- `guilds.<id>.users`: optional per-guild user allowlist (ids or names).
|
- `guilds.<id>.users`: optional per-guild user allowlist (ids or names).
|
||||||
|
- `guilds.<id>.tools`: optional per-guild tool policy overrides (`allow`/`deny`/`alsoAllow`) used when the channel override is missing.
|
||||||
|
- `guilds.<id>.toolsBySender`: optional per-sender tool policy overrides at the guild level (applies when the channel override is missing; `"*"` wildcard supported).
|
||||||
- `guilds.<id>.channels.<channel>.allow`: allow/deny the channel when `groupPolicy="allowlist"`.
|
- `guilds.<id>.channels.<channel>.allow`: allow/deny the channel when `groupPolicy="allowlist"`.
|
||||||
- `guilds.<id>.channels.<channel>.requireMention`: mention gating for the channel.
|
- `guilds.<id>.channels.<channel>.requireMention`: mention gating for the channel.
|
||||||
|
- `guilds.<id>.channels.<channel>.tools`: optional per-channel tool policy overrides (`allow`/`deny`/`alsoAllow`).
|
||||||
|
- `guilds.<id>.channels.<channel>.toolsBySender`: optional per-sender tool policy overrides within the channel (`"*"` wildcard supported).
|
||||||
- `guilds.<id>.channels.<channel>.users`: optional per-channel user allowlist.
|
- `guilds.<id>.channels.<channel>.users`: optional per-channel user allowlist.
|
||||||
- `guilds.<id>.channels.<channel>.skills`: skill filter (omit = all skills, empty = none).
|
- `guilds.<id>.channels.<channel>.skills`: skill filter (omit = all skills, empty = none).
|
||||||
- `guilds.<id>.channels.<channel>.systemPrompt`: extra system prompt for the channel (combined with channel topic).
|
- `guilds.<id>.channels.<channel>.systemPrompt`: extra system prompt for the channel (combined with channel topic).
|
||||||
|
|||||||
@@ -421,8 +421,12 @@ Key settings (see `/gateway/configuration` for shared channel patterns):
|
|||||||
- `channels.msteams.replyStyle`: `thread | top-level` (see [Reply Style](#reply-style-threads-vs-posts)).
|
- `channels.msteams.replyStyle`: `thread | top-level` (see [Reply Style](#reply-style-threads-vs-posts)).
|
||||||
- `channels.msteams.teams.<teamId>.replyStyle`: per-team override.
|
- `channels.msteams.teams.<teamId>.replyStyle`: per-team override.
|
||||||
- `channels.msteams.teams.<teamId>.requireMention`: per-team override.
|
- `channels.msteams.teams.<teamId>.requireMention`: per-team override.
|
||||||
|
- `channels.msteams.teams.<teamId>.tools`: default per-team tool policy overrides (`allow`/`deny`/`alsoAllow`) used when a channel override is missing.
|
||||||
|
- `channels.msteams.teams.<teamId>.toolsBySender`: default per-team per-sender tool policy overrides (`"*"` wildcard supported).
|
||||||
- `channels.msteams.teams.<teamId>.channels.<conversationId>.replyStyle`: per-channel override.
|
- `channels.msteams.teams.<teamId>.channels.<conversationId>.replyStyle`: per-channel override.
|
||||||
- `channels.msteams.teams.<teamId>.channels.<conversationId>.requireMention`: per-channel override.
|
- `channels.msteams.teams.<teamId>.channels.<conversationId>.requireMention`: per-channel override.
|
||||||
|
- `channels.msteams.teams.<teamId>.channels.<conversationId>.tools`: per-channel tool policy overrides (`allow`/`deny`/`alsoAllow`).
|
||||||
|
- `channels.msteams.teams.<teamId>.channels.<conversationId>.toolsBySender`: per-channel per-sender tool policy overrides (`"*"` wildcard supported).
|
||||||
- `channels.msteams.sharePointSiteId`: SharePoint site ID for file uploads in group chats/channels (see [Sending files in group chats](#sending-files-in-group-chats)).
|
- `channels.msteams.sharePointSiteId`: SharePoint site ID for file uploads in group chats/channels (see [Sending files in group chats](#sending-files-in-group-chats)).
|
||||||
|
|
||||||
## Routing & Sessions
|
## Routing & Sessions
|
||||||
|
|||||||
@@ -464,6 +464,8 @@ For fine-grained control, use these tags in agent responses:
|
|||||||
Channel options (`channels.slack.channels.<id>` or `channels.slack.channels.<name>`):
|
Channel options (`channels.slack.channels.<id>` or `channels.slack.channels.<name>`):
|
||||||
- `allow`: allow/deny the channel when `groupPolicy="allowlist"`.
|
- `allow`: allow/deny the channel when `groupPolicy="allowlist"`.
|
||||||
- `requireMention`: mention gating for the channel.
|
- `requireMention`: mention gating for the channel.
|
||||||
|
- `tools`: optional per-channel tool policy overrides (`allow`/`deny`/`alsoAllow`).
|
||||||
|
- `toolsBySender`: optional per-sender tool policy overrides within the channel (keys are sender ids/@handles/emails; `"*"` wildcard supported).
|
||||||
- `allowBots`: allow bot-authored messages in this channel (default: false).
|
- `allowBots`: allow bot-authored messages in this channel (default: false).
|
||||||
- `users`: optional per-channel user allowlist.
|
- `users`: optional per-channel user allowlist.
|
||||||
- `skills`: skill filter (omit = all skills, empty = none).
|
- `skills`: skill filter (omit = all skills, empty = none).
|
||||||
|
|||||||
+129
-1
@@ -383,6 +383,133 @@ For message tool sends, set `asVoice: true` with a voice-compatible audio `media
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Stickers
|
||||||
|
|
||||||
|
Clawdbot supports receiving and sending Telegram stickers with intelligent caching.
|
||||||
|
|
||||||
|
### Receiving stickers
|
||||||
|
|
||||||
|
When a user sends a sticker, Clawdbot handles it based on the sticker type:
|
||||||
|
|
||||||
|
- **Static stickers (WEBP):** Downloaded and processed through vision. The sticker appears as a `<media:sticker>` placeholder in the message content.
|
||||||
|
- **Animated stickers (TGS):** Skipped (Lottie format not supported for processing).
|
||||||
|
- **Video stickers (WEBM):** Skipped (video format not supported for processing).
|
||||||
|
|
||||||
|
Template context field available when receiving stickers:
|
||||||
|
- `Sticker` — object with:
|
||||||
|
- `emoji` — emoji associated with the sticker
|
||||||
|
- `setName` — name of the sticker set
|
||||||
|
- `fileId` — Telegram file ID (send the same sticker back)
|
||||||
|
- `fileUniqueId` — stable ID for cache lookup
|
||||||
|
- `cachedDescription` — cached vision description when available
|
||||||
|
|
||||||
|
### Sticker cache
|
||||||
|
|
||||||
|
Stickers are processed through the AI's vision capabilities to generate descriptions. Since the same stickers are often sent repeatedly, Clawdbot caches these descriptions to avoid redundant API calls.
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
|
||||||
|
1. **First encounter:** The sticker image is sent to the AI for vision analysis. The AI generates a description (e.g., "A cartoon cat waving enthusiastically").
|
||||||
|
2. **Cache storage:** The description is saved along with the sticker's file ID, emoji, and set name.
|
||||||
|
3. **Subsequent encounters:** When the same sticker is seen again, the cached description is used directly. The image is not sent to the AI.
|
||||||
|
|
||||||
|
**Cache location:** `~/.clawdbot/telegram/sticker-cache.json`
|
||||||
|
|
||||||
|
**Cache entry format:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"fileId": "CAACAgIAAxkBAAI...",
|
||||||
|
"fileUniqueId": "AgADBAADb6cxG2Y",
|
||||||
|
"emoji": "👋",
|
||||||
|
"setName": "CoolCats",
|
||||||
|
"description": "A cartoon cat waving enthusiastically",
|
||||||
|
"cachedAt": "2026-01-15T10:30:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Reduces API costs by avoiding repeated vision calls for the same sticker
|
||||||
|
- Faster response times for cached stickers (no vision processing delay)
|
||||||
|
- Enables sticker search functionality based on cached descriptions
|
||||||
|
|
||||||
|
The cache is populated automatically as stickers are received. There is no manual cache management required.
|
||||||
|
|
||||||
|
### Sending stickers
|
||||||
|
|
||||||
|
The agent can send and search stickers using the `sticker` and `sticker-search` actions. These are disabled by default and must be enabled in config:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
telegram: {
|
||||||
|
actions: {
|
||||||
|
sticker: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Send a sticker:**
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
"action": "sticker",
|
||||||
|
"channel": "telegram",
|
||||||
|
"to": "123456789",
|
||||||
|
"fileId": "CAACAgIAAxkBAAI..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- `fileId` (required) — the Telegram file ID of the sticker. Obtain this from `Sticker.fileId` when receiving a sticker, or from a `sticker-search` result.
|
||||||
|
- `replyTo` (optional) — message ID to reply to.
|
||||||
|
- `threadId` (optional) — message thread ID for forum topics.
|
||||||
|
|
||||||
|
**Search for stickers:**
|
||||||
|
|
||||||
|
The agent can search cached stickers by description, emoji, or set name:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
"action": "sticker-search",
|
||||||
|
"channel": "telegram",
|
||||||
|
"query": "cat waving",
|
||||||
|
"limit": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns matching stickers from the cache:
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"count": 2,
|
||||||
|
"stickers": [
|
||||||
|
{
|
||||||
|
"fileId": "CAACAgIAAxkBAAI...",
|
||||||
|
"emoji": "👋",
|
||||||
|
"description": "A cartoon cat waving enthusiastically",
|
||||||
|
"setName": "CoolCats"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The search uses fuzzy matching across description text, emoji characters, and set names.
|
||||||
|
|
||||||
|
**Example with threading:**
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
"action": "sticker",
|
||||||
|
"channel": "telegram",
|
||||||
|
"to": "-1001234567890",
|
||||||
|
"fileId": "CAACAgIAAxkBAAI...",
|
||||||
|
"replyTo": 42,
|
||||||
|
"threadId": 123
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Streaming (drafts)
|
## Streaming (drafts)
|
||||||
Telegram can stream **draft bubbles** while the agent is generating a response.
|
Telegram can stream **draft bubbles** while the agent is generating a response.
|
||||||
Clawdbot uses Bot API `sendMessageDraft` (not real messages) and then sends the
|
Clawdbot uses Bot API `sendMessageDraft` (not real messages) and then sends the
|
||||||
@@ -420,7 +547,7 @@ Outbound Telegram API calls retry on transient network/429 errors with exponenti
|
|||||||
- Tool: `telegram` with `react` action (`chatId`, `messageId`, `emoji`).
|
- Tool: `telegram` with `react` action (`chatId`, `messageId`, `emoji`).
|
||||||
- Tool: `telegram` with `deleteMessage` action (`chatId`, `messageId`).
|
- Tool: `telegram` with `deleteMessage` action (`chatId`, `messageId`).
|
||||||
- Reaction removal semantics: see [/tools/reactions](/tools/reactions).
|
- Reaction removal semantics: see [/tools/reactions](/tools/reactions).
|
||||||
- Tool gating: `channels.telegram.actions.reactions`, `channels.telegram.actions.sendMessage`, `channels.telegram.actions.deleteMessage` (default: enabled).
|
- Tool gating: `channels.telegram.actions.reactions`, `channels.telegram.actions.sendMessage`, `channels.telegram.actions.deleteMessage` (default: enabled), and `channels.telegram.actions.sticker` (default: disabled).
|
||||||
|
|
||||||
## Reaction notifications
|
## Reaction notifications
|
||||||
|
|
||||||
@@ -537,6 +664,7 @@ Provider options:
|
|||||||
- `channels.telegram.actions.reactions`: gate Telegram tool reactions.
|
- `channels.telegram.actions.reactions`: gate Telegram tool reactions.
|
||||||
- `channels.telegram.actions.sendMessage`: gate Telegram tool message sends.
|
- `channels.telegram.actions.sendMessage`: gate Telegram tool message sends.
|
||||||
- `channels.telegram.actions.deleteMessage`: gate Telegram tool message deletes.
|
- `channels.telegram.actions.deleteMessage`: gate Telegram tool message deletes.
|
||||||
|
- `channels.telegram.actions.sticker`: gate Telegram sticker actions — send and search (default: false).
|
||||||
- `channels.telegram.reactionNotifications`: `off | own | all` — control which reactions trigger system events (default: `own` when not set).
|
- `channels.telegram.reactionNotifications`: `off | own | all` — control which reactions trigger system events (default: `own` when not set).
|
||||||
- `channels.telegram.reactionLevel`: `off | ack | minimal | extensive` — control agent's reaction capability (default: `minimal` when not set).
|
- `channels.telegram.reactionLevel`: `off | ack | minimal | extensive` — control agent's reaction capability (default: `minimal` when not set).
|
||||||
|
|
||||||
|
|||||||
+10
-12
@@ -1,8 +1,8 @@
|
|||||||
---
|
---
|
||||||
summary: "CLI reference for `clawdbot browser` (profiles, tabs, actions, extension relay, remote serve)"
|
summary: "CLI reference for `clawdbot browser` (profiles, tabs, actions, extension relay)"
|
||||||
read_when:
|
read_when:
|
||||||
- You use `clawdbot browser` and want examples for common tasks
|
- You use `clawdbot browser` and want examples for common tasks
|
||||||
- You want to control a remote browser via `browser.controlUrl`
|
- You want to control a browser running on another machine via a node host
|
||||||
- You want to use the Chrome extension relay (attach/detach via toolbar button)
|
- You want to use the Chrome extension relay (attach/detach via toolbar button)
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -16,8 +16,10 @@ Related:
|
|||||||
|
|
||||||
## Common flags
|
## Common flags
|
||||||
|
|
||||||
- `--url <controlUrl>`: override `browser.controlUrl` for this command invocation.
|
- `--url <gatewayWsUrl>`: Gateway WebSocket URL (defaults to config).
|
||||||
- `--browser-profile <name>`: choose a browser profile (default comes from config).
|
- `--token <token>`: Gateway token (if required).
|
||||||
|
- `--timeout <ms>`: request timeout (ms).
|
||||||
|
- `--browser-profile <name>`: choose a browser profile (default from config).
|
||||||
- `--json`: machine-readable output (where supported).
|
- `--json`: machine-readable output (where supported).
|
||||||
|
|
||||||
## Quick start (local)
|
## Quick start (local)
|
||||||
@@ -93,14 +95,10 @@ Then Chrome → `chrome://extensions` → enable “Developer mode” → “Loa
|
|||||||
|
|
||||||
Full guide: [Chrome extension](/tools/chrome-extension)
|
Full guide: [Chrome extension](/tools/chrome-extension)
|
||||||
|
|
||||||
## Remote browser control (`clawdbot browser serve`)
|
## Remote browser control (node host proxy)
|
||||||
|
|
||||||
If the Gateway runs on a different machine than the browser, run a standalone browser control server on the machine that runs Chrome:
|
If the Gateway runs on a different machine than the browser, run a **node host** on the machine that has Chrome/Brave/Edge/Chromium. The Gateway will proxy browser actions to that node (no separate browser control server required).
|
||||||
|
|
||||||
```bash
|
Use `gateway.nodes.browser.mode` to control auto-routing and `gateway.nodes.browser.node` to pin a specific node if multiple are connected.
|
||||||
clawdbot browser serve --bind 127.0.0.1 --port 18791 --token <token>
|
|
||||||
```
|
|
||||||
|
|
||||||
Then point the Gateway at it using `browser.controlUrl` + `browser.controlToken` (or `CLAWDBOT_BROWSER_CONTROL_TOKEN`).
|
Security + remote setup: [Browser tool](/tools/browser), [Remote access](/gateway/remote), [Tailscale](/gateway/tailscale), [Security](/gateway/security)
|
||||||
|
|
||||||
Security + TLS best-practices: [Browser tool](/tools/browser), [Tailscale](/gateway/tailscale), [Security](/gateway/security)
|
|
||||||
+1
-2
@@ -859,9 +859,8 @@ Location:
|
|||||||
Browser control CLI (dedicated Chrome/Brave/Edge/Chromium). See [`clawdbot browser`](/cli/browser) and the [Browser tool](/tools/browser).
|
Browser control CLI (dedicated Chrome/Brave/Edge/Chromium). See [`clawdbot browser`](/cli/browser) and the [Browser tool](/tools/browser).
|
||||||
|
|
||||||
Common options:
|
Common options:
|
||||||
- `--url <controlUrl>`
|
- `--url`, `--token`, `--timeout`, `--json`
|
||||||
- `--browser-profile <name>`
|
- `--browser-profile <name>`
|
||||||
- `--json`
|
|
||||||
|
|
||||||
Manage:
|
Manage:
|
||||||
- `browser status`
|
- `browser status`
|
||||||
|
|||||||
@@ -232,6 +232,42 @@ Notes:
|
|||||||
- Discord defaults live in `channels.discord.guilds."*"` (overridable per guild/channel).
|
- Discord defaults live in `channels.discord.guilds."*"` (overridable per guild/channel).
|
||||||
- Group history context is wrapped uniformly across channels and is **pending-only** (messages skipped due to mention gating); use `messages.groupChat.historyLimit` for the global default and `channels.<channel>.historyLimit` (or `channels.<channel>.accounts.*.historyLimit`) for overrides. Set `0` to disable.
|
- Group history context is wrapped uniformly across channels and is **pending-only** (messages skipped due to mention gating); use `messages.groupChat.historyLimit` for the global default and `channels.<channel>.historyLimit` (or `channels.<channel>.accounts.*.historyLimit`) for overrides. Set `0` to disable.
|
||||||
|
|
||||||
|
## Group/channel tool restrictions (optional)
|
||||||
|
Some channel configs support restricting which tools are available **inside a specific group/room/channel**.
|
||||||
|
|
||||||
|
- `tools`: allow/deny tools for the whole group.
|
||||||
|
- `toolsBySender`: per-sender overrides within the group (keys are sender IDs/usernames/emails/phone numbers depending on the channel). Use `"*"` as a wildcard.
|
||||||
|
|
||||||
|
Resolution order (most specific wins):
|
||||||
|
1) group/channel `toolsBySender` match
|
||||||
|
2) group/channel `tools`
|
||||||
|
3) default (`"*"`) `toolsBySender` match
|
||||||
|
4) default (`"*"`) `tools`
|
||||||
|
|
||||||
|
Example (Telegram):
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
telegram: {
|
||||||
|
groups: {
|
||||||
|
"*": { tools: { deny: ["exec"] } },
|
||||||
|
"-1001234567890": {
|
||||||
|
tools: { deny: ["exec", "read", "write"] },
|
||||||
|
toolsBySender: {
|
||||||
|
"123456789": { alsoAllow: ["exec"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Group/channel tool restrictions are applied in addition to global/agent tool policy (deny still wins).
|
||||||
|
- Some channels use different nesting for rooms/channels (e.g., Discord `guilds.*.channels.*`, Slack `channels.*`, MS Teams `teams.*.channels.*`).
|
||||||
|
|
||||||
## Group allowlists
|
## Group allowlists
|
||||||
When `channels.whatsapp.groups`, `channels.telegram.groups`, or `channels.imessage.groups` is configured, the keys act as a group allowlist. Use `"*"` to allow all groups while still setting default mention behavior.
|
When `channels.whatsapp.groups`, `channels.telegram.groups`, or `channels.imessage.groups` is configured, the keys act as a group allowlist. Use `"*"` to allow all groups while still setting default mention behavior.
|
||||||
|
|
||||||
|
|||||||
+1
-4
@@ -345,10 +345,6 @@
|
|||||||
"source": "/auth-monitoring",
|
"source": "/auth-monitoring",
|
||||||
"destination": "/automation/auth-monitoring"
|
"destination": "/automation/auth-monitoring"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"source": "/scripts",
|
|
||||||
"destination": "/scripts"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"source": "/camera",
|
"source": "/camera",
|
||||||
"destination": "/nodes/camera"
|
"destination": "/nodes/camera"
|
||||||
@@ -960,6 +956,7 @@
|
|||||||
"gateway/doctor",
|
"gateway/doctor",
|
||||||
"gateway/logging",
|
"gateway/logging",
|
||||||
"gateway/security",
|
"gateway/security",
|
||||||
|
"security/formal-verification",
|
||||||
"gateway/sandbox-vs-tool-policy-vs-elevated",
|
"gateway/sandbox-vs-tool-policy-vs-elevated",
|
||||||
"gateway/sandboxing",
|
"gateway/sandboxing",
|
||||||
"gateway/troubleshooting",
|
"gateway/troubleshooting",
|
||||||
|
|||||||
@@ -2759,7 +2759,7 @@ Example:
|
|||||||
|
|
||||||
### `browser` (clawd-managed browser)
|
### `browser` (clawd-managed browser)
|
||||||
|
|
||||||
Clawdbot can start a **dedicated, isolated** Chrome/Brave/Edge/Chromium instance for clawd and expose a small loopback control server.
|
Clawdbot can start a **dedicated, isolated** Chrome/Brave/Edge/Chromium instance for clawd and expose a small loopback control service.
|
||||||
Profiles can point at a **remote** Chromium-based browser via `profiles.<name>.cdpUrl`. Remote
|
Profiles can point at a **remote** Chromium-based browser via `profiles.<name>.cdpUrl`. Remote
|
||||||
profiles are attach-only (start/stop/reset are disabled).
|
profiles are attach-only (start/stop/reset are disabled).
|
||||||
|
|
||||||
@@ -2768,8 +2768,9 @@ scheme/host for profiles that only set `cdpPort`.
|
|||||||
|
|
||||||
Defaults:
|
Defaults:
|
||||||
- enabled: `true`
|
- enabled: `true`
|
||||||
- control URL: `http://127.0.0.1:18791` (CDP uses `18792`)
|
- evaluateEnabled: `true` (set `false` to disable `act:evaluate` and `wait --fn`)
|
||||||
- CDP URL: `http://127.0.0.1:18792` (control URL + 1, legacy single-profile)
|
- control service: loopback only (port derived from `gateway.port`, default `18791`)
|
||||||
|
- CDP URL: `http://127.0.0.1:18792` (control service + 1, legacy single-profile)
|
||||||
- profile color: `#FF4500` (lobster-orange)
|
- profile color: `#FF4500` (lobster-orange)
|
||||||
- Note: the control server is started by the running gateway (Clawdbot.app menubar, or `clawdbot gateway`).
|
- Note: the control server is started by the running gateway (Clawdbot.app menubar, or `clawdbot gateway`).
|
||||||
- Auto-detect order: default browser if Chromium-based; otherwise Chrome → Brave → Edge → Chromium → Chrome Canary.
|
- Auto-detect order: default browser if Chromium-based; otherwise Chrome → Brave → Edge → Chromium → Chrome Canary.
|
||||||
@@ -2778,7 +2779,7 @@ Defaults:
|
|||||||
{
|
{
|
||||||
browser: {
|
browser: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
controlUrl: "http://127.0.0.1:18791",
|
evaluateEnabled: true,
|
||||||
// cdpUrl: "http://127.0.0.1:18792", // legacy single-profile override
|
// cdpUrl: "http://127.0.0.1:18792", // legacy single-profile override
|
||||||
defaultProfile: "chrome",
|
defaultProfile: "chrome",
|
||||||
profiles: {
|
profiles: {
|
||||||
|
|||||||
@@ -83,13 +83,13 @@ Defaults (can be overridden via env/flags/config):
|
|||||||
- `CLAWDBOT_STATE_DIR=~/.clawdbot-dev`
|
- `CLAWDBOT_STATE_DIR=~/.clawdbot-dev`
|
||||||
- `CLAWDBOT_CONFIG_PATH=~/.clawdbot-dev/clawdbot.json`
|
- `CLAWDBOT_CONFIG_PATH=~/.clawdbot-dev/clawdbot.json`
|
||||||
- `CLAWDBOT_GATEWAY_PORT=19001` (Gateway WS + HTTP)
|
- `CLAWDBOT_GATEWAY_PORT=19001` (Gateway WS + HTTP)
|
||||||
- `browser.controlUrl=http://127.0.0.1:19003` (derived: `gateway.port+2`)
|
- browser control service port = `19003` (derived: `gateway.port+2`, loopback only)
|
||||||
- `canvasHost.port=19005` (derived: `gateway.port+4`)
|
- `canvasHost.port=19005` (derived: `gateway.port+4`)
|
||||||
- `agents.defaults.workspace` default becomes `~/clawd-dev` when you run `setup`/`onboard` under `--dev`.
|
- `agents.defaults.workspace` default becomes `~/clawd-dev` when you run `setup`/`onboard` under `--dev`.
|
||||||
|
|
||||||
Derived ports (rules of thumb):
|
Derived ports (rules of thumb):
|
||||||
- Base port = `gateway.port` (or `CLAWDBOT_GATEWAY_PORT` / `--port`)
|
- Base port = `gateway.port` (or `CLAWDBOT_GATEWAY_PORT` / `--port`)
|
||||||
- `browser.controlUrl port = base + 2` (or `CLAWDBOT_BROWSER_CONTROL_URL` / config override)
|
- browser control service port = base + 2 (loopback only)
|
||||||
- `canvasHost.port = base + 4` (or `CLAWDBOT_CANVAS_HOST_PORT` / config override)
|
- `canvasHost.port = base + 4` (or `CLAWDBOT_CANVAS_HOST_PORT` / config override)
|
||||||
- Browser profile CDP ports auto-allocate from `browser.controlPort + 9 .. + 108` (persisted per profile).
|
- Browser profile CDP ports auto-allocate from `browser.controlPort + 9 .. + 108` (persisted per profile).
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ clawdbot --profile rescue gateway install
|
|||||||
|
|
||||||
Base port = `gateway.port` (or `CLAWDBOT_GATEWAY_PORT` / `--port`).
|
Base port = `gateway.port` (or `CLAWDBOT_GATEWAY_PORT` / `--port`).
|
||||||
|
|
||||||
- `browser.controlUrl port = base + 2`
|
- browser control service port = base + 2 (loopback only)
|
||||||
- `canvasHost.port = base + 4`
|
- `canvasHost.port = base + 4`
|
||||||
- Browser profile CDP ports auto-allocate from `browser.controlPort + 9 .. + 108`
|
- Browser profile CDP ports auto-allocate from `browser.controlPort + 9 .. + 108`
|
||||||
|
|
||||||
@@ -81,8 +81,8 @@ If you override any of these in config or env, you must keep them unique per ins
|
|||||||
|
|
||||||
## Browser/CDP notes (common footgun)
|
## Browser/CDP notes (common footgun)
|
||||||
|
|
||||||
- Do **not** pin `browser.controlUrl` or `browser.cdpUrl` to the same values on multiple instances.
|
- Do **not** pin `browser.cdpUrl` to the same values on multiple instances.
|
||||||
- Each instance needs its own browser control port and CDP range.
|
- Each instance needs its own browser control port and CDP range (derived from its gateway port).
|
||||||
- If you need explicit CDP ports, set `browser.profiles.<name>.cdpPort` per instance.
|
- If you need explicit CDP ports, set `browser.profiles.<name>.cdpPort` per instance.
|
||||||
- Remote Chrome: use `browser.profiles.<name>.cdpUrl` (per profile, per instance).
|
- Remote Chrome: use `browser.profiles.<name>.cdpUrl` (per profile, per instance).
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,6 @@ Short version: **keep the Gateway loopback-only** unless you’re sure you need
|
|||||||
- `gateway.remote.tlsFingerprint` pins the remote TLS cert when using `wss://`.
|
- `gateway.remote.tlsFingerprint` pins the remote TLS cert when using `wss://`.
|
||||||
- **Tailscale Serve** can authenticate via identity headers when `gateway.auth.allowTailscale: true`.
|
- **Tailscale Serve** can authenticate via identity headers when `gateway.auth.allowTailscale: true`.
|
||||||
Set it to `false` if you want tokens/passwords instead.
|
Set it to `false` if you want tokens/passwords instead.
|
||||||
- Treat `browser.controlUrl` like an admin API: tailnet-only + token auth.
|
- Treat browser control like operator access: tailnet-only + deliberate node pairing.
|
||||||
|
|
||||||
Deep dive: [Security](/gateway/security).
|
Deep dive: [Security](/gateway/security).
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
title: Formal Verification (Security Models)
|
||||||
|
summary: Redirect to the canonical Formal Verification page.
|
||||||
|
permalink: /gateway/security/formal-verification/
|
||||||
|
---
|
||||||
|
|
||||||
|
This page moved to: [/security/formal-verification/](/security/formal-verification/)
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Best-effort client-side redirect for Mintlify/Next.
|
||||||
|
window.location.replace("/security/formal-verification/");
|
||||||
|
</script>
|
||||||
+18
-21
@@ -7,6 +7,8 @@ read_when:
|
|||||||
|
|
||||||
## Quick check: `clawdbot security audit`
|
## Quick check: `clawdbot security audit`
|
||||||
|
|
||||||
|
See also: [Formal Verification (Security Models)](/security/formal-verification/)
|
||||||
|
|
||||||
Run this regularly (especially after changing config or exposing network surfaces):
|
Run this regularly (especially after changing config or exposing network surfaces):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -36,7 +38,7 @@ Start with the smallest access that still works, then widen it as you gain confi
|
|||||||
- **Inbound access** (DM policies, group policies, allowlists): can strangers trigger the bot?
|
- **Inbound access** (DM policies, group policies, allowlists): can strangers trigger the bot?
|
||||||
- **Tool blast radius** (elevated tools + open rooms): could prompt injection turn into shell/file/network actions?
|
- **Tool blast radius** (elevated tools + open rooms): could prompt injection turn into shell/file/network actions?
|
||||||
- **Network exposure** (Gateway bind/auth, Tailscale Serve/Funnel).
|
- **Network exposure** (Gateway bind/auth, Tailscale Serve/Funnel).
|
||||||
- **Browser control exposure** (remote controlUrl without token, HTTP, token reuse).
|
- **Browser control exposure** (remote nodes, relay ports, remote CDP endpoints).
|
||||||
- **Local disk hygiene** (permissions, symlinks, config includes, “synced folder” paths).
|
- **Local disk hygiene** (permissions, symlinks, config includes, “synced folder” paths).
|
||||||
- **Plugins** (extensions exist without an explicit allowlist).
|
- **Plugins** (extensions exist without an explicit allowlist).
|
||||||
- **Model hygiene** (warn when configured models look legacy; not a hard block).
|
- **Model hygiene** (warn when configured models look legacy; not a hard block).
|
||||||
@@ -61,7 +63,7 @@ When the audit prints findings, treat this as a priority order:
|
|||||||
|
|
||||||
1. **Anything “open” + tools enabled**: lock down DMs/groups first (pairing/allowlists), then tighten tool policy/sandboxing.
|
1. **Anything “open” + tools enabled**: lock down DMs/groups first (pairing/allowlists), then tighten tool policy/sandboxing.
|
||||||
2. **Public network exposure** (LAN bind, Funnel, missing auth): fix immediately.
|
2. **Public network exposure** (LAN bind, Funnel, missing auth): fix immediately.
|
||||||
3. **Browser control remote exposure**: treat it like a remote admin API (token required; HTTPS/tailnet-only).
|
3. **Browser control remote exposure**: treat it like operator access (tailnet-only, pair nodes deliberately, avoid public exposure).
|
||||||
4. **Permissions**: make sure state/config/credentials/auth are not group/world-readable.
|
4. **Permissions**: make sure state/config/credentials/auth are not group/world-readable.
|
||||||
5. **Plugins/extensions**: only load what you explicitly trust.
|
5. **Plugins/extensions**: only load what you explicitly trust.
|
||||||
6. **Model choice**: prefer modern, instruction-hardened models for any bot with tools.
|
6. **Model choice**: prefer modern, instruction-hardened models for any bot with tools.
|
||||||
@@ -277,7 +279,7 @@ Assume “compromised” means: someone got into a room that can trigger the bot
|
|||||||
- Lock down inbound surfaces (DM policy, group allowlists, mention gating).
|
- Lock down inbound surfaces (DM policy, group allowlists, mention gating).
|
||||||
2. **Rotate secrets**
|
2. **Rotate secrets**
|
||||||
- Rotate `gateway.auth` token/password.
|
- Rotate `gateway.auth` token/password.
|
||||||
- Rotate `browser.controlToken` and `hooks.token` (if used).
|
- Rotate `hooks.token` (if used) and revoke any suspicious node pairings.
|
||||||
- Revoke/rotate model provider credentials (API keys / OAuth).
|
- Revoke/rotate model provider credentials (API keys / OAuth).
|
||||||
3. **Review artifacts**
|
3. **Review artifacts**
|
||||||
- Check Gateway logs and recent sessions/transcripts for unexpected tool calls.
|
- Check Gateway logs and recent sessions/transcripts for unexpected tool calls.
|
||||||
@@ -430,26 +432,19 @@ Trusted proxies:
|
|||||||
|
|
||||||
See [Tailscale](/gateway/tailscale) and [Web overview](/web).
|
See [Tailscale](/gateway/tailscale) and [Web overview](/web).
|
||||||
|
|
||||||
### 0.6.1) Browser control server over Tailscale (recommended)
|
### 0.6.1) Browser control via node host (recommended)
|
||||||
|
|
||||||
If your Gateway is remote but the browser runs on another machine, you’ll often run a **separate browser control server**
|
If your Gateway is remote but the browser runs on another machine, run a **node host**
|
||||||
on the browser machine (see [Browser tool](/tools/browser)). Treat this like an admin API.
|
on the browser machine and let the Gateway proxy browser actions (see [Browser tool](/tools/browser)).
|
||||||
|
Treat node pairing like admin access.
|
||||||
|
|
||||||
Recommended pattern:
|
Recommended pattern:
|
||||||
|
- Keep the Gateway and node host on the same tailnet (Tailscale).
|
||||||
```bash
|
- Pair the node intentionally; disable browser proxy routing if you don’t need it.
|
||||||
# on the machine that runs Chrome
|
|
||||||
clawdbot browser serve --bind 127.0.0.1 --port 18791 --token <token>
|
|
||||||
tailscale serve https / http://127.0.0.1:18791
|
|
||||||
```
|
|
||||||
|
|
||||||
Then on the Gateway, set:
|
|
||||||
- `browser.controlUrl` to the `https://…` Serve URL (MagicDNS/ts.net)
|
|
||||||
- and authenticate with the same token (`CLAWDBOT_BROWSER_CONTROL_TOKEN` env preferred)
|
|
||||||
|
|
||||||
Avoid:
|
Avoid:
|
||||||
- `--bind 0.0.0.0` (LAN-visible surface)
|
- Exposing relay/control ports over LAN or public Internet.
|
||||||
- Tailscale Funnel for browser control endpoints (public exposure)
|
- Tailscale Funnel for browser control endpoints (public exposure).
|
||||||
|
|
||||||
### 0.7) Secrets on disk (what’s sensitive)
|
### 0.7) Secrets on disk (what’s sensitive)
|
||||||
|
|
||||||
@@ -577,13 +572,15 @@ If that browser profile already contains logged-in sessions, the model can
|
|||||||
access those accounts and data. Treat browser profiles as **sensitive state**:
|
access those accounts and data. Treat browser profiles as **sensitive state**:
|
||||||
- Prefer a dedicated profile for the agent (the default `clawd` profile).
|
- Prefer a dedicated profile for the agent (the default `clawd` profile).
|
||||||
- Avoid pointing the agent at your personal daily-driver profile.
|
- Avoid pointing the agent at your personal daily-driver profile.
|
||||||
|
- `act:evaluate` and `wait --fn` run arbitrary JavaScript in the page context.
|
||||||
|
Prompt injection can steer the model into calling them. If you do not need
|
||||||
|
them, set `browser.evaluateEnabled=false` (see [Configuration](/gateway/configuration#browser-clawd-managed-browser)).
|
||||||
- Keep host browser control disabled for sandboxed agents unless you trust them.
|
- Keep host browser control disabled for sandboxed agents unless you trust them.
|
||||||
- Treat browser downloads as untrusted input; prefer an isolated downloads directory.
|
- Treat browser downloads as untrusted input; prefer an isolated downloads directory.
|
||||||
- Disable browser sync/password managers in the agent profile if possible (reduces blast radius).
|
- Disable browser sync/password managers in the agent profile if possible (reduces blast radius).
|
||||||
- For remote gateways, assume “browser control” is equivalent to “operator access” to whatever that profile can reach.
|
- For remote gateways, assume “browser control” is equivalent to “operator access” to whatever that profile can reach.
|
||||||
- Treat `browser.controlUrl` endpoints as an admin API: tailnet-only + token auth. Prefer Tailscale Serve over LAN binds.
|
- Keep the Gateway and node hosts tailnet-only; avoid exposing relay/control ports to LAN or public Internet.
|
||||||
- Keep `browser.controlToken` separate from `gateway.auth.token` (you can reuse it, but that increases blast radius).
|
- Disable browser proxy routing when you don’t need it (`gateway.nodes.browser.mode="off"`).
|
||||||
- Prefer env vars for the token (`CLAWDBOT_BROWSER_CONTROL_TOKEN`) instead of storing it in config on disk.
|
|
||||||
- Chrome extension relay mode is **not** “safer”; it can take over your existing Chrome tabs. Assume it can act as you in whatever that tab/profile can reach.
|
- Chrome extension relay mode is **not** “safer”; it can take over your existing Chrome tabs. Assume it can act as you in whatever that tab/profile can reach.
|
||||||
|
|
||||||
## Per-agent access profiles (multi-agent)
|
## Per-agent access profiles (multi-agent)
|
||||||
|
|||||||
@@ -100,35 +100,13 @@ clawdbot gateway --tailscale funnel --auth password
|
|||||||
- Serve/Funnel only expose the **Gateway control UI + WS**. Nodes connect over
|
- Serve/Funnel only expose the **Gateway control UI + WS**. Nodes connect over
|
||||||
the same Gateway WS endpoint, so Serve can work for node access.
|
the same Gateway WS endpoint, so Serve can work for node access.
|
||||||
|
|
||||||
## Browser control server (remote Gateway + local browser)
|
## Browser control (remote Gateway + local browser)
|
||||||
|
|
||||||
If you run the Gateway on one machine but want to drive a browser on another machine, use a **separate browser control server**
|
If you run the Gateway on one machine but want to drive a browser on another machine,
|
||||||
and publish it through Tailscale **Serve** (tailnet-only):
|
run a **node host** on the browser machine and keep both on the same tailnet.
|
||||||
|
The Gateway will proxy browser actions to the node; no separate control server or Serve URL needed.
|
||||||
|
|
||||||
```bash
|
Avoid Funnel for browser control; treat node pairing like operator access.
|
||||||
# on the machine that runs Chrome
|
|
||||||
clawdbot browser serve --bind 127.0.0.1 --port 18791 --token <token>
|
|
||||||
tailscale serve https / http://127.0.0.1:18791
|
|
||||||
```
|
|
||||||
|
|
||||||
Then point the Gateway config at the HTTPS URL:
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
browser: {
|
|
||||||
enabled: true,
|
|
||||||
controlUrl: "https://<magicdns>/"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
And authenticate from the Gateway with the same token (prefer env):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export CLAWDBOT_BROWSER_CONTROL_TOKEN="<token>"
|
|
||||||
```
|
|
||||||
|
|
||||||
Avoid Funnel for browser control endpoints unless you explicitly want public exposure.
|
|
||||||
|
|
||||||
## Tailscale prerequisites + limits
|
## Tailscale prerequisites + limits
|
||||||
|
|
||||||
|
|||||||
+4
-3
@@ -1093,9 +1093,10 @@ clawdbot browser extension path
|
|||||||
|
|
||||||
Then Chrome → `chrome://extensions` → enable “Developer mode” → “Load unpacked” → pick that folder.
|
Then Chrome → `chrome://extensions` → enable “Developer mode” → “Load unpacked” → pick that folder.
|
||||||
|
|
||||||
Full guide (including remote Gateway via Tailscale + security notes): [Chrome extension](/tools/chrome-extension)
|
Full guide (including remote Gateway + security notes): [Chrome extension](/tools/chrome-extension)
|
||||||
|
|
||||||
If the Gateway runs on the same machine as Chrome (default setup), you usually **do not** need `clawdbot browser serve`.
|
If the Gateway runs on the same machine as Chrome (default setup), you usually **do not** need anything extra.
|
||||||
|
If the Gateway runs elsewhere, run a node host on the browser machine so the Gateway can proxy browser actions.
|
||||||
You still need to click the extension button on the tab you want to control (it doesn’t auto-attach).
|
You still need to click the extension button on the tab you want to control (it doesn’t auto-attach).
|
||||||
|
|
||||||
## Sandboxing and memory
|
## Sandboxing and memory
|
||||||
@@ -1479,7 +1480,7 @@ setup is an always‑on host plus your laptop as a node.
|
|||||||
- **Safer execution controls.** `system.run` is gated by node allowlists/approvals on that laptop.
|
- **Safer execution controls.** `system.run` is gated by node allowlists/approvals on that laptop.
|
||||||
- **More device tools.** Nodes expose `canvas`, `camera`, and `screen` in addition to `system.run`.
|
- **More device tools.** Nodes expose `canvas`, `camera`, and `screen` in addition to `system.run`.
|
||||||
- **Local browser automation.** Keep the Gateway on a VPS, but run Chrome locally and relay control
|
- **Local browser automation.** Keep the Gateway on a VPS, but run Chrome locally and relay control
|
||||||
with the Chrome extension + `clawdbot browser serve`.
|
with the Chrome extension + a node host on the laptop.
|
||||||
|
|
||||||
SSH is fine for ad‑hoc shell access, but nodes are simpler for ongoing agent workflows and
|
SSH is fine for ad‑hoc shell access, but nodes are simpler for ongoing agent workflows and
|
||||||
device automation.
|
device automation.
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
---
|
---
|
||||||
|
title: "Node.js + npm (PATH sanity)"
|
||||||
summary: "Node.js + npm install sanity: versions, PATH, and global installs"
|
summary: "Node.js + npm install sanity: versions, PATH, and global installs"
|
||||||
read_when:
|
read_when:
|
||||||
- You installed Clawdbot but `clawdbot` is “command not found”
|
- "You installed Clawdbot but `clawdbot` is “command not found”"
|
||||||
- You’re setting up Node.js/npm on a new machine
|
- "You’re setting up Node.js/npm on a new machine"
|
||||||
- `npm install -g ...` fails with permissions or PATH issues
|
- "npm install -g ... fails with permissions or PATH issues"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Node.js + npm (PATH sanity)
|
# Node.js + npm (PATH sanity)
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
---
|
||||||
|
title: Formal Verification (Security Models)
|
||||||
|
summary: Machine-checked security models for Clawdbot’s highest-risk paths.
|
||||||
|
permalink: /security/formal-verification/
|
||||||
|
---
|
||||||
|
|
||||||
|
# Formal Verification (Security Models)
|
||||||
|
|
||||||
|
This page tracks Clawdbot’s **formal security models** (TLA+/TLC today; more as needed).
|
||||||
|
|
||||||
|
**Goal (north star):** provide a machine-checked argument that Clawdbot enforces its
|
||||||
|
intended security policy (authorization, session isolation, tool gating, and
|
||||||
|
misconfiguration safety), under explicit assumptions.
|
||||||
|
|
||||||
|
**What this is (today):** an executable, attacker-driven **security regression suite**:
|
||||||
|
- Each claim has a runnable model-check over a finite state space.
|
||||||
|
- Many claims have a paired **negative model** that produces a counterexample trace for a realistic bug class.
|
||||||
|
|
||||||
|
**What this is not (yet):** a proof that “Clawdbot is secure in all respects” or that the full TypeScript implementation is correct.
|
||||||
|
|
||||||
|
## Where the models live
|
||||||
|
|
||||||
|
Models are maintained in a separate repo: [vignesh07/clawdbot-formal-models](https://github.com/vignesh07/clawdbot-formal-models).
|
||||||
|
|
||||||
|
## Important caveats
|
||||||
|
|
||||||
|
- These are **models**, not the full TypeScript implementation. Drift between model and code is possible.
|
||||||
|
- Results are bounded by the state space explored by TLC; “green” does not imply security beyond the modeled assumptions and bounds.
|
||||||
|
- Some claims rely on explicit environmental assumptions (e.g., correct deployment, correct configuration inputs).
|
||||||
|
|
||||||
|
## Reproducing results
|
||||||
|
|
||||||
|
Today, results are reproduced by cloning the models repo locally and running TLC (see below). A future iteration could offer:
|
||||||
|
- CI-run models with public artifacts (counterexample traces, run logs)
|
||||||
|
- a hosted “run this model” workflow for small, bounded checks
|
||||||
|
|
||||||
|
Getting started:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/vignesh07/clawdbot-formal-models
|
||||||
|
cd clawdbot-formal-models
|
||||||
|
|
||||||
|
# Java 11+ required (TLC runs on the JVM).
|
||||||
|
# The repo vendors a pinned `tla2tools.jar` (TLA+ tools) and provides `bin/tlc` + Make targets.
|
||||||
|
|
||||||
|
make <target>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gateway exposure and open gateway misconfiguration
|
||||||
|
|
||||||
|
**Claim:** binding beyond loopback without auth can make remote compromise possible / increases exposure; token/password blocks unauth attackers (per the model assumptions).
|
||||||
|
|
||||||
|
- Green runs:
|
||||||
|
- `make gateway-exposure-v2`
|
||||||
|
- `make gateway-exposure-v2-protected`
|
||||||
|
- Red (expected):
|
||||||
|
- `make gateway-exposure-v2-negative`
|
||||||
|
|
||||||
|
See also: `docs/gateway-exposure-matrix.md` in the models repo.
|
||||||
|
|
||||||
|
### Nodes.run pipeline (highest-risk capability)
|
||||||
|
|
||||||
|
**Claim:** `nodes.run` requires (a) node command allowlist plus declared commands and (b) live approval when configured; approvals are tokenized to prevent replay (in the model).
|
||||||
|
|
||||||
|
- Green runs:
|
||||||
|
- `make nodes-pipeline`
|
||||||
|
- `make approvals-token`
|
||||||
|
- Red (expected):
|
||||||
|
- `make nodes-pipeline-negative`
|
||||||
|
- `make approvals-token-negative`
|
||||||
|
|
||||||
|
### Pairing store (DM gating)
|
||||||
|
|
||||||
|
**Claim:** pairing requests respect TTL and pending-request caps.
|
||||||
|
|
||||||
|
- Green runs:
|
||||||
|
- `make pairing`
|
||||||
|
- `make pairing-cap`
|
||||||
|
- Red (expected):
|
||||||
|
- `make pairing-negative`
|
||||||
|
- `make pairing-cap-negative`
|
||||||
|
|
||||||
|
### Ingress gating (mentions + control-command bypass)
|
||||||
|
|
||||||
|
**Claim:** in group contexts requiring mention, an unauthorized “control command” cannot bypass mention gating.
|
||||||
|
|
||||||
|
- Green:
|
||||||
|
- `make ingress-gating`
|
||||||
|
- Red (expected):
|
||||||
|
- `make ingress-gating-negative`
|
||||||
|
|
||||||
|
### Routing/session-key isolation
|
||||||
|
|
||||||
|
**Claim:** DMs from distinct peers do not collapse into the same session unless explicitly linked/configured.
|
||||||
|
|
||||||
|
- Green:
|
||||||
|
- `make routing-isolation`
|
||||||
|
- Red (expected):
|
||||||
|
- `make routing-isolation-negative`
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
Next models to deepen fidelity:
|
||||||
|
- Pairing store concurrency/locking/idempotency
|
||||||
|
- Provider-specific ingress preflight modeling
|
||||||
|
- Routing identity-links + dmScope variants + binding precedence
|
||||||
|
- Gateway auth conformance (proxy/tailscale specifics)
|
||||||
@@ -175,6 +175,7 @@ clawdbot onboard --install-daemon
|
|||||||
```
|
```
|
||||||
|
|
||||||
If you don’t have a global install yet, run the onboarding step via `pnpm clawdbot ...` from the repo.
|
If you don’t have a global install yet, run the onboarding step via `pnpm clawdbot ...` from the repo.
|
||||||
|
`pnpm build` also bundles A2UI assets; if you need to run just that step, use `pnpm canvas:a2ui:bundle`.
|
||||||
|
|
||||||
Gateway (from this repo):
|
Gateway (from this repo):
|
||||||
|
|
||||||
|
|||||||
+29
-135
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
summary: "Integrated browser control server + action commands"
|
summary: "Integrated browser control service + action commands"
|
||||||
read_when:
|
read_when:
|
||||||
- Adding agent-controlled browser automation
|
- Adding agent-controlled browser automation
|
||||||
- Debugging why clawd is interfering with your own Chrome
|
- Debugging why clawd is interfering with your own Chrome
|
||||||
@@ -10,7 +10,7 @@ read_when:
|
|||||||
|
|
||||||
Clawdbot can run a **dedicated Chrome/Brave/Edge/Chromium profile** that the agent controls.
|
Clawdbot can run a **dedicated Chrome/Brave/Edge/Chromium profile** that the agent controls.
|
||||||
It is isolated from your personal browser and is managed through a small local
|
It is isolated from your personal browser and is managed through a small local
|
||||||
control server.
|
control service inside the Gateway (loopback only).
|
||||||
|
|
||||||
Beginner view:
|
Beginner view:
|
||||||
- Think of it as a **separate, agent-only browser**.
|
- Think of it as a **separate, agent-only browser**.
|
||||||
@@ -57,8 +57,7 @@ Browser settings live in `~/.clawdbot/clawdbot.json`.
|
|||||||
{
|
{
|
||||||
browser: {
|
browser: {
|
||||||
enabled: true, // default: true
|
enabled: true, // default: true
|
||||||
controlUrl: "http://127.0.0.1:18791",
|
// cdpUrl: "http://127.0.0.1:18792", // legacy single-profile override
|
||||||
cdpUrl: "http://127.0.0.1:18792", // defaults to controlUrl + 1
|
|
||||||
remoteCdpTimeoutMs: 1500, // remote CDP HTTP timeout (ms)
|
remoteCdpTimeoutMs: 1500, // remote CDP HTTP timeout (ms)
|
||||||
remoteCdpHandshakeTimeoutMs: 3000, // remote CDP WebSocket handshake timeout (ms)
|
remoteCdpHandshakeTimeoutMs: 3000, // remote CDP WebSocket handshake timeout (ms)
|
||||||
defaultProfile: "chrome",
|
defaultProfile: "chrome",
|
||||||
@@ -77,10 +76,11 @@ Browser settings live in `~/.clawdbot/clawdbot.json`.
|
|||||||
```
|
```
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- `controlUrl` defaults to `http://127.0.0.1:18791`.
|
- The browser control service binds to loopback on a port derived from `gateway.port`
|
||||||
|
(default: `18791`, which is gateway + 2). The relay uses the next port (`18792`).
|
||||||
- If you override the Gateway port (`gateway.port` or `CLAWDBOT_GATEWAY_PORT`),
|
- If you override the Gateway port (`gateway.port` or `CLAWDBOT_GATEWAY_PORT`),
|
||||||
the default browser ports shift to stay in the same “family” (control = gateway + 2).
|
the derived browser ports shift to stay in the same “family”.
|
||||||
- `cdpUrl` defaults to `controlUrl + 1` when unset.
|
- `cdpUrl` defaults to the relay port when unset.
|
||||||
- `remoteCdpTimeoutMs` applies to remote (non-loopback) CDP reachability checks.
|
- `remoteCdpTimeoutMs` applies to remote (non-loopback) CDP reachability checks.
|
||||||
- `remoteCdpHandshakeTimeoutMs` applies to remote CDP WebSocket reachability checks.
|
- `remoteCdpHandshakeTimeoutMs` applies to remote CDP WebSocket reachability checks.
|
||||||
- `attachOnly: true` means “never launch a local browser; only attach if it is already running.”
|
- `attachOnly: true` means “never launch a local browser; only attach if it is already running.”
|
||||||
@@ -126,38 +126,11 @@ clawdbot config set browser.executablePath "/usr/bin/google-chrome"
|
|||||||
|
|
||||||
## Local vs remote control
|
## Local vs remote control
|
||||||
|
|
||||||
- **Local control (default):** `controlUrl` is loopback (`127.0.0.1`/`localhost`).
|
- **Local control (default):** the Gateway starts the loopback control service and can launch a local browser.
|
||||||
The Gateway starts the control server and can launch a local browser.
|
- **Remote control (node host):** run a node host on the machine that has the browser; the Gateway proxies browser actions to it.
|
||||||
- **Remote control:** `controlUrl` is non-loopback. The Gateway **does not** start
|
|
||||||
a local server; it assumes you are pointing at an existing server elsewhere.
|
|
||||||
- **Remote CDP:** set `browser.profiles.<name>.cdpUrl` (or `browser.cdpUrl`) to
|
- **Remote CDP:** set `browser.profiles.<name>.cdpUrl` (or `browser.cdpUrl`) to
|
||||||
attach to a remote Chromium-based browser. In this case, Clawdbot will not launch a local browser.
|
attach to a remote Chromium-based browser. In this case, Clawdbot will not launch a local browser.
|
||||||
|
|
||||||
## Remote browser (control server)
|
|
||||||
|
|
||||||
You can run the **browser control server** on another machine and point your
|
|
||||||
Gateway at it with a remote `controlUrl`. This lets the agent drive a browser
|
|
||||||
outside the host (lab box, VM, remote desktop, etc.).
|
|
||||||
|
|
||||||
Key points:
|
|
||||||
- The **control server** speaks to Chromium-based browsers (Chrome/Brave/Edge/Chromium) via **CDP**.
|
|
||||||
- The **Gateway** only needs the HTTP control URL.
|
|
||||||
- Profiles are resolved on the **control server** side.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
browser: {
|
|
||||||
enabled: true,
|
|
||||||
controlUrl: "http://10.0.0.42:18791",
|
|
||||||
defaultProfile: "work"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `profiles.<name>.cdpUrl` for **remote CDP** if you want the Gateway to talk
|
|
||||||
directly to a Chromium-based browser instance without a remote control server.
|
|
||||||
|
|
||||||
Remote CDP URLs can include auth:
|
Remote CDP URLs can include auth:
|
||||||
- Query tokens (e.g., `https://provider.example?token=<token>`)
|
- Query tokens (e.g., `https://provider.example?token=<token>`)
|
||||||
- HTTP Basic auth (e.g., `https://user:pass@provider.example`)
|
- HTTP Basic auth (e.g., `https://user:pass@provider.example`)
|
||||||
@@ -166,11 +139,11 @@ Clawdbot preserves the auth when calling `/json/*` endpoints and when connecting
|
|||||||
to the CDP WebSocket. Prefer environment variables or secrets managers for
|
to the CDP WebSocket. Prefer environment variables or secrets managers for
|
||||||
tokens instead of committing them to config files.
|
tokens instead of committing them to config files.
|
||||||
|
|
||||||
### Node browser proxy (zero-config default)
|
## Node browser proxy (zero-config default)
|
||||||
|
|
||||||
If you run a **node host** on the machine that has your browser, Clawdbot can
|
If you run a **node host** on the machine that has your browser, Clawdbot can
|
||||||
auto-route browser tool calls to that node without any custom `controlUrl`
|
auto-route browser tool calls to that node without any extra browser config.
|
||||||
setup. This is the default path for remote gateways.
|
This is the default path for remote gateways.
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- The node host exposes its local browser control server via a **proxy command**.
|
- The node host exposes its local browser control server via a **proxy command**.
|
||||||
@@ -179,7 +152,7 @@ Notes:
|
|||||||
- On the node: `nodeHost.browserProxy.enabled=false`
|
- On the node: `nodeHost.browserProxy.enabled=false`
|
||||||
- On the gateway: `gateway.nodes.browser.mode="off"`
|
- On the gateway: `gateway.nodes.browser.mode="off"`
|
||||||
|
|
||||||
### Browserless (hosted remote CDP)
|
## Browserless (hosted remote CDP)
|
||||||
|
|
||||||
[Browserless](https://browserless.io) is a hosted Chromium service that exposes
|
[Browserless](https://browserless.io) is a hosted Chromium service that exposes
|
||||||
CDP endpoints over HTTPS. You can point a Clawdbot browser profile at a
|
CDP endpoints over HTTPS. You can point a Clawdbot browser profile at a
|
||||||
@@ -207,94 +180,16 @@ Notes:
|
|||||||
- Replace `<BROWSERLESS_API_KEY>` with your real Browserless token.
|
- Replace `<BROWSERLESS_API_KEY>` with your real Browserless token.
|
||||||
- Choose the region endpoint that matches your Browserless account (see their docs).
|
- Choose the region endpoint that matches your Browserless account (see their docs).
|
||||||
|
|
||||||
### Running the control server on the browser machine
|
|
||||||
|
|
||||||
Run a standalone browser control server (recommended when your Gateway is remote):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# on the machine that runs Chrome/Brave/Edge
|
|
||||||
clawdbot browser serve --bind <browser-host> --port 18791 --token <token>
|
|
||||||
```
|
|
||||||
|
|
||||||
Then point your Gateway at it:
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
browser: {
|
|
||||||
enabled: true,
|
|
||||||
controlUrl: "http://<browser-host>:18791",
|
|
||||||
|
|
||||||
// Option A (recommended): keep token in env on the Gateway
|
|
||||||
// (avoid writing secrets into config files)
|
|
||||||
// controlToken: "<token>"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
And set the auth token in the Gateway environment:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export CLAWDBOT_BROWSER_CONTROL_TOKEN="<token>"
|
|
||||||
```
|
|
||||||
|
|
||||||
Option B: store the token in the Gateway config instead (same shared token):
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
browser: {
|
|
||||||
enabled: true,
|
|
||||||
controlUrl: "http://<browser-host>:18791",
|
|
||||||
controlToken: "<token>"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
This section covers the **browser control server** (`browser.controlUrl`) used for agent browser automation.
|
|
||||||
|
|
||||||
Key ideas:
|
Key ideas:
|
||||||
- Treat the browser control server like an admin API: **private network only**.
|
- Browser control is loopback-only; access flows through the Gateway’s auth or node pairing.
|
||||||
- Use **token auth** always when the server is reachable off-machine.
|
- Keep the Gateway and any node hosts on a private network (Tailscale); avoid public exposure.
|
||||||
- Prefer **Tailnet-only** connectivity over LAN exposure.
|
- Treat remote CDP URLs/tokens as secrets; prefer env vars or a secrets manager.
|
||||||
|
|
||||||
### Tokens (what is shared with what?)
|
Remote CDP tips:
|
||||||
|
- Prefer HTTPS endpoints and short-lived tokens where possible.
|
||||||
- `browser.controlToken` / `CLAWDBOT_BROWSER_CONTROL_TOKEN` is **only** for authenticating browser control HTTP requests to `browser.controlUrl`.
|
- Avoid embedding long-lived tokens directly in config files.
|
||||||
- It is **not** the Gateway token (`gateway.auth.token`) and **not** a node pairing token.
|
|
||||||
- You *can* reuse the same string value, but it’s better to keep them separate to reduce blast radius.
|
|
||||||
|
|
||||||
### Binding (don’t expose to your LAN by accident)
|
|
||||||
|
|
||||||
Recommended:
|
|
||||||
- Keep `clawdbot browser serve` bound to loopback (`127.0.0.1`) and publish it via Tailscale.
|
|
||||||
- Or bind to a Tailnet IP only (never `0.0.0.0`) and require a token.
|
|
||||||
|
|
||||||
Avoid:
|
|
||||||
- `--bind 0.0.0.0` (LAN-visible). Even with token auth, traffic is plain HTTP unless you also add TLS.
|
|
||||||
|
|
||||||
### TLS / HTTPS (recommended approach: terminate in front)
|
|
||||||
|
|
||||||
Best practice here: keep `clawdbot browser serve` on HTTP and terminate TLS in front.
|
|
||||||
|
|
||||||
If you’re already using Tailscale, you have two good options:
|
|
||||||
|
|
||||||
1) **Tailnet-only, still HTTP** (transport is encrypted by Tailscale):
|
|
||||||
- Keep `controlUrl` as `http://…` but ensure it’s only reachable over your tailnet.
|
|
||||||
|
|
||||||
2) **Serve HTTPS via Tailscale** (nice UX: `https://…` URL):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# on the browser machine
|
|
||||||
clawdbot browser serve --bind 127.0.0.1 --port 18791 --token <token>
|
|
||||||
tailscale serve https / http://127.0.0.1:18791
|
|
||||||
```
|
|
||||||
|
|
||||||
Then set your Gateway config `browser.controlUrl` to the HTTPS URL (MagicDNS/ts.net) and keep using the same token.
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- Do **not** use Tailscale Funnel for this unless you explicitly want to make the endpoint public.
|
|
||||||
- For Tailnet setup/background, see [Gateway web surfaces](/web/index) and the [Gateway CLI](/cli/gateway).
|
|
||||||
|
|
||||||
## Profiles (multi-browser)
|
## Profiles (multi-browser)
|
||||||
|
|
||||||
@@ -318,13 +213,12 @@ Clawdbot can also drive **your existing Chrome tabs** (no separate “clawd” C
|
|||||||
Full guide: [Chrome extension](/tools/chrome-extension)
|
Full guide: [Chrome extension](/tools/chrome-extension)
|
||||||
|
|
||||||
Flow:
|
Flow:
|
||||||
- You run a **browser control server** (Gateway on the same machine, or `clawdbot browser serve`).
|
- The Gateway runs locally (same machine) or a node host runs on the browser machine.
|
||||||
- A local **relay server** listens at a loopback `cdpUrl` (default: `http://127.0.0.1:18792`).
|
- A local **relay server** listens at a loopback `cdpUrl` (default: `http://127.0.0.1:18792`).
|
||||||
- You click the **Clawdbot Browser Relay** extension icon on a tab to attach (it does not auto-attach).
|
- You click the **Clawdbot Browser Relay** extension icon on a tab to attach (it does not auto-attach).
|
||||||
- The agent controls that tab via the normal `browser` tool, by selecting the right profile.
|
- The agent controls that tab via the normal `browser` tool, by selecting the right profile.
|
||||||
|
|
||||||
If the Gateway runs on the same machine as Chrome (default setup), you usually **do not** need `clawdbot browser serve`.
|
If the Gateway runs elsewhere, run a node host on the browser machine so the Gateway can proxy browser actions.
|
||||||
Use `browser serve` only when the Gateway runs elsewhere (remote mode).
|
|
||||||
|
|
||||||
### Sandboxed sessions
|
### Sandboxed sessions
|
||||||
|
|
||||||
@@ -387,8 +281,7 @@ Platforms:
|
|||||||
|
|
||||||
## Control API (optional)
|
## Control API (optional)
|
||||||
|
|
||||||
If you want to integrate directly, the browser control server exposes a small
|
For local integrations only, the Gateway exposes a small loopback HTTP API:
|
||||||
HTTP API:
|
|
||||||
|
|
||||||
- Status/start/stop: `GET /`, `POST /start`, `POST /stop`
|
- Status/start/stop: `GET /`, `POST /start`, `POST /stop`
|
||||||
- Tabs: `GET /tabs`, `POST /tabs/open`, `POST /tabs/focus`, `DELETE /tabs/:targetId`
|
- Tabs: `GET /tabs`, `POST /tabs/open`, `POST /tabs/focus`, `DELETE /tabs/:targetId`
|
||||||
@@ -612,8 +505,11 @@ These are useful for “make the site behave like X” workflows:
|
|||||||
## Security & privacy
|
## Security & privacy
|
||||||
|
|
||||||
- The clawd browser profile may contain logged-in sessions; treat it as sensitive.
|
- The clawd browser profile may contain logged-in sessions; treat it as sensitive.
|
||||||
|
- `browser act kind=evaluate` / `clawdbot browser evaluate` and `wait --fn`
|
||||||
|
execute arbitrary JavaScript in the page context. Prompt injection can steer
|
||||||
|
this. Disable it with `browser.evaluateEnabled=false` if you do not need it.
|
||||||
- For logins and anti-bot notes (X/Twitter, etc.), see [Browser login + X/Twitter posting](/tools/browser-login).
|
- For logins and anti-bot notes (X/Twitter, etc.), see [Browser login + X/Twitter posting](/tools/browser-login).
|
||||||
- Keep control URLs loopback-only unless you intentionally expose the server.
|
- Keep the Gateway/node host private (loopback or tailnet-only).
|
||||||
- Remote CDP endpoints are powerful; tunnel and protect them.
|
- Remote CDP endpoints are powerful; tunnel and protect them.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
@@ -631,12 +527,10 @@ How it maps:
|
|||||||
- `browser act` uses the snapshot `ref` IDs to click/type/drag/select.
|
- `browser act` uses the snapshot `ref` IDs to click/type/drag/select.
|
||||||
- `browser screenshot` captures pixels (full page or element).
|
- `browser screenshot` captures pixels (full page or element).
|
||||||
- `browser` accepts:
|
- `browser` accepts:
|
||||||
- `profile` to choose a named browser profile (host or remote control server).
|
- `profile` to choose a named browser profile (clawd, chrome, or remote CDP).
|
||||||
- `target` (`sandbox` | `host` | `custom`) to select where the browser lives.
|
- `target` (`sandbox` | `host` | `node`) to select where the browser lives.
|
||||||
- `controlUrl` sets `target: "custom"` implicitly (remote control server).
|
|
||||||
- In sandboxed sessions, `target: "host"` requires `agents.defaults.sandbox.browser.allowHostControl=true`.
|
- In sandboxed sessions, `target: "host"` requires `agents.defaults.sandbox.browser.allowHostControl=true`.
|
||||||
- If `target` is omitted: sandboxed sessions default to `sandbox`, non-sandbox sessions default to `host`.
|
- If `target` is omitted: sandboxed sessions default to `sandbox`, non-sandbox sessions default to `host`.
|
||||||
- Sandbox allowlists can restrict `target: "custom"` to specific URLs/hosts/ports.
|
- If a browser-capable node is connected, the tool may auto-route to it unless you pin `target="host"` or `target="node"`.
|
||||||
- Defaults: allowlists unset (no restriction), and sandbox host control is disabled.
|
|
||||||
|
|
||||||
This keeps the agent deterministic and avoids brittle selectors.
|
This keeps the agent deterministic and avoids brittle selectors.
|
||||||
@@ -15,7 +15,7 @@ Attach/detach happens via a **single Chrome toolbar button**.
|
|||||||
## What it is (concept)
|
## What it is (concept)
|
||||||
|
|
||||||
There are three parts:
|
There are three parts:
|
||||||
- **Browser control server** (HTTP): the API the agent/tool calls (`browser.controlUrl`)
|
- **Browser control service** (Gateway or node): the API the agent/tool calls (via the Gateway)
|
||||||
- **Local relay server** (loopback CDP): bridges between the control server and the extension (`http://127.0.0.1:18792` by default)
|
- **Local relay server** (loopback CDP): bridges between the control server and the extension (`http://127.0.0.1:18792` by default)
|
||||||
- **Chrome MV3 extension**: attaches to the active tab using `chrome.debugger` and pipes CDP messages to the relay
|
- **Chrome MV3 extension**: attaches to the active tab using `chrome.debugger` and pipes CDP messages to the relay
|
||||||
|
|
||||||
@@ -87,23 +87,22 @@ clawdbot browser create-profile \
|
|||||||
- `!`: relay not reachable (most common: browser relay server isn’t running on this machine).
|
- `!`: relay not reachable (most common: browser relay server isn’t running on this machine).
|
||||||
|
|
||||||
If you see `!`:
|
If you see `!`:
|
||||||
- Make sure the Gateway is running locally (default setup), or run `clawdbot browser serve` on this machine (remote gateway setup).
|
- Make sure the Gateway is running locally (default setup), or run a node host on this machine if the Gateway runs elsewhere.
|
||||||
- Open the extension Options page; it shows whether the relay is reachable.
|
- Open the extension Options page; it shows whether the relay is reachable.
|
||||||
|
|
||||||
## Do I need `clawdbot browser serve`?
|
## Remote Gateway (use a node host)
|
||||||
|
|
||||||
### Local Gateway (same machine as Chrome) — usually **no**
|
### Local Gateway (same machine as Chrome) — usually **no extra steps**
|
||||||
|
|
||||||
If the Gateway is running on the same machine as Chrome and your `browser.controlUrl` is loopback (default),
|
If the Gateway runs on the same machine as Chrome, it starts the browser control service on loopback
|
||||||
you typically **do not** need `clawdbot browser serve`.
|
and auto-starts the relay server. The extension talks to the local relay; the CLI/tool calls go to the Gateway.
|
||||||
|
|
||||||
The Gateway’s built-in browser control server will start on `http://127.0.0.1:18791/` and Clawdbot will
|
### Remote Gateway (Gateway runs elsewhere) — **run a node host**
|
||||||
auto-start the local relay server on `http://127.0.0.1:18792/`.
|
|
||||||
|
|
||||||
### Remote Gateway (Gateway runs elsewhere) — **yes**
|
If your Gateway runs on another machine, start a node host on the machine that runs Chrome.
|
||||||
|
The Gateway will proxy browser actions to that node; the extension + relay stay local to the browser machine.
|
||||||
|
|
||||||
If your Gateway runs on another machine, run `clawdbot browser serve` on the machine that runs Chrome
|
If multiple nodes are connected, pin one with `gateway.nodes.browser.node` or set `gateway.nodes.browser.mode`.
|
||||||
(and publish it via Tailscale Serve / TLS). See the section below.
|
|
||||||
|
|
||||||
## Sandboxing (tool containers)
|
## Sandboxing (tool containers)
|
||||||
|
|
||||||
@@ -134,26 +133,10 @@ Then ensure the tool isn’t denied by tool policy, and (if needed) call `browse
|
|||||||
|
|
||||||
Debugging: `clawdbot sandbox explain`
|
Debugging: `clawdbot sandbox explain`
|
||||||
|
|
||||||
## Remote Gateway (recommended: Tailscale Serve)
|
## Remote access tips
|
||||||
|
|
||||||
Goal: Gateway runs on one machine, but Chrome runs somewhere else.
|
- Keep the Gateway and node host on the same tailnet; avoid exposing relay ports to LAN or public Internet.
|
||||||
|
- Pair nodes intentionally; disable browser proxy routing if you don’t want remote control (`gateway.nodes.browser.mode="off"`).
|
||||||
On the **browser machine**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot browser serve --bind 127.0.0.1 --port 18791 --token <token>
|
|
||||||
tailscale serve https / http://127.0.0.1:18791
|
|
||||||
```
|
|
||||||
|
|
||||||
On the **Gateway machine**:
|
|
||||||
- Set `browser.controlUrl` to the HTTPS Serve URL (MagicDNS/ts.net).
|
|
||||||
- Provide the token (prefer env):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export CLAWDBOT_BROWSER_CONTROL_TOKEN="<token>"
|
|
||||||
```
|
|
||||||
|
|
||||||
Then the agent can drive the browser by calling the remote `browser.controlUrl` API, while the extension + relay stay local on the browser machine.
|
|
||||||
|
|
||||||
## How “extension path” works
|
## How “extension path” works
|
||||||
|
|
||||||
@@ -176,8 +159,8 @@ This is powerful and risky. Treat it like giving the model “hands on your brow
|
|||||||
|
|
||||||
Recommendations:
|
Recommendations:
|
||||||
- Prefer a dedicated Chrome profile (separate from your personal browsing) for extension relay usage.
|
- Prefer a dedicated Chrome profile (separate from your personal browsing) for extension relay usage.
|
||||||
- Keep the browser control server tailnet-only (Tailscale) and require a token.
|
- Keep the Gateway and any node hosts tailnet-only; rely on Gateway auth + node pairing.
|
||||||
- Avoid exposing browser control over LAN (`0.0.0.0`) and avoid Funnel (public).
|
- Avoid exposing relay ports over LAN (`0.0.0.0`) and avoid Funnel (public).
|
||||||
|
|
||||||
Related:
|
Related:
|
||||||
- Browser tool overview: [Browser](/tools/browser)
|
- Browser tool overview: [Browser](/tools/browser)
|
||||||
|
|||||||
+2
-1
@@ -67,7 +67,8 @@ Example:
|
|||||||
- macOS: `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, `/bin`
|
- macOS: `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, `/bin`
|
||||||
- Linux: `/usr/local/bin`, `/usr/bin`, `/bin`
|
- Linux: `/usr/local/bin`, `/usr/bin`, `/bin`
|
||||||
- `host=sandbox`: runs `sh -lc` (login shell) inside the container, so `/etc/profile` may reset `PATH`.
|
- `host=sandbox`: runs `sh -lc` (login shell) inside the container, so `/etc/profile` may reset `PATH`.
|
||||||
Clawdbot prepends `env.PATH` after profile sourcing; `tools.exec.pathPrepend` applies here too.
|
Clawdbot prepends `env.PATH` after profile sourcing via an internal env var (no shell interpolation);
|
||||||
|
`tools.exec.pathPrepend` applies here too.
|
||||||
- `host=node`: only env overrides you pass are sent to the node. `tools.exec.pathPrepend` only applies
|
- `host=node`: only env overrides you pass are sent to the node. `tools.exec.pathPrepend` only applies
|
||||||
if the exec call already sets `env.PATH`. Headless node hosts accept `PATH` only when it prepends
|
if the exec call already sets `env.PATH`. Headless node hosts accept `PATH` only when it prepends
|
||||||
the node host PATH (no replacement). macOS nodes drop `PATH` overrides entirely.
|
the node host PATH (no replacement). macOS nodes drop `PATH` overrides entirely.
|
||||||
|
|||||||
+6
-3
@@ -249,16 +249,17 @@ Profile management:
|
|||||||
- `reset-profile` — kill orphan process on profile's port (local only)
|
- `reset-profile` — kill orphan process on profile's port (local only)
|
||||||
|
|
||||||
Common parameters:
|
Common parameters:
|
||||||
- `controlUrl` (defaults from config)
|
|
||||||
- `profile` (optional; defaults to `browser.defaultProfile`)
|
- `profile` (optional; defaults to `browser.defaultProfile`)
|
||||||
|
- `target` (`sandbox` | `host` | `node`)
|
||||||
|
- `node` (optional; picks a specific node id/name)
|
||||||
Notes:
|
Notes:
|
||||||
- Requires `browser.enabled=true` (default is `true`; set `false` to disable).
|
- Requires `browser.enabled=true` (default is `true`; set `false` to disable).
|
||||||
- Uses `browser.controlUrl` unless `controlUrl` is passed explicitly.
|
|
||||||
- All actions accept optional `profile` parameter for multi-instance support.
|
- All actions accept optional `profile` parameter for multi-instance support.
|
||||||
- When `profile` is omitted, uses `browser.defaultProfile` (defaults to "chrome").
|
- When `profile` is omitted, uses `browser.defaultProfile` (defaults to "chrome").
|
||||||
- Profile names: lowercase alphanumeric + hyphens only (max 64 chars).
|
- Profile names: lowercase alphanumeric + hyphens only (max 64 chars).
|
||||||
- Port range: 18800-18899 (~100 profiles max).
|
- Port range: 18800-18899 (~100 profiles max).
|
||||||
- Remote profiles are attach-only (no start/stop/reset).
|
- Remote profiles are attach-only (no start/stop/reset).
|
||||||
|
- If a browser-capable node is connected, the tool may auto-route to it (unless you pin `target`).
|
||||||
- `snapshot` defaults to `ai` when Playwright is installed; use `aria` for the accessibility tree.
|
- `snapshot` defaults to `ai` when Playwright is installed; use `aria` for the accessibility tree.
|
||||||
- `snapshot` also supports role-snapshot options (`interactive`, `compact`, `depth`, `selector`) which return refs like `e12`.
|
- `snapshot` also supports role-snapshot options (`interactive`, `compact`, `depth`, `selector`) which return refs like `e12`.
|
||||||
- `act` requires `ref` from `snapshot` (numeric `12` from AI snapshots, or `e12` from role snapshots); use `evaluate` for rare CSS selector needs.
|
- `act` requires `ref` from `snapshot` (numeric `12` from AI snapshots, or `e12` from role snapshots); use `evaluate` for rare CSS selector needs.
|
||||||
@@ -410,7 +411,9 @@ Gateway-backed tools (`canvas`, `nodes`, `cron`):
|
|||||||
- `timeoutMs`
|
- `timeoutMs`
|
||||||
|
|
||||||
Browser tool:
|
Browser tool:
|
||||||
- `controlUrl` (defaults from config)
|
- `profile` (optional; defaults to `browser.defaultProfile`)
|
||||||
|
- `target` (`sandbox` | `host` | `node`)
|
||||||
|
- `node` (optional; pin a specific node id/name)
|
||||||
|
|
||||||
## Recommended agent flows
|
## Recommended agent flows
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
import {
|
import {
|
||||||
buildChannelKeyCandidates,
|
buildChannelKeyCandidates,
|
||||||
normalizeChannelSlug,
|
normalizeChannelSlug,
|
||||||
|
resolveToolsBySender,
|
||||||
resolveChannelEntryMatchWithFallback,
|
resolveChannelEntryMatchWithFallback,
|
||||||
resolveNestedAllowlistDecision,
|
resolveNestedAllowlistDecision,
|
||||||
} from "clawdbot/plugin-sdk";
|
} from "clawdbot/plugin-sdk";
|
||||||
@@ -106,9 +107,36 @@ export function resolveMSTeamsGroupToolPolicy(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (resolved.channelConfig) {
|
if (resolved.channelConfig) {
|
||||||
return resolved.channelConfig.tools ?? resolved.teamConfig?.tools;
|
const senderPolicy = resolveToolsBySender({
|
||||||
|
toolsBySender: resolved.channelConfig.toolsBySender,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
|
});
|
||||||
|
if (senderPolicy) return senderPolicy;
|
||||||
|
if (resolved.channelConfig.tools) return resolved.channelConfig.tools;
|
||||||
|
const teamSenderPolicy = resolveToolsBySender({
|
||||||
|
toolsBySender: resolved.teamConfig?.toolsBySender,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
|
});
|
||||||
|
if (teamSenderPolicy) return teamSenderPolicy;
|
||||||
|
return resolved.teamConfig?.tools;
|
||||||
|
}
|
||||||
|
if (resolved.teamConfig) {
|
||||||
|
const teamSenderPolicy = resolveToolsBySender({
|
||||||
|
toolsBySender: resolved.teamConfig.toolsBySender,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
|
});
|
||||||
|
if (teamSenderPolicy) return teamSenderPolicy;
|
||||||
|
if (resolved.teamConfig.tools) return resolved.teamConfig.tools;
|
||||||
}
|
}
|
||||||
if (resolved.teamConfig?.tools) return resolved.teamConfig.tools;
|
|
||||||
|
|
||||||
if (!groupId) return undefined;
|
if (!groupId) return undefined;
|
||||||
|
|
||||||
@@ -125,7 +153,24 @@ export function resolveMSTeamsGroupToolPolicy(
|
|||||||
normalizeKey: normalizeChannelSlug,
|
normalizeKey: normalizeChannelSlug,
|
||||||
});
|
});
|
||||||
if (match.entry) {
|
if (match.entry) {
|
||||||
return match.entry.tools ?? teamConfig?.tools;
|
const senderPolicy = resolveToolsBySender({
|
||||||
|
toolsBySender: match.entry.toolsBySender,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
|
});
|
||||||
|
if (senderPolicy) return senderPolicy;
|
||||||
|
if (match.entry.tools) return match.entry.tools;
|
||||||
|
const teamSenderPolicy = resolveToolsBySender({
|
||||||
|
toolsBySender: teamConfig?.toolsBySender,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
|
});
|
||||||
|
if (teamSenderPolicy) return teamSenderPolicy;
|
||||||
|
return teamConfig?.tools;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function createMSTeamsReplyDispatcher(params: {
|
|||||||
}) {
|
}) {
|
||||||
const core = getMSTeamsRuntime();
|
const core = getMSTeamsRuntime();
|
||||||
const sendTypingIndicator = async () => {
|
const sendTypingIndicator = async () => {
|
||||||
await params.context.sendActivities([{ type: "typing" }]);
|
await params.context.sendActivity({ type: "typing" });
|
||||||
};
|
};
|
||||||
const typingCallbacks = createTypingCallbacks({
|
const typingCallbacks = createTypingCallbacks({
|
||||||
start: sendTypingIndicator,
|
start: sendTypingIndicator,
|
||||||
@@ -70,38 +70,38 @@ export function createMSTeamsReplyDispatcher(params: {
|
|||||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
channel: "msteams",
|
channel: "msteams",
|
||||||
});
|
});
|
||||||
const messages = renderReplyPayloadsToMessages([payload], {
|
const messages = renderReplyPayloadsToMessages([payload], {
|
||||||
textChunkLimit: params.textLimit,
|
textChunkLimit: params.textLimit,
|
||||||
chunkText: true,
|
chunkText: true,
|
||||||
mediaMode: "split",
|
mediaMode: "split",
|
||||||
tableMode,
|
tableMode,
|
||||||
chunkMode,
|
chunkMode,
|
||||||
});
|
});
|
||||||
const mediaMaxBytes = resolveChannelMediaMaxBytes({
|
const mediaMaxBytes = resolveChannelMediaMaxBytes({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
resolveChannelLimitMb: ({ cfg }) => cfg.channels?.msteams?.mediaMaxMb,
|
resolveChannelLimitMb: ({ cfg }) => cfg.channels?.msteams?.mediaMaxMb,
|
||||||
});
|
});
|
||||||
const ids = await sendMSTeamsMessages({
|
const ids = await sendMSTeamsMessages({
|
||||||
replyStyle: params.replyStyle,
|
replyStyle: params.replyStyle,
|
||||||
adapter: params.adapter,
|
adapter: params.adapter,
|
||||||
appId: params.appId,
|
appId: params.appId,
|
||||||
conversationRef: params.conversationRef,
|
conversationRef: params.conversationRef,
|
||||||
context: params.context,
|
context: params.context,
|
||||||
messages,
|
messages,
|
||||||
// Enable default retry/backoff for throttling/transient failures.
|
// Enable default retry/backoff for throttling/transient failures.
|
||||||
retry: {},
|
retry: {},
|
||||||
onRetry: (event) => {
|
onRetry: (event) => {
|
||||||
params.log.debug("retrying send", {
|
params.log.debug("retrying send", {
|
||||||
replyStyle: params.replyStyle,
|
replyStyle: params.replyStyle,
|
||||||
...event,
|
...event,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
tokenProvider: params.tokenProvider,
|
tokenProvider: params.tokenProvider,
|
||||||
sharePointSiteId: params.sharePointSiteId,
|
sharePointSiteId: params.sharePointSiteId,
|
||||||
mediaMaxBytes,
|
mediaMaxBytes,
|
||||||
});
|
});
|
||||||
if (ids.length > 0) params.onSentMessageIds?.(ids);
|
if (ids.length > 0) params.onSentMessageIds?.(ids);
|
||||||
},
|
},
|
||||||
onError: (err, info) => {
|
onError: (err, info) => {
|
||||||
const errMsg = formatUnknownError(err);
|
const errMsg = formatUnknownError(err);
|
||||||
|
|||||||
+1
-1
@@ -82,7 +82,7 @@
|
|||||||
"docs:bin": "node scripts/build-docs-list.mjs",
|
"docs:bin": "node scripts/build-docs-list.mjs",
|
||||||
"docs:dev": "cd docs && mint dev",
|
"docs:dev": "cd docs && mint dev",
|
||||||
"docs:build": "cd docs && pnpm dlx --reporter append-only mint broken-links",
|
"docs:build": "cd docs && pnpm dlx --reporter append-only mint broken-links",
|
||||||
"build": "tsc -p tsconfig.json && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/write-build-info.ts",
|
"build": "pnpm canvas:a2ui:bundle && tsc -p tsconfig.json && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/write-build-info.ts",
|
||||||
"plugins:sync": "node --import tsx scripts/sync-plugin-versions.ts",
|
"plugins:sync": "node --import tsx scripts/sync-plugin-versions.ts",
|
||||||
"release:check": "node --import tsx scripts/release-check.ts",
|
"release:check": "node --import tsx scripts/release-check.ts",
|
||||||
"ui:install": "node scripts/ui.js install",
|
"ui:install": "node scripts/ui.js install",
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
on_error() {
|
||||||
|
echo "A2UI bundling failed. Re-run with: pnpm canvas:a2ui:bundle" >&2
|
||||||
|
echo "If this persists, verify pnpm deps and try again." >&2
|
||||||
|
}
|
||||||
|
trap on_error ERR
|
||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
HASH_FILE="$ROOT_DIR/src/canvas-host/a2ui/.bundle.hash"
|
HASH_FILE="$ROOT_DIR/src/canvas-host/a2ui/.bundle.hash"
|
||||||
|
OUTPUT_FILE="$ROOT_DIR/src/canvas-host/a2ui/a2ui.bundle.js"
|
||||||
|
|
||||||
INPUT_PATHS=(
|
INPUT_PATHS=(
|
||||||
"$ROOT_DIR/package.json"
|
"$ROOT_DIR/package.json"
|
||||||
@@ -33,7 +40,7 @@ compute_hash() {
|
|||||||
current_hash="$(compute_hash)"
|
current_hash="$(compute_hash)"
|
||||||
if [[ -f "$HASH_FILE" ]]; then
|
if [[ -f "$HASH_FILE" ]]; then
|
||||||
previous_hash="$(cat "$HASH_FILE")"
|
previous_hash="$(cat "$HASH_FILE")"
|
||||||
if [[ "$previous_hash" == "$current_hash" ]]; then
|
if [[ "$previous_hash" == "$current_hash" && -f "$OUTPUT_FILE" ]]; then
|
||||||
echo "A2UI bundle up to date; skipping."
|
echo "A2UI bundle up to date; skipping."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|||||||
+35
-10
@@ -1,19 +1,44 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
|
||||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
const srcDir = path.join(repoRoot, "src", "canvas-host", "a2ui");
|
|
||||||
const outDir = path.join(repoRoot, "dist", "canvas-host", "a2ui");
|
|
||||||
|
|
||||||
async function main() {
|
export function getA2uiPaths(env = process.env) {
|
||||||
await fs.stat(path.join(srcDir, "index.html"));
|
const srcDir =
|
||||||
await fs.stat(path.join(srcDir, "a2ui.bundle.js"));
|
env.CLAWDBOT_A2UI_SRC_DIR ?? path.join(repoRoot, "src", "canvas-host", "a2ui");
|
||||||
|
const outDir =
|
||||||
|
env.CLAWDBOT_A2UI_OUT_DIR ?? path.join(repoRoot, "dist", "canvas-host", "a2ui");
|
||||||
|
return { srcDir, outDir };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copyA2uiAssets({
|
||||||
|
srcDir,
|
||||||
|
outDir,
|
||||||
|
}: {
|
||||||
|
srcDir: string;
|
||||||
|
outDir: string;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
await fs.stat(path.join(srcDir, "index.html"));
|
||||||
|
await fs.stat(path.join(srcDir, "a2ui.bundle.js"));
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
'Missing A2UI bundle assets. Run "pnpm canvas:a2ui:bundle" and retry.';
|
||||||
|
throw new Error(message, { cause: err });
|
||||||
|
}
|
||||||
await fs.mkdir(path.dirname(outDir), { recursive: true });
|
await fs.mkdir(path.dirname(outDir), { recursive: true });
|
||||||
await fs.cp(srcDir, outDir, { recursive: true });
|
await fs.cp(srcDir, outDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((err) => {
|
async function main() {
|
||||||
console.error(String(err));
|
const { srcDir, outDir } = getA2uiPaths();
|
||||||
process.exit(1);
|
await copyA2uiAssets({ srcDir, outDir });
|
||||||
});
|
}
|
||||||
|
|
||||||
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(String(err));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import os from "node:os";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { resolveApiKeyForProfile } from "./oauth.js";
|
||||||
|
import { ensureAuthProfileStore } from "./store.js";
|
||||||
|
import type { AuthProfileStore } from "./types.js";
|
||||||
|
|
||||||
|
describe("resolveApiKeyForProfile fallback to main agent", () => {
|
||||||
|
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||||
|
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
|
||||||
|
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||||
|
let tmpDir: string;
|
||||||
|
let mainAgentDir: string;
|
||||||
|
let secondaryAgentDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "oauth-fallback-test-"));
|
||||||
|
mainAgentDir = path.join(tmpDir, "agents", "main", "agent");
|
||||||
|
secondaryAgentDir = path.join(tmpDir, "agents", "kids", "agent");
|
||||||
|
await fs.mkdir(mainAgentDir, { recursive: true });
|
||||||
|
await fs.mkdir(secondaryAgentDir, { recursive: true });
|
||||||
|
|
||||||
|
// Set environment variables so resolveClawdbotAgentDir() returns mainAgentDir
|
||||||
|
process.env.CLAWDBOT_STATE_DIR = tmpDir;
|
||||||
|
process.env.CLAWDBOT_AGENT_DIR = mainAgentDir;
|
||||||
|
process.env.PI_CODING_AGENT_DIR = mainAgentDir;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
|
||||||
|
// Restore original environment
|
||||||
|
if (previousStateDir === undefined) delete process.env.CLAWDBOT_STATE_DIR;
|
||||||
|
else process.env.CLAWDBOT_STATE_DIR = previousStateDir;
|
||||||
|
if (previousAgentDir === undefined) delete process.env.CLAWDBOT_AGENT_DIR;
|
||||||
|
else process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
|
||||||
|
if (previousPiAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
|
||||||
|
else process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
|
||||||
|
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to main agent credentials when secondary agent token is expired and refresh fails", async () => {
|
||||||
|
const profileId = "anthropic:claude-cli";
|
||||||
|
const now = Date.now();
|
||||||
|
const expiredTime = now - 60 * 60 * 1000; // 1 hour ago
|
||||||
|
const freshTime = now + 60 * 60 * 1000; // 1 hour from now
|
||||||
|
|
||||||
|
// Write expired credentials for secondary agent
|
||||||
|
const secondaryStore: AuthProfileStore = {
|
||||||
|
version: 1,
|
||||||
|
profiles: {
|
||||||
|
[profileId]: {
|
||||||
|
type: "oauth",
|
||||||
|
provider: "anthropic",
|
||||||
|
access: "expired-access-token",
|
||||||
|
refresh: "expired-refresh-token",
|
||||||
|
expires: expiredTime,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(secondaryAgentDir, "auth-profiles.json"),
|
||||||
|
JSON.stringify(secondaryStore),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Write fresh credentials for main agent
|
||||||
|
const mainStore: AuthProfileStore = {
|
||||||
|
version: 1,
|
||||||
|
profiles: {
|
||||||
|
[profileId]: {
|
||||||
|
type: "oauth",
|
||||||
|
provider: "anthropic",
|
||||||
|
access: "fresh-access-token",
|
||||||
|
refresh: "fresh-refresh-token",
|
||||||
|
expires: freshTime,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await fs.writeFile(path.join(mainAgentDir, "auth-profiles.json"), JSON.stringify(mainStore));
|
||||||
|
|
||||||
|
// Mock fetch to simulate OAuth refresh failure
|
||||||
|
const fetchSpy = vi.fn(async () => {
|
||||||
|
return new Response(JSON.stringify({ error: "invalid_grant" }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchSpy);
|
||||||
|
|
||||||
|
// Load the secondary agent's store (will merge with main agent's store)
|
||||||
|
const loadedSecondaryStore = ensureAuthProfileStore(secondaryAgentDir);
|
||||||
|
|
||||||
|
// Call resolveApiKeyForProfile with the secondary agent's expired credentials
|
||||||
|
// This should:
|
||||||
|
// 1. Try to refresh the expired token (fails due to mocked fetch)
|
||||||
|
// 2. Fall back to main agent's fresh credentials
|
||||||
|
// 3. Copy those credentials to the secondary agent
|
||||||
|
const result = await resolveApiKeyForProfile({
|
||||||
|
store: loadedSecondaryStore,
|
||||||
|
profileId,
|
||||||
|
agentDir: secondaryAgentDir,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.apiKey).toBe("fresh-access-token");
|
||||||
|
expect(result?.provider).toBe("anthropic");
|
||||||
|
|
||||||
|
// Verify the credentials were copied to the secondary agent
|
||||||
|
const updatedSecondaryStore = JSON.parse(
|
||||||
|
await fs.readFile(path.join(secondaryAgentDir, "auth-profiles.json"), "utf8"),
|
||||||
|
) as AuthProfileStore;
|
||||||
|
expect(updatedSecondaryStore.profiles[profileId]).toMatchObject({
|
||||||
|
access: "fresh-access-token",
|
||||||
|
expires: freshTime,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws error when both secondary and main agent credentials are expired", async () => {
|
||||||
|
const profileId = "anthropic:claude-cli";
|
||||||
|
const now = Date.now();
|
||||||
|
const expiredTime = now - 60 * 60 * 1000; // 1 hour ago
|
||||||
|
|
||||||
|
// Write expired credentials for both agents
|
||||||
|
const expiredStore: AuthProfileStore = {
|
||||||
|
version: 1,
|
||||||
|
profiles: {
|
||||||
|
[profileId]: {
|
||||||
|
type: "oauth",
|
||||||
|
provider: "anthropic",
|
||||||
|
access: "expired-access-token",
|
||||||
|
refresh: "expired-refresh-token",
|
||||||
|
expires: expiredTime,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(secondaryAgentDir, "auth-profiles.json"),
|
||||||
|
JSON.stringify(expiredStore),
|
||||||
|
);
|
||||||
|
await fs.writeFile(path.join(mainAgentDir, "auth-profiles.json"), JSON.stringify(expiredStore));
|
||||||
|
|
||||||
|
// Mock fetch to simulate OAuth refresh failure
|
||||||
|
const fetchSpy = vi.fn(async () => {
|
||||||
|
return new Response(JSON.stringify({ error: "invalid_grant" }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchSpy);
|
||||||
|
|
||||||
|
const loadedSecondaryStore = ensureAuthProfileStore(secondaryAgentDir);
|
||||||
|
|
||||||
|
// Should throw because both agents have expired credentials
|
||||||
|
await expect(
|
||||||
|
resolveApiKeyForProfile({
|
||||||
|
store: loadedSecondaryStore,
|
||||||
|
profileId,
|
||||||
|
agentDir: secondaryAgentDir,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/OAuth token refresh failed/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@ import lockfile from "proper-lockfile";
|
|||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import { refreshChutesTokens } from "../chutes-oauth.js";
|
import { refreshChutesTokens } from "../chutes-oauth.js";
|
||||||
import { refreshQwenPortalCredentials } from "../../providers/qwen-portal-oauth.js";
|
import { refreshQwenPortalCredentials } from "../../providers/qwen-portal-oauth.js";
|
||||||
import { AUTH_STORE_LOCK_OPTIONS } from "./constants.js";
|
import { AUTH_STORE_LOCK_OPTIONS, log } from "./constants.js";
|
||||||
import { formatAuthDoctorHint } from "./doctor.js";
|
import { formatAuthDoctorHint } from "./doctor.js";
|
||||||
import { ensureAuthStoreFile, resolveAuthStorePath } from "./paths.js";
|
import { ensureAuthStoreFile, resolveAuthStorePath } from "./paths.js";
|
||||||
import { suggestOAuthProfileIdForLegacyDefault } from "./repair.js";
|
import { suggestOAuthProfileIdForLegacyDefault } from "./repair.js";
|
||||||
@@ -196,6 +196,32 @@ export async function resolveApiKeyForProfile(params: {
|
|||||||
// keep original error
|
// keep original error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback: if this is a secondary agent, try using the main agent's credentials
|
||||||
|
if (params.agentDir) {
|
||||||
|
try {
|
||||||
|
const mainStore = ensureAuthProfileStore(undefined); // main agent (no agentDir)
|
||||||
|
const mainCred = mainStore.profiles[profileId];
|
||||||
|
if (mainCred?.type === "oauth" && Date.now() < mainCred.expires) {
|
||||||
|
// Main agent has fresh credentials - copy them to this agent and use them
|
||||||
|
refreshedStore.profiles[profileId] = { ...mainCred };
|
||||||
|
saveAuthProfileStore(refreshedStore, params.agentDir);
|
||||||
|
log.info("inherited fresh OAuth credentials from main agent", {
|
||||||
|
profileId,
|
||||||
|
agentDir: params.agentDir,
|
||||||
|
expires: new Date(mainCred.expires).toISOString(),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
apiKey: buildOAuthApiKey(mainCred.provider, mainCred),
|
||||||
|
provider: mainCred.provider,
|
||||||
|
email: mainCred.email,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// keep original error if main agent fallback also fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
const hint = formatAuthDoctorHint({
|
const hint = formatAuthDoctorHint({
|
||||||
cfg,
|
cfg,
|
||||||
|
|||||||
@@ -60,11 +60,18 @@ export function buildDockerExecArgs(params: {
|
|||||||
for (const [key, value] of Object.entries(params.env)) {
|
for (const [key, value] of Object.entries(params.env)) {
|
||||||
args.push("-e", `${key}=${value}`);
|
args.push("-e", `${key}=${value}`);
|
||||||
}
|
}
|
||||||
|
const hasCustomPath = typeof params.env.PATH === "string" && params.env.PATH.length > 0;
|
||||||
|
if (hasCustomPath) {
|
||||||
|
// Avoid interpolating PATH into the shell command; pass it via env instead.
|
||||||
|
args.push("-e", `CLAWDBOT_PREPEND_PATH=${params.env.PATH}`);
|
||||||
|
}
|
||||||
// Login shell (-l) sources /etc/profile which resets PATH to a minimal set,
|
// Login shell (-l) sources /etc/profile which resets PATH to a minimal set,
|
||||||
// overriding both Docker ENV and -e PATH=... environment variables.
|
// overriding both Docker ENV and -e PATH=... environment variables.
|
||||||
// Prepend custom PATH after profile sourcing to ensure custom tools are accessible
|
// Prepend custom PATH after profile sourcing to ensure custom tools are accessible
|
||||||
// while preserving system paths that /etc/profile may have added.
|
// while preserving system paths that /etc/profile may have added.
|
||||||
const pathExport = params.env.PATH ? `export PATH="${params.env.PATH}:$PATH"; ` : "";
|
const pathExport = hasCustomPath
|
||||||
|
? 'export PATH="${CLAWDBOT_PREPEND_PATH}:$PATH"; unset CLAWDBOT_PREPEND_PATH; '
|
||||||
|
: "";
|
||||||
args.push(params.containerName, "sh", "-lc", `${pathExport}${params.command}`);
|
args.push(params.containerName, "sh", "-lc", `${pathExport}${params.command}`);
|
||||||
return args;
|
return args;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -318,9 +318,30 @@ describe("buildDockerExecArgs", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const commandArg = args[args.length - 1];
|
const commandArg = args[args.length - 1];
|
||||||
expect(commandArg).toContain('export PATH="/custom/bin:/usr/local/bin:/usr/bin:$PATH"');
|
expect(args).toContain("CLAWDBOT_PREPEND_PATH=/custom/bin:/usr/local/bin:/usr/bin");
|
||||||
|
expect(commandArg).toContain('export PATH="${CLAWDBOT_PREPEND_PATH}:$PATH"');
|
||||||
expect(commandArg).toContain("echo hello");
|
expect(commandArg).toContain("echo hello");
|
||||||
expect(commandArg).toBe('export PATH="/custom/bin:/usr/local/bin:/usr/bin:$PATH"; echo hello');
|
expect(commandArg).toBe(
|
||||||
|
'export PATH="${CLAWDBOT_PREPEND_PATH}:$PATH"; unset CLAWDBOT_PREPEND_PATH; echo hello',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not interpolate PATH into the shell command", () => {
|
||||||
|
const injectedPath = "$(touch /tmp/clawdbot-path-injection)";
|
||||||
|
const args = buildDockerExecArgs({
|
||||||
|
containerName: "test-container",
|
||||||
|
command: "echo hello",
|
||||||
|
env: {
|
||||||
|
PATH: injectedPath,
|
||||||
|
HOME: "/home/user",
|
||||||
|
},
|
||||||
|
tty: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const commandArg = args[args.length - 1];
|
||||||
|
expect(args).toContain(`CLAWDBOT_PREPEND_PATH=${injectedPath}`);
|
||||||
|
expect(commandArg).not.toContain(injectedPath);
|
||||||
|
expect(commandArg).toContain("CLAWDBOT_PREPEND_PATH");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not add PATH export when PATH is not in env", () => {
|
it("does not add PATH export when PATH is not in env", () => {
|
||||||
|
|||||||
@@ -20,8 +20,10 @@ describe("gateway tool", () => {
|
|||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const kill = vi.spyOn(process, "kill").mockImplementation(() => true);
|
const kill = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||||
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
|
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||||
|
const previousProfile = process.env.CLAWDBOT_PROFILE;
|
||||||
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-test-"));
|
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-test-"));
|
||||||
process.env.CLAWDBOT_STATE_DIR = stateDir;
|
process.env.CLAWDBOT_STATE_DIR = stateDir;
|
||||||
|
process.env.CLAWDBOT_PROFILE = "isolated";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tool = createClawdbotTools({
|
const tool = createClawdbotTools({
|
||||||
@@ -47,7 +49,9 @@ describe("gateway tool", () => {
|
|||||||
payload?: { kind?: string; doctorHint?: string | null };
|
payload?: { kind?: string; doctorHint?: string | null };
|
||||||
};
|
};
|
||||||
expect(parsed.payload?.kind).toBe("restart");
|
expect(parsed.payload?.kind).toBe("restart");
|
||||||
expect(parsed.payload?.doctorHint).toBe("Run: clawdbot doctor --non-interactive");
|
expect(parsed.payload?.doctorHint).toBe(
|
||||||
|
"Run: clawdbot --profile isolated doctor --non-interactive",
|
||||||
|
);
|
||||||
|
|
||||||
expect(kill).not.toHaveBeenCalled();
|
expect(kill).not.toHaveBeenCalled();
|
||||||
await vi.runAllTimersAsync();
|
await vi.runAllTimersAsync();
|
||||||
@@ -60,6 +64,11 @@ describe("gateway tool", () => {
|
|||||||
} else {
|
} else {
|
||||||
process.env.CLAWDBOT_STATE_DIR = previousStateDir;
|
process.env.CLAWDBOT_STATE_DIR = previousStateDir;
|
||||||
}
|
}
|
||||||
|
if (previousProfile === undefined) {
|
||||||
|
delete process.env.CLAWDBOT_PROFILE;
|
||||||
|
} else {
|
||||||
|
process.env.CLAWDBOT_PROFILE = previousProfile;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,11 +20,8 @@ import { createWebFetchTool, createWebSearchTool } from "./tools/web-tools.js";
|
|||||||
import { createTtsTool } from "./tools/tts-tool.js";
|
import { createTtsTool } from "./tools/tts-tool.js";
|
||||||
|
|
||||||
export function createClawdbotTools(options?: {
|
export function createClawdbotTools(options?: {
|
||||||
browserControlUrl?: string;
|
sandboxBrowserBridgeUrl?: string;
|
||||||
allowHostBrowserControl?: boolean;
|
allowHostBrowserControl?: boolean;
|
||||||
allowedControlUrls?: string[];
|
|
||||||
allowedControlHosts?: string[];
|
|
||||||
allowedControlPorts?: number[];
|
|
||||||
agentSessionKey?: string;
|
agentSessionKey?: string;
|
||||||
agentChannel?: GatewayMessageChannel;
|
agentChannel?: GatewayMessageChannel;
|
||||||
agentAccountId?: string;
|
agentAccountId?: string;
|
||||||
@@ -75,11 +72,8 @@ export function createClawdbotTools(options?: {
|
|||||||
});
|
});
|
||||||
const tools: AnyAgentTool[] = [
|
const tools: AnyAgentTool[] = [
|
||||||
createBrowserTool({
|
createBrowserTool({
|
||||||
defaultControlUrl: options?.browserControlUrl,
|
sandboxBridgeUrl: options?.sandboxBrowserBridgeUrl,
|
||||||
allowHostControl: options?.allowHostBrowserControl,
|
allowHostControl: options?.allowHostBrowserControl,
|
||||||
allowedControlUrls: options?.allowedControlUrls,
|
|
||||||
allowedControlHosts: options?.allowedControlHosts,
|
|
||||||
allowedControlPorts: options?.allowedControlPorts,
|
|
||||||
}),
|
}),
|
||||||
createCanvasTool(),
|
createCanvasTool(),
|
||||||
createNodesTool({
|
createNodesTool({
|
||||||
|
|||||||
@@ -103,5 +103,47 @@ describe("pruneHistoryForContextShare", () => {
|
|||||||
expect(pruned.droppedChunks).toBe(0);
|
expect(pruned.droppedChunks).toBe(0);
|
||||||
expect(pruned.messages.length).toBe(messages.length);
|
expect(pruned.messages.length).toBe(messages.length);
|
||||||
expect(pruned.keptTokens).toBe(estimateMessagesTokens(messages));
|
expect(pruned.keptTokens).toBe(estimateMessagesTokens(messages));
|
||||||
|
expect(pruned.droppedMessagesList).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns droppedMessagesList containing dropped messages", () => {
|
||||||
|
const messages: AgentMessage[] = [
|
||||||
|
makeMessage(1, 4000),
|
||||||
|
makeMessage(2, 4000),
|
||||||
|
makeMessage(3, 4000),
|
||||||
|
makeMessage(4, 4000),
|
||||||
|
];
|
||||||
|
const maxContextTokens = 2000; // budget is 1000 tokens (50%)
|
||||||
|
const pruned = pruneHistoryForContextShare({
|
||||||
|
messages,
|
||||||
|
maxContextTokens,
|
||||||
|
maxHistoryShare: 0.5,
|
||||||
|
parts: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(pruned.droppedChunks).toBeGreaterThan(0);
|
||||||
|
expect(pruned.droppedMessagesList.length).toBe(pruned.droppedMessages);
|
||||||
|
|
||||||
|
// All messages accounted for: kept + dropped = original
|
||||||
|
const allIds = [
|
||||||
|
...pruned.droppedMessagesList.map((m) => m.timestamp),
|
||||||
|
...pruned.messages.map((m) => m.timestamp),
|
||||||
|
].sort((a, b) => a - b);
|
||||||
|
const originalIds = messages.map((m) => m.timestamp).sort((a, b) => a - b);
|
||||||
|
expect(allIds).toEqual(originalIds);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty droppedMessagesList when no pruning needed", () => {
|
||||||
|
const messages: AgentMessage[] = [makeMessage(1, 100)];
|
||||||
|
const pruned = pruneHistoryForContextShare({
|
||||||
|
messages,
|
||||||
|
maxContextTokens: 100_000,
|
||||||
|
maxHistoryShare: 0.5,
|
||||||
|
parts: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(pruned.droppedChunks).toBe(0);
|
||||||
|
expect(pruned.droppedMessagesList).toEqual([]);
|
||||||
|
expect(pruned.messages.length).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -301,6 +301,7 @@ export function pruneHistoryForContextShare(params: {
|
|||||||
parts?: number;
|
parts?: number;
|
||||||
}): {
|
}): {
|
||||||
messages: AgentMessage[];
|
messages: AgentMessage[];
|
||||||
|
droppedMessagesList: AgentMessage[];
|
||||||
droppedChunks: number;
|
droppedChunks: number;
|
||||||
droppedMessages: number;
|
droppedMessages: number;
|
||||||
droppedTokens: number;
|
droppedTokens: number;
|
||||||
@@ -310,6 +311,7 @@ export function pruneHistoryForContextShare(params: {
|
|||||||
const maxHistoryShare = params.maxHistoryShare ?? 0.5;
|
const maxHistoryShare = params.maxHistoryShare ?? 0.5;
|
||||||
const budgetTokens = Math.max(1, Math.floor(params.maxContextTokens * maxHistoryShare));
|
const budgetTokens = Math.max(1, Math.floor(params.maxContextTokens * maxHistoryShare));
|
||||||
let keptMessages = params.messages;
|
let keptMessages = params.messages;
|
||||||
|
const allDroppedMessages: AgentMessage[] = [];
|
||||||
let droppedChunks = 0;
|
let droppedChunks = 0;
|
||||||
let droppedMessages = 0;
|
let droppedMessages = 0;
|
||||||
let droppedTokens = 0;
|
let droppedTokens = 0;
|
||||||
@@ -323,11 +325,13 @@ export function pruneHistoryForContextShare(params: {
|
|||||||
droppedChunks += 1;
|
droppedChunks += 1;
|
||||||
droppedMessages += dropped.length;
|
droppedMessages += dropped.length;
|
||||||
droppedTokens += estimateMessagesTokens(dropped);
|
droppedTokens += estimateMessagesTokens(dropped);
|
||||||
|
allDroppedMessages.push(...dropped);
|
||||||
keptMessages = rest.flat();
|
keptMessages = rest.flat();
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages: keptMessages,
|
messages: keptMessages,
|
||||||
|
droppedMessagesList: allDroppedMessages,
|
||||||
droppedChunks,
|
droppedChunks,
|
||||||
droppedMessages,
|
droppedMessages,
|
||||||
droppedTokens,
|
droppedTokens,
|
||||||
|
|||||||
@@ -119,9 +119,16 @@ function mergeConfig(
|
|||||||
const provider = overrides?.provider ?? defaults?.provider ?? "auto";
|
const provider = overrides?.provider ?? defaults?.provider ?? "auto";
|
||||||
const defaultRemote = defaults?.remote;
|
const defaultRemote = defaults?.remote;
|
||||||
const overrideRemote = overrides?.remote;
|
const overrideRemote = overrides?.remote;
|
||||||
const hasRemote = Boolean(defaultRemote || overrideRemote);
|
const hasRemoteConfig = Boolean(
|
||||||
|
overrideRemote?.baseUrl ||
|
||||||
|
overrideRemote?.apiKey ||
|
||||||
|
overrideRemote?.headers ||
|
||||||
|
defaultRemote?.baseUrl ||
|
||||||
|
defaultRemote?.apiKey ||
|
||||||
|
defaultRemote?.headers,
|
||||||
|
);
|
||||||
const includeRemote =
|
const includeRemote =
|
||||||
hasRemote || provider === "openai" || provider === "gemini" || provider === "auto";
|
hasRemoteConfig || provider === "openai" || provider === "gemini" || provider === "auto";
|
||||||
const batch = {
|
const batch = {
|
||||||
enabled: overrideRemote?.batch?.enabled ?? defaultRemote?.batch?.enabled ?? true,
|
enabled: overrideRemote?.batch?.enabled ?? defaultRemote?.batch?.enabled ?? true,
|
||||||
wait: overrideRemote?.batch?.wait ?? defaultRemote?.batch?.wait ?? true,
|
wait: overrideRemote?.batch?.wait ?? defaultRemote?.batch?.wait ?? true,
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import type { AuthProfileStore } from "./auth-profiles.js";
|
||||||
|
import { saveAuthProfileStore } from "./auth-profiles.js";
|
||||||
|
import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js";
|
||||||
import { runWithModelFallback } from "./model-fallback.js";
|
import { runWithModelFallback } from "./model-fallback.js";
|
||||||
|
|
||||||
function makeCfg(overrides: Partial<ClawdbotConfig> = {}): ClawdbotConfig {
|
function makeCfg(overrides: Partial<ClawdbotConfig> = {}): ClawdbotConfig {
|
||||||
@@ -117,6 +124,122 @@ describe("runWithModelFallback", () => {
|
|||||||
expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
|
expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("skips providers when all profiles are in cooldown", async () => {
|
||||||
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-auth-"));
|
||||||
|
const provider = `cooldown-test-${crypto.randomUUID()}`;
|
||||||
|
const profileId = `${provider}:default`;
|
||||||
|
|
||||||
|
const store: AuthProfileStore = {
|
||||||
|
version: AUTH_STORE_VERSION,
|
||||||
|
profiles: {
|
||||||
|
[profileId]: {
|
||||||
|
type: "api_key",
|
||||||
|
provider,
|
||||||
|
key: "test-key",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
usageStats: {
|
||||||
|
[profileId]: {
|
||||||
|
cooldownUntil: Date.now() + 60_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
saveAuthProfileStore(store, tempDir);
|
||||||
|
|
||||||
|
const cfg = makeCfg({
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
model: {
|
||||||
|
primary: `${provider}/m1`,
|
||||||
|
fallbacks: ["fallback/ok-model"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const run = vi.fn().mockImplementation(async (providerId, modelId) => {
|
||||||
|
if (providerId === "fallback") return "ok";
|
||||||
|
throw new Error(`unexpected provider: ${providerId}/${modelId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await runWithModelFallback({
|
||||||
|
cfg,
|
||||||
|
provider,
|
||||||
|
model: "m1",
|
||||||
|
agentDir: tempDir,
|
||||||
|
run,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.result).toBe("ok");
|
||||||
|
expect(run.mock.calls).toEqual([["fallback", "ok-model"]]);
|
||||||
|
expect(result.attempts[0]?.reason).toBe("rate_limit");
|
||||||
|
} finally {
|
||||||
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not skip when any profile is available", async () => {
|
||||||
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-auth-"));
|
||||||
|
const provider = `cooldown-mixed-${crypto.randomUUID()}`;
|
||||||
|
const profileA = `${provider}:a`;
|
||||||
|
const profileB = `${provider}:b`;
|
||||||
|
|
||||||
|
const store: AuthProfileStore = {
|
||||||
|
version: AUTH_STORE_VERSION,
|
||||||
|
profiles: {
|
||||||
|
[profileA]: {
|
||||||
|
type: "api_key",
|
||||||
|
provider,
|
||||||
|
key: "key-a",
|
||||||
|
},
|
||||||
|
[profileB]: {
|
||||||
|
type: "api_key",
|
||||||
|
provider,
|
||||||
|
key: "key-b",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
usageStats: {
|
||||||
|
[profileA]: {
|
||||||
|
cooldownUntil: Date.now() + 60_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
saveAuthProfileStore(store, tempDir);
|
||||||
|
|
||||||
|
const cfg = makeCfg({
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
model: {
|
||||||
|
primary: `${provider}/m1`,
|
||||||
|
fallbacks: ["fallback/ok-model"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const run = vi.fn().mockImplementation(async (providerId) => {
|
||||||
|
if (providerId === provider) return "ok";
|
||||||
|
return "unexpected";
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await runWithModelFallback({
|
||||||
|
cfg,
|
||||||
|
provider,
|
||||||
|
model: "m1",
|
||||||
|
agentDir: tempDir,
|
||||||
|
run,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.result).toBe("ok");
|
||||||
|
expect(run.mock.calls).toEqual([[provider, "m1"]]);
|
||||||
|
expect(result.attempts).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("does not append configured primary when fallbacksOverride is set", async () => {
|
it("does not append configured primary when fallbacksOverride is set", async () => {
|
||||||
const cfg = makeCfg({
|
const cfg = makeCfg({
|
||||||
agents: {
|
agents: {
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ import {
|
|||||||
resolveModelRefFromString,
|
resolveModelRefFromString,
|
||||||
} from "./model-selection.js";
|
} from "./model-selection.js";
|
||||||
import type { FailoverReason } from "./pi-embedded-helpers.js";
|
import type { FailoverReason } from "./pi-embedded-helpers.js";
|
||||||
|
import {
|
||||||
|
ensureAuthProfileStore,
|
||||||
|
isProfileInCooldown,
|
||||||
|
resolveAuthProfileOrder,
|
||||||
|
} from "./auth-profiles.js";
|
||||||
|
|
||||||
type ModelCandidate = {
|
type ModelCandidate = {
|
||||||
provider: string;
|
provider: string;
|
||||||
@@ -189,6 +194,7 @@ export async function runWithModelFallback<T>(params: {
|
|||||||
cfg: ClawdbotConfig | undefined;
|
cfg: ClawdbotConfig | undefined;
|
||||||
provider: string;
|
provider: string;
|
||||||
model: string;
|
model: string;
|
||||||
|
agentDir?: string;
|
||||||
/** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */
|
/** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */
|
||||||
fallbacksOverride?: string[];
|
fallbacksOverride?: string[];
|
||||||
run: (provider: string, model: string) => Promise<T>;
|
run: (provider: string, model: string) => Promise<T>;
|
||||||
@@ -211,11 +217,33 @@ export async function runWithModelFallback<T>(params: {
|
|||||||
model: params.model,
|
model: params.model,
|
||||||
fallbacksOverride: params.fallbacksOverride,
|
fallbacksOverride: params.fallbacksOverride,
|
||||||
});
|
});
|
||||||
|
const authStore = params.cfg
|
||||||
|
? ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false })
|
||||||
|
: null;
|
||||||
const attempts: FallbackAttempt[] = [];
|
const attempts: FallbackAttempt[] = [];
|
||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
|
|
||||||
for (let i = 0; i < candidates.length; i += 1) {
|
for (let i = 0; i < candidates.length; i += 1) {
|
||||||
const candidate = candidates[i] as ModelCandidate;
|
const candidate = candidates[i] as ModelCandidate;
|
||||||
|
if (authStore) {
|
||||||
|
const profileIds = resolveAuthProfileOrder({
|
||||||
|
cfg: params.cfg,
|
||||||
|
store: authStore,
|
||||||
|
provider: candidate.provider,
|
||||||
|
});
|
||||||
|
const isAnyProfileAvailable = profileIds.some((id) => !isProfileInCooldown(authStore, id));
|
||||||
|
|
||||||
|
if (profileIds.length > 0 && !isAnyProfileAvailable) {
|
||||||
|
// All profiles for this provider are in cooldown; skip without attempting
|
||||||
|
attempts.push({
|
||||||
|
provider: candidate.provider,
|
||||||
|
model: candidate.model,
|
||||||
|
error: `Provider ${candidate.provider} is in cooldown (all profiles unavailable)`,
|
||||||
|
reason: "rate_limit",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const result = await params.run(candidate.provider, candidate.model);
|
const result = await params.run(candidate.provider, candidate.model);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ describe("buildEmbeddedSandboxInfo", () => {
|
|||||||
},
|
},
|
||||||
browserAllowHostControl: true,
|
browserAllowHostControl: true,
|
||||||
browser: {
|
browser: {
|
||||||
controlUrl: "http://localhost:9222",
|
bridgeUrl: "http://localhost:9222",
|
||||||
noVncUrl: "http://localhost:6080",
|
noVncUrl: "http://localhost:6080",
|
||||||
containerName: "clawdbot-sbx-browser-test",
|
containerName: "clawdbot-sbx-browser-test",
|
||||||
},
|
},
|
||||||
@@ -138,7 +138,7 @@ describe("buildEmbeddedSandboxInfo", () => {
|
|||||||
workspaceDir: "/tmp/clawdbot-sandbox",
|
workspaceDir: "/tmp/clawdbot-sandbox",
|
||||||
workspaceAccess: "none",
|
workspaceAccess: "none",
|
||||||
agentWorkspaceMount: undefined,
|
agentWorkspaceMount: undefined,
|
||||||
browserControlUrl: "http://localhost:9222",
|
browserBridgeUrl: "http://localhost:9222",
|
||||||
browserNoVncUrl: "http://localhost:6080",
|
browserNoVncUrl: "http://localhost:6080",
|
||||||
hostBrowserAllowed: true,
|
hostBrowserAllowed: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import "./test-helpers/fast-coding-tools.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { ensureClawdbotModelsJson } from "./models-config.js";
|
import { ensureClawdbotModelsJson } from "./models-config.js";
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { SessionManager } from "@mariozechner/pi-coding-agent";
|
|||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import { resolveContextWindowInfo } from "../context-window-guard.js";
|
import { resolveContextWindowInfo } from "../context-window-guard.js";
|
||||||
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
|
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
|
||||||
|
import { setCompactionSafeguardRuntime } from "../pi-extensions/compaction-safeguard-runtime.js";
|
||||||
import { setContextPruningRuntime } from "../pi-extensions/context-pruning/runtime.js";
|
import { setContextPruningRuntime } from "../pi-extensions/context-pruning/runtime.js";
|
||||||
import { computeEffectiveSettings } from "../pi-extensions/context-pruning/settings.js";
|
import { computeEffectiveSettings } from "../pi-extensions/context-pruning/settings.js";
|
||||||
import { makeToolPrunablePredicate } from "../pi-extensions/context-pruning/tools.js";
|
import { makeToolPrunablePredicate } from "../pi-extensions/context-pruning/tools.js";
|
||||||
@@ -75,6 +76,10 @@ export function buildEmbeddedExtensionPaths(params: {
|
|||||||
}): string[] {
|
}): string[] {
|
||||||
const paths: string[] = [];
|
const paths: string[] = [];
|
||||||
if (resolveCompactionMode(params.cfg) === "safeguard") {
|
if (resolveCompactionMode(params.cfg) === "safeguard") {
|
||||||
|
const compactionCfg = params.cfg?.agents?.defaults?.compaction;
|
||||||
|
setCompactionSafeguardRuntime(params.sessionManager, {
|
||||||
|
maxHistoryShare: compactionCfg?.maxHistoryShare,
|
||||||
|
});
|
||||||
paths.push(resolvePiExtensionPath("compaction-safeguard"));
|
paths.push(resolvePiExtensionPath("compaction-safeguard"));
|
||||||
}
|
}
|
||||||
const pruning = buildContextPruningExtension(params);
|
const pruning = buildContextPruningExtension(params);
|
||||||
|
|||||||
@@ -215,6 +215,10 @@ export async function runEmbeddedAttempt(
|
|||||||
groupChannel: params.groupChannel,
|
groupChannel: params.groupChannel,
|
||||||
groupSpace: params.groupSpace,
|
groupSpace: params.groupSpace,
|
||||||
spawnedBy: params.spawnedBy,
|
spawnedBy: params.spawnedBy,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
sessionKey: params.sessionKey ?? params.sessionId,
|
sessionKey: params.sessionKey ?? params.sessionId,
|
||||||
agentDir,
|
agentDir,
|
||||||
workspaceDir: effectiveWorkspace,
|
workspaceDir: effectiveWorkspace,
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ export type RunEmbeddedPiAgentParams = {
|
|||||||
groupSpace?: string | null;
|
groupSpace?: string | null;
|
||||||
/** Parent session key for subagent policy inheritance. */
|
/** Parent session key for subagent policy inheritance. */
|
||||||
spawnedBy?: string | null;
|
spawnedBy?: string | null;
|
||||||
|
senderId?: string | null;
|
||||||
|
senderName?: string | null;
|
||||||
|
senderUsername?: string | null;
|
||||||
|
senderE164?: string | null;
|
||||||
/** Current channel ID for auto-threading (Slack). */
|
/** Current channel ID for auto-threading (Slack). */
|
||||||
currentChannelId?: string;
|
currentChannelId?: string;
|
||||||
/** Current thread timestamp for auto-threading (Slack). */
|
/** Current thread timestamp for auto-threading (Slack). */
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ export type EmbeddedRunAttemptParams = {
|
|||||||
groupSpace?: string | null;
|
groupSpace?: string | null;
|
||||||
/** Parent session key for subagent policy inheritance. */
|
/** Parent session key for subagent policy inheritance. */
|
||||||
spawnedBy?: string | null;
|
spawnedBy?: string | null;
|
||||||
|
senderId?: string | null;
|
||||||
|
senderName?: string | null;
|
||||||
|
senderUsername?: string | null;
|
||||||
|
senderE164?: string | null;
|
||||||
currentChannelId?: string;
|
currentChannelId?: string;
|
||||||
currentThreadTs?: string;
|
currentThreadTs?: string;
|
||||||
replyToMode?: "off" | "first" | "all";
|
replyToMode?: "off" | "first" | "all";
|
||||||
|
|||||||
@@ -13,12 +13,9 @@ export function buildEmbeddedSandboxInfo(
|
|||||||
workspaceDir: sandbox.workspaceDir,
|
workspaceDir: sandbox.workspaceDir,
|
||||||
workspaceAccess: sandbox.workspaceAccess,
|
workspaceAccess: sandbox.workspaceAccess,
|
||||||
agentWorkspaceMount: sandbox.workspaceAccess === "ro" ? "/agent" : undefined,
|
agentWorkspaceMount: sandbox.workspaceAccess === "ro" ? "/agent" : undefined,
|
||||||
browserControlUrl: sandbox.browser?.controlUrl,
|
browserBridgeUrl: sandbox.browser?.bridgeUrl,
|
||||||
browserNoVncUrl: sandbox.browser?.noVncUrl,
|
browserNoVncUrl: sandbox.browser?.noVncUrl,
|
||||||
hostBrowserAllowed: sandbox.browserAllowHostControl,
|
hostBrowserAllowed: sandbox.browserAllowHostControl,
|
||||||
allowedControlUrls: sandbox.browserAllowedControlUrls,
|
|
||||||
allowedControlHosts: sandbox.browserAllowedControlHosts,
|
|
||||||
allowedControlPorts: sandbox.browserAllowedControlPorts,
|
|
||||||
...(elevatedAllowed
|
...(elevatedAllowed
|
||||||
? {
|
? {
|
||||||
elevated: {
|
elevated: {
|
||||||
|
|||||||
@@ -69,12 +69,9 @@ export type EmbeddedSandboxInfo = {
|
|||||||
workspaceDir?: string;
|
workspaceDir?: string;
|
||||||
workspaceAccess?: "none" | "ro" | "rw";
|
workspaceAccess?: "none" | "ro" | "rw";
|
||||||
agentWorkspaceMount?: string;
|
agentWorkspaceMount?: string;
|
||||||
browserControlUrl?: string;
|
browserBridgeUrl?: string;
|
||||||
browserNoVncUrl?: string;
|
browserNoVncUrl?: string;
|
||||||
hostBrowserAllowed?: boolean;
|
hostBrowserAllowed?: boolean;
|
||||||
allowedControlUrls?: string[];
|
|
||||||
allowedControlHosts?: string[];
|
|
||||||
allowedControlPorts?: number[];
|
|
||||||
elevated?: {
|
elevated?: {
|
||||||
allowed: boolean;
|
allowed: boolean;
|
||||||
defaultLevel: "on" | "off" | "ask" | "full";
|
defaultLevel: "on" | "off" | "ask" | "full";
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
export type CompactionSafeguardRuntimeValue = {
|
||||||
|
maxHistoryShare?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Session-scoped runtime registry keyed by object identity.
|
||||||
|
// Follows the same WeakMap pattern as context-pruning/runtime.ts.
|
||||||
|
const REGISTRY = new WeakMap<object, CompactionSafeguardRuntimeValue>();
|
||||||
|
|
||||||
|
export function setCompactionSafeguardRuntime(
|
||||||
|
sessionManager: unknown,
|
||||||
|
value: CompactionSafeguardRuntimeValue | null,
|
||||||
|
): void {
|
||||||
|
if (!sessionManager || typeof sessionManager !== "object") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = sessionManager as object;
|
||||||
|
if (value === null) {
|
||||||
|
REGISTRY.delete(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
REGISTRY.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCompactionSafeguardRuntime(
|
||||||
|
sessionManager: unknown,
|
||||||
|
): CompactionSafeguardRuntimeValue | null {
|
||||||
|
if (!sessionManager || typeof sessionManager !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return REGISTRY.get(sessionManager as object) ?? null;
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getCompactionSafeguardRuntime,
|
||||||
|
setCompactionSafeguardRuntime,
|
||||||
|
} from "./compaction-safeguard-runtime.js";
|
||||||
import { __testing } from "./compaction-safeguard.js";
|
import { __testing } from "./compaction-safeguard.js";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -208,3 +212,41 @@ describe("isOversizedForSummary", () => {
|
|||||||
expect(typeof isOversized).toBe("boolean");
|
expect(typeof isOversized).toBe("boolean");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("compaction-safeguard runtime registry", () => {
|
||||||
|
it("stores and retrieves config by session manager identity", () => {
|
||||||
|
const sm = {};
|
||||||
|
setCompactionSafeguardRuntime(sm, { maxHistoryShare: 0.3 });
|
||||||
|
const runtime = getCompactionSafeguardRuntime(sm);
|
||||||
|
expect(runtime).toEqual({ maxHistoryShare: 0.3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for unknown session manager", () => {
|
||||||
|
const sm = {};
|
||||||
|
expect(getCompactionSafeguardRuntime(sm)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears entry when value is null", () => {
|
||||||
|
const sm = {};
|
||||||
|
setCompactionSafeguardRuntime(sm, { maxHistoryShare: 0.7 });
|
||||||
|
expect(getCompactionSafeguardRuntime(sm)).not.toBeNull();
|
||||||
|
setCompactionSafeguardRuntime(sm, null);
|
||||||
|
expect(getCompactionSafeguardRuntime(sm)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores non-object session managers", () => {
|
||||||
|
setCompactionSafeguardRuntime(null, { maxHistoryShare: 0.5 });
|
||||||
|
expect(getCompactionSafeguardRuntime(null)).toBeNull();
|
||||||
|
setCompactionSafeguardRuntime(undefined, { maxHistoryShare: 0.5 });
|
||||||
|
expect(getCompactionSafeguardRuntime(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isolates different session managers", () => {
|
||||||
|
const sm1 = {};
|
||||||
|
const sm2 = {};
|
||||||
|
setCompactionSafeguardRuntime(sm1, { maxHistoryShare: 0.3 });
|
||||||
|
setCompactionSafeguardRuntime(sm2, { maxHistoryShare: 0.8 });
|
||||||
|
expect(getCompactionSafeguardRuntime(sm1)).toEqual({ maxHistoryShare: 0.3 });
|
||||||
|
expect(getCompactionSafeguardRuntime(sm2)).toEqual({ maxHistoryShare: 0.8 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
resolveContextWindowTokens,
|
resolveContextWindowTokens,
|
||||||
summarizeInStages,
|
summarizeInStages,
|
||||||
} from "../compaction.js";
|
} from "../compaction.js";
|
||||||
|
import { getCompactionSafeguardRuntime } from "./compaction-safeguard-runtime.js";
|
||||||
const FALLBACK_SUMMARY =
|
const FALLBACK_SUMMARY =
|
||||||
"Summary unavailable due to context limits. Older messages were truncated.";
|
"Summary unavailable due to context limits. Older messages were truncated.";
|
||||||
const TURN_PREFIX_INSTRUCTIONS =
|
const TURN_PREFIX_INSTRUCTIONS =
|
||||||
@@ -174,21 +175,28 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
|||||||
const turnPrefixMessages = preparation.turnPrefixMessages ?? [];
|
const turnPrefixMessages = preparation.turnPrefixMessages ?? [];
|
||||||
let messagesToSummarize = preparation.messagesToSummarize;
|
let messagesToSummarize = preparation.messagesToSummarize;
|
||||||
|
|
||||||
|
const runtime = getCompactionSafeguardRuntime(ctx.sessionManager);
|
||||||
|
const maxHistoryShare = runtime?.maxHistoryShare ?? 0.5;
|
||||||
|
|
||||||
const tokensBefore =
|
const tokensBefore =
|
||||||
typeof preparation.tokensBefore === "number" && Number.isFinite(preparation.tokensBefore)
|
typeof preparation.tokensBefore === "number" && Number.isFinite(preparation.tokensBefore)
|
||||||
? preparation.tokensBefore
|
? preparation.tokensBefore
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
let droppedSummary: string | undefined;
|
||||||
|
|
||||||
if (tokensBefore !== undefined) {
|
if (tokensBefore !== undefined) {
|
||||||
const summarizableTokens =
|
const summarizableTokens =
|
||||||
estimateMessagesTokens(messagesToSummarize) + estimateMessagesTokens(turnPrefixMessages);
|
estimateMessagesTokens(messagesToSummarize) + estimateMessagesTokens(turnPrefixMessages);
|
||||||
const newContentTokens = Math.max(0, Math.floor(tokensBefore - summarizableTokens));
|
const newContentTokens = Math.max(0, Math.floor(tokensBefore - summarizableTokens));
|
||||||
const maxHistoryTokens = Math.floor(contextWindowTokens * 0.5);
|
// Apply SAFETY_MARGIN so token underestimates don't trigger unnecessary pruning
|
||||||
|
const maxHistoryTokens = Math.floor(contextWindowTokens * maxHistoryShare * SAFETY_MARGIN);
|
||||||
|
|
||||||
if (newContentTokens > maxHistoryTokens) {
|
if (newContentTokens > maxHistoryTokens) {
|
||||||
const pruned = pruneHistoryForContextShare({
|
const pruned = pruneHistoryForContextShare({
|
||||||
messages: messagesToSummarize,
|
messages: messagesToSummarize,
|
||||||
maxContextTokens: contextWindowTokens,
|
maxContextTokens: contextWindowTokens,
|
||||||
maxHistoryShare: 0.5,
|
maxHistoryShare,
|
||||||
parts: 2,
|
parts: 2,
|
||||||
});
|
});
|
||||||
if (pruned.droppedChunks > 0) {
|
if (pruned.droppedChunks > 0) {
|
||||||
@@ -200,6 +208,37 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
|||||||
`(${pruned.droppedMessages} messages) to fit history budget.`,
|
`(${pruned.droppedMessages} messages) to fit history budget.`,
|
||||||
);
|
);
|
||||||
messagesToSummarize = pruned.messages;
|
messagesToSummarize = pruned.messages;
|
||||||
|
|
||||||
|
// Summarize dropped messages so context isn't lost
|
||||||
|
if (pruned.droppedMessagesList.length > 0) {
|
||||||
|
try {
|
||||||
|
const droppedChunkRatio = computeAdaptiveChunkRatio(
|
||||||
|
pruned.droppedMessagesList,
|
||||||
|
contextWindowTokens,
|
||||||
|
);
|
||||||
|
const droppedMaxChunkTokens = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor(contextWindowTokens * droppedChunkRatio),
|
||||||
|
);
|
||||||
|
droppedSummary = await summarizeInStages({
|
||||||
|
messages: pruned.droppedMessagesList,
|
||||||
|
model,
|
||||||
|
apiKey,
|
||||||
|
signal,
|
||||||
|
reserveTokens: Math.max(1, Math.floor(preparation.settings.reserveTokens)),
|
||||||
|
maxChunkTokens: droppedMaxChunkTokens,
|
||||||
|
contextWindow: contextWindowTokens,
|
||||||
|
customInstructions,
|
||||||
|
previousSummary: preparation.previousSummary,
|
||||||
|
});
|
||||||
|
} catch (droppedError) {
|
||||||
|
console.warn(
|
||||||
|
`Compaction safeguard: failed to summarize dropped messages, continuing without: ${
|
||||||
|
droppedError instanceof Error ? droppedError.message : String(droppedError)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -210,6 +249,10 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
|||||||
const maxChunkTokens = Math.max(1, Math.floor(contextWindowTokens * adaptiveRatio));
|
const maxChunkTokens = Math.max(1, Math.floor(contextWindowTokens * adaptiveRatio));
|
||||||
const reserveTokens = Math.max(1, Math.floor(preparation.settings.reserveTokens));
|
const reserveTokens = Math.max(1, Math.floor(preparation.settings.reserveTokens));
|
||||||
|
|
||||||
|
// Feed dropped-messages summary as previousSummary so the main summarization
|
||||||
|
// incorporates context from pruned messages instead of losing it entirely.
|
||||||
|
const effectivePreviousSummary = droppedSummary ?? preparation.previousSummary;
|
||||||
|
|
||||||
const historySummary = await summarizeInStages({
|
const historySummary = await summarizeInStages({
|
||||||
messages: messagesToSummarize,
|
messages: messagesToSummarize,
|
||||||
model,
|
model,
|
||||||
@@ -219,7 +262,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
|||||||
maxChunkTokens,
|
maxChunkTokens,
|
||||||
contextWindow: contextWindowTokens,
|
contextWindow: contextWindowTokens,
|
||||||
customInstructions,
|
customInstructions,
|
||||||
previousSummary: preparation.previousSummary,
|
previousSummary: effectivePreviousSummary,
|
||||||
});
|
});
|
||||||
|
|
||||||
let summary = historySummary;
|
let summary = historySummary;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import "./test-helpers/fast-coding-tools.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { createClawdbotCodingTools } from "./pi-tools.js";
|
import { createClawdbotCodingTools } from "./pi-tools.js";
|
||||||
import type { SandboxDockerConfig } from "./sandbox.js";
|
import type { SandboxDockerConfig } from "./sandbox.js";
|
||||||
@@ -270,6 +271,75 @@ describe("Agent-specific tool filtering", () => {
|
|||||||
expect(defaultNames).not.toContain("exec");
|
expect(defaultNames).not.toContain("exec");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should apply per-sender tool policies for group tools", () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
channels: {
|
||||||
|
whatsapp: {
|
||||||
|
groups: {
|
||||||
|
"*": {
|
||||||
|
tools: { allow: ["read"] },
|
||||||
|
toolsBySender: {
|
||||||
|
alice: { allow: ["read", "exec"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const aliceTools = createClawdbotCodingTools({
|
||||||
|
config: cfg,
|
||||||
|
sessionKey: "agent:main:whatsapp:group:family",
|
||||||
|
senderId: "alice",
|
||||||
|
workspaceDir: "/tmp/test-group-sender",
|
||||||
|
agentDir: "/tmp/agent-group-sender",
|
||||||
|
});
|
||||||
|
const aliceNames = aliceTools.map((t) => t.name);
|
||||||
|
expect(aliceNames).toContain("read");
|
||||||
|
expect(aliceNames).toContain("exec");
|
||||||
|
|
||||||
|
const bobTools = createClawdbotCodingTools({
|
||||||
|
config: cfg,
|
||||||
|
sessionKey: "agent:main:whatsapp:group:family",
|
||||||
|
senderId: "bob",
|
||||||
|
workspaceDir: "/tmp/test-group-sender-bob",
|
||||||
|
agentDir: "/tmp/agent-group-sender",
|
||||||
|
});
|
||||||
|
const bobNames = bobTools.map((t) => t.name);
|
||||||
|
expect(bobNames).toContain("read");
|
||||||
|
expect(bobNames).not.toContain("exec");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not let default sender policy override group tools", () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
channels: {
|
||||||
|
whatsapp: {
|
||||||
|
groups: {
|
||||||
|
"*": {
|
||||||
|
toolsBySender: {
|
||||||
|
admin: { allow: ["read", "exec"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
locked: {
|
||||||
|
tools: { allow: ["read"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const adminTools = createClawdbotCodingTools({
|
||||||
|
config: cfg,
|
||||||
|
sessionKey: "agent:main:whatsapp:group:locked",
|
||||||
|
senderId: "admin",
|
||||||
|
workspaceDir: "/tmp/test-group-default-override",
|
||||||
|
agentDir: "/tmp/agent-group-default-override",
|
||||||
|
});
|
||||||
|
const adminNames = adminTools.map((t) => t.name);
|
||||||
|
expect(adminNames).toContain("read");
|
||||||
|
expect(adminNames).not.toContain("exec");
|
||||||
|
});
|
||||||
|
|
||||||
it("should resolve telegram group tool policy for topic session keys", () => {
|
it("should resolve telegram group tool policy for topic session keys", () => {
|
||||||
const cfg: ClawdbotConfig = {
|
const cfg: ClawdbotConfig = {
|
||||||
channels: {
|
channels: {
|
||||||
|
|||||||
-1
@@ -96,7 +96,6 @@ describe("createClawdbotCodingTools", () => {
|
|||||||
};
|
};
|
||||||
expect(parameters.properties?.action).toBeDefined();
|
expect(parameters.properties?.action).toBeDefined();
|
||||||
expect(parameters.properties?.target).toBeDefined();
|
expect(parameters.properties?.target).toBeDefined();
|
||||||
expect(parameters.properties?.controlUrl).toBeDefined();
|
|
||||||
expect(parameters.properties?.targetUrl).toBeDefined();
|
expect(parameters.properties?.targetUrl).toBeDefined();
|
||||||
expect(parameters.properties?.request).toBeDefined();
|
expect(parameters.properties?.request).toBeDefined();
|
||||||
expect(parameters.required ?? []).toContain("action");
|
expect(parameters.required ?? []).toContain("action");
|
||||||
|
|||||||
@@ -233,6 +233,10 @@ export function resolveGroupToolPolicy(params: {
|
|||||||
groupChannel?: string | null;
|
groupChannel?: string | null;
|
||||||
groupSpace?: string | null;
|
groupSpace?: string | null;
|
||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
|
senderId?: string | null;
|
||||||
|
senderName?: string | null;
|
||||||
|
senderUsername?: string | null;
|
||||||
|
senderE164?: string | null;
|
||||||
}): SandboxToolPolicy | undefined {
|
}): SandboxToolPolicy | undefined {
|
||||||
if (!params.config) return undefined;
|
if (!params.config) return undefined;
|
||||||
const sessionContext = resolveGroupContextFromSessionKey(params.sessionKey);
|
const sessionContext = resolveGroupContextFromSessionKey(params.sessionKey);
|
||||||
@@ -255,12 +259,20 @@ export function resolveGroupToolPolicy(params: {
|
|||||||
groupChannel: params.groupChannel,
|
groupChannel: params.groupChannel,
|
||||||
groupSpace: params.groupSpace,
|
groupSpace: params.groupSpace,
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
}) ??
|
}) ??
|
||||||
resolveChannelGroupToolsPolicy({
|
resolveChannelGroupToolsPolicy({
|
||||||
cfg: params.config,
|
cfg: params.config,
|
||||||
channel,
|
channel,
|
||||||
groupId,
|
groupId,
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
});
|
});
|
||||||
return pickToolPolicy(toolsConfig);
|
return pickToolPolicy(toolsConfig);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,6 +140,10 @@ export function createClawdbotCodingTools(options?: {
|
|||||||
groupSpace?: string | null;
|
groupSpace?: string | null;
|
||||||
/** Parent session key for subagent group policy inheritance. */
|
/** Parent session key for subagent group policy inheritance. */
|
||||||
spawnedBy?: string | null;
|
spawnedBy?: string | null;
|
||||||
|
senderId?: string | null;
|
||||||
|
senderName?: string | null;
|
||||||
|
senderUsername?: string | null;
|
||||||
|
senderE164?: string | null;
|
||||||
/** Reply-to mode for Slack auto-threading. */
|
/** Reply-to mode for Slack auto-threading. */
|
||||||
replyToMode?: "off" | "first" | "all";
|
replyToMode?: "off" | "first" | "all";
|
||||||
/** Mutable ref to track if a reply was sent (for "first" mode). */
|
/** Mutable ref to track if a reply was sent (for "first" mode). */
|
||||||
@@ -174,6 +178,10 @@ export function createClawdbotCodingTools(options?: {
|
|||||||
groupChannel: options?.groupChannel,
|
groupChannel: options?.groupChannel,
|
||||||
groupSpace: options?.groupSpace,
|
groupSpace: options?.groupSpace,
|
||||||
accountId: options?.agentAccountId,
|
accountId: options?.agentAccountId,
|
||||||
|
senderId: options?.senderId,
|
||||||
|
senderName: options?.senderName,
|
||||||
|
senderUsername: options?.senderUsername,
|
||||||
|
senderE164: options?.senderE164,
|
||||||
});
|
});
|
||||||
const profilePolicy = resolveToolProfilePolicy(profile);
|
const profilePolicy = resolveToolProfilePolicy(profile);
|
||||||
const providerProfilePolicy = resolveToolProfilePolicy(providerProfile);
|
const providerProfilePolicy = resolveToolProfilePolicy(providerProfile);
|
||||||
@@ -294,11 +302,8 @@ export function createClawdbotCodingTools(options?: {
|
|||||||
// Channel docking: include channel-defined agent tools (login, etc.).
|
// Channel docking: include channel-defined agent tools (login, etc.).
|
||||||
...listChannelAgentTools({ cfg: options?.config }),
|
...listChannelAgentTools({ cfg: options?.config }),
|
||||||
...createClawdbotTools({
|
...createClawdbotTools({
|
||||||
browserControlUrl: sandbox?.browser?.controlUrl,
|
sandboxBrowserBridgeUrl: sandbox?.browser?.bridgeUrl,
|
||||||
allowHostBrowserControl: sandbox ? sandbox.browserAllowHostControl : true,
|
allowHostBrowserControl: sandbox ? sandbox.browserAllowHostControl : true,
|
||||||
allowedControlUrls: sandbox?.browserAllowedControlUrls,
|
|
||||||
allowedControlHosts: sandbox?.browserAllowedControlHosts,
|
|
||||||
allowedControlPorts: sandbox?.browserAllowedControlPorts,
|
|
||||||
agentSessionKey: options?.sessionKey,
|
agentSessionKey: options?.sessionKey,
|
||||||
agentChannel: resolveGatewayMessageChannel(options?.messageProvider),
|
agentChannel: resolveGatewayMessageChannel(options?.messageProvider),
|
||||||
agentAccountId: options?.agentAccountId,
|
agentAccountId: options?.agentAccountId,
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { startBrowserBridgeServer, stopBrowserBridgeServer } from "../../browser/bridge-server.js";
|
import { startBrowserBridgeServer, stopBrowserBridgeServer } from "../../browser/bridge-server.js";
|
||||||
import { type ResolvedBrowserConfig, resolveProfile } from "../../browser/config.js";
|
import { type ResolvedBrowserConfig, resolveProfile } from "../../browser/config.js";
|
||||||
import { DEFAULT_CLAWD_BROWSER_COLOR } from "../../browser/constants.js";
|
import {
|
||||||
|
DEFAULT_BROWSER_EVALUATE_ENABLED,
|
||||||
|
DEFAULT_CLAWD_BROWSER_COLOR,
|
||||||
|
} from "../../browser/constants.js";
|
||||||
import { BROWSER_BRIDGES } from "./browser-bridges.js";
|
import { BROWSER_BRIDGES } from "./browser-bridges.js";
|
||||||
import { DEFAULT_SANDBOX_BROWSER_IMAGE, SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js";
|
import { DEFAULT_SANDBOX_BROWSER_IMAGE, SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js";
|
||||||
import {
|
import {
|
||||||
@@ -39,14 +42,12 @@ function buildSandboxBrowserResolvedConfig(params: {
|
|||||||
controlPort: number;
|
controlPort: number;
|
||||||
cdpPort: number;
|
cdpPort: number;
|
||||||
headless: boolean;
|
headless: boolean;
|
||||||
|
evaluateEnabled: boolean;
|
||||||
}): ResolvedBrowserConfig {
|
}): ResolvedBrowserConfig {
|
||||||
const controlHost = "127.0.0.1";
|
|
||||||
const controlUrl = `http://${controlHost}:${params.controlPort}`;
|
|
||||||
const cdpHost = "127.0.0.1";
|
const cdpHost = "127.0.0.1";
|
||||||
return {
|
return {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
controlUrl,
|
evaluateEnabled: params.evaluateEnabled,
|
||||||
controlHost,
|
|
||||||
controlPort: params.controlPort,
|
controlPort: params.controlPort,
|
||||||
cdpProtocol: "http",
|
cdpProtocol: "http",
|
||||||
cdpHost,
|
cdpHost,
|
||||||
@@ -80,6 +81,7 @@ export async function ensureSandboxBrowser(params: {
|
|||||||
workspaceDir: string;
|
workspaceDir: string;
|
||||||
agentWorkspaceDir: string;
|
agentWorkspaceDir: string;
|
||||||
cfg: SandboxConfig;
|
cfg: SandboxConfig;
|
||||||
|
evaluateEnabled?: boolean;
|
||||||
}): Promise<SandboxBrowserContext | null> {
|
}): Promise<SandboxBrowserContext | null> {
|
||||||
if (!params.cfg.browser.enabled) return null;
|
if (!params.cfg.browser.enabled) return null;
|
||||||
if (!isToolAllowed(params.cfg.tools, "browser")) return null;
|
if (!isToolAllowed(params.cfg.tools, "browser")) return null;
|
||||||
@@ -174,6 +176,7 @@ export async function ensureSandboxBrowser(params: {
|
|||||||
controlPort: 0,
|
controlPort: 0,
|
||||||
cdpPort: mappedCdp,
|
cdpPort: mappedCdp,
|
||||||
headless: params.cfg.browser.headless,
|
headless: params.cfg.browser.headless,
|
||||||
|
evaluateEnabled: params.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED,
|
||||||
}),
|
}),
|
||||||
onEnsureAttachTarget,
|
onEnsureAttachTarget,
|
||||||
});
|
});
|
||||||
@@ -204,7 +207,7 @@ export async function ensureSandboxBrowser(params: {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
controlUrl: resolvedBridge.baseUrl,
|
bridgeUrl: resolvedBridge.baseUrl,
|
||||||
noVncUrl,
|
noVncUrl,
|
||||||
containerName,
|
containerName,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -86,11 +86,6 @@ export function resolveSandboxBrowserConfig(params: {
|
|||||||
}): SandboxBrowserConfig {
|
}): SandboxBrowserConfig {
|
||||||
const agentBrowser = params.scope === "shared" ? undefined : params.agentBrowser;
|
const agentBrowser = params.scope === "shared" ? undefined : params.agentBrowser;
|
||||||
const globalBrowser = params.globalBrowser;
|
const globalBrowser = params.globalBrowser;
|
||||||
const allowedControlUrls = agentBrowser?.allowedControlUrls ?? globalBrowser?.allowedControlUrls;
|
|
||||||
const allowedControlHosts =
|
|
||||||
agentBrowser?.allowedControlHosts ?? globalBrowser?.allowedControlHosts;
|
|
||||||
const allowedControlPorts =
|
|
||||||
agentBrowser?.allowedControlPorts ?? globalBrowser?.allowedControlPorts;
|
|
||||||
return {
|
return {
|
||||||
enabled: agentBrowser?.enabled ?? globalBrowser?.enabled ?? false,
|
enabled: agentBrowser?.enabled ?? globalBrowser?.enabled ?? false,
|
||||||
image: agentBrowser?.image ?? globalBrowser?.image ?? DEFAULT_SANDBOX_BROWSER_IMAGE,
|
image: agentBrowser?.image ?? globalBrowser?.image ?? DEFAULT_SANDBOX_BROWSER_IMAGE,
|
||||||
@@ -105,18 +100,6 @@ export function resolveSandboxBrowserConfig(params: {
|
|||||||
headless: agentBrowser?.headless ?? globalBrowser?.headless ?? false,
|
headless: agentBrowser?.headless ?? globalBrowser?.headless ?? false,
|
||||||
enableNoVnc: agentBrowser?.enableNoVnc ?? globalBrowser?.enableNoVnc ?? true,
|
enableNoVnc: agentBrowser?.enableNoVnc ?? globalBrowser?.enableNoVnc ?? true,
|
||||||
allowHostControl: agentBrowser?.allowHostControl ?? globalBrowser?.allowHostControl ?? false,
|
allowHostControl: agentBrowser?.allowHostControl ?? globalBrowser?.allowHostControl ?? false,
|
||||||
allowedControlUrls:
|
|
||||||
Array.isArray(allowedControlUrls) && allowedControlUrls.length > 0
|
|
||||||
? allowedControlUrls
|
|
||||||
: undefined,
|
|
||||||
allowedControlHosts:
|
|
||||||
Array.isArray(allowedControlHosts) && allowedControlHosts.length > 0
|
|
||||||
? allowedControlHosts
|
|
||||||
: undefined,
|
|
||||||
allowedControlPorts:
|
|
||||||
Array.isArray(allowedControlPorts) && allowedControlPorts.length > 0
|
|
||||||
? allowedControlPorts
|
|
||||||
: undefined,
|
|
||||||
autoStart: agentBrowser?.autoStart ?? globalBrowser?.autoStart ?? true,
|
autoStart: agentBrowser?.autoStart ?? globalBrowser?.autoStart ?? true,
|
||||||
autoStartTimeoutMs:
|
autoStartTimeoutMs:
|
||||||
agentBrowser?.autoStartTimeoutMs ??
|
agentBrowser?.autoStartTimeoutMs ??
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
|
|||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
import { resolveUserPath } from "../../utils.js";
|
import { resolveUserPath } from "../../utils.js";
|
||||||
|
import { DEFAULT_BROWSER_EVALUATE_ENABLED } from "../../browser/constants.js";
|
||||||
import { syncSkillsToWorkspace } from "../skills.js";
|
import { syncSkillsToWorkspace } from "../skills.js";
|
||||||
import { DEFAULT_AGENT_WORKSPACE_DIR } from "../workspace.js";
|
import { DEFAULT_AGENT_WORKSPACE_DIR } from "../workspace.js";
|
||||||
import { ensureSandboxBrowser } from "./browser.js";
|
import { ensureSandboxBrowser } from "./browser.js";
|
||||||
@@ -69,11 +70,14 @@ export async function resolveSandboxContext(params: {
|
|||||||
cfg,
|
cfg,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const evaluateEnabled =
|
||||||
|
params.config?.browser?.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED;
|
||||||
const browser = await ensureSandboxBrowser({
|
const browser = await ensureSandboxBrowser({
|
||||||
scopeKey,
|
scopeKey,
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
agentWorkspaceDir,
|
agentWorkspaceDir,
|
||||||
cfg,
|
cfg,
|
||||||
|
evaluateEnabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -87,9 +91,6 @@ export async function resolveSandboxContext(params: {
|
|||||||
docker: cfg.docker,
|
docker: cfg.docker,
|
||||||
tools: cfg.tools,
|
tools: cfg.tools,
|
||||||
browserAllowHostControl: cfg.browser.allowHostControl,
|
browserAllowHostControl: cfg.browser.allowHostControl,
|
||||||
browserAllowedControlUrls: cfg.browser.allowedControlUrls,
|
|
||||||
browserAllowedControlHosts: cfg.browser.allowedControlHosts,
|
|
||||||
browserAllowedControlPorts: cfg.browser.allowedControlPorts,
|
|
||||||
browser: browser ?? undefined,
|
browser: browser ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,9 +37,6 @@ export type SandboxBrowserConfig = {
|
|||||||
headless: boolean;
|
headless: boolean;
|
||||||
enableNoVnc: boolean;
|
enableNoVnc: boolean;
|
||||||
allowHostControl: boolean;
|
allowHostControl: boolean;
|
||||||
allowedControlUrls?: string[];
|
|
||||||
allowedControlHosts?: string[];
|
|
||||||
allowedControlPorts?: number[];
|
|
||||||
autoStart: boolean;
|
autoStart: boolean;
|
||||||
autoStartTimeoutMs: number;
|
autoStartTimeoutMs: number;
|
||||||
};
|
};
|
||||||
@@ -63,7 +60,7 @@ export type SandboxConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type SandboxBrowserContext = {
|
export type SandboxBrowserContext = {
|
||||||
controlUrl: string;
|
bridgeUrl: string;
|
||||||
noVncUrl?: string;
|
noVncUrl?: string;
|
||||||
containerName: string;
|
containerName: string;
|
||||||
};
|
};
|
||||||
@@ -79,9 +76,6 @@ export type SandboxContext = {
|
|||||||
docker: SandboxDockerConfig;
|
docker: SandboxDockerConfig;
|
||||||
tools: SandboxToolPolicy;
|
tools: SandboxToolPolicy;
|
||||||
browserAllowHostControl: boolean;
|
browserAllowHostControl: boolean;
|
||||||
browserAllowedControlUrls?: string[];
|
|
||||||
browserAllowedControlHosts?: string[];
|
|
||||||
browserAllowedControlPorts?: number[];
|
|
||||||
browser?: SandboxBrowserContext;
|
browser?: SandboxBrowserContext;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { acquireSessionWriteLock } from "./session-write-lock.js";
|
import { __testing, acquireSessionWriteLock } from "./session-write-lock.js";
|
||||||
|
|
||||||
describe("acquireSessionWriteLock", () => {
|
describe("acquireSessionWriteLock", () => {
|
||||||
it("reuses locks across symlinked session paths", async () => {
|
it("reuses locks across symlinked session paths", async () => {
|
||||||
@@ -31,4 +31,132 @@ describe("acquireSessionWriteLock", () => {
|
|||||||
await fs.rm(root, { recursive: true, force: true });
|
await fs.rm(root, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the lock file until the last release", async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-"));
|
||||||
|
try {
|
||||||
|
const sessionFile = path.join(root, "sessions.json");
|
||||||
|
const lockPath = `${sessionFile}.lock`;
|
||||||
|
|
||||||
|
const lockA = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 });
|
||||||
|
const lockB = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 });
|
||||||
|
|
||||||
|
await expect(fs.access(lockPath)).resolves.toBeUndefined();
|
||||||
|
await lockA.release();
|
||||||
|
await expect(fs.access(lockPath)).resolves.toBeUndefined();
|
||||||
|
await lockB.release();
|
||||||
|
await expect(fs.access(lockPath)).rejects.toThrow();
|
||||||
|
} finally {
|
||||||
|
await fs.rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reclaims stale lock files", async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-"));
|
||||||
|
try {
|
||||||
|
const sessionFile = path.join(root, "sessions.json");
|
||||||
|
const lockPath = `${sessionFile}.lock`;
|
||||||
|
await fs.writeFile(
|
||||||
|
lockPath,
|
||||||
|
JSON.stringify({ pid: 123456, createdAt: new Date(Date.now() - 60_000).toISOString() }),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const lock = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500, staleMs: 10 });
|
||||||
|
const raw = await fs.readFile(lockPath, "utf8");
|
||||||
|
const payload = JSON.parse(raw) as { pid: number };
|
||||||
|
|
||||||
|
expect(payload.pid).toBe(process.pid);
|
||||||
|
await lock.release();
|
||||||
|
} finally {
|
||||||
|
await fs.rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes held locks on termination signals", async () => {
|
||||||
|
const signals = ["SIGINT", "SIGTERM", "SIGQUIT", "SIGABRT"] as const;
|
||||||
|
for (const signal of signals) {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-cleanup-"));
|
||||||
|
try {
|
||||||
|
const sessionFile = path.join(root, "sessions.json");
|
||||||
|
const lockPath = `${sessionFile}.lock`;
|
||||||
|
await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 });
|
||||||
|
const keepAlive = () => {};
|
||||||
|
if (signal === "SIGINT") {
|
||||||
|
process.on(signal, keepAlive);
|
||||||
|
}
|
||||||
|
|
||||||
|
__testing.handleTerminationSignal(signal);
|
||||||
|
|
||||||
|
await expect(fs.stat(lockPath)).rejects.toThrow();
|
||||||
|
if (signal === "SIGINT") {
|
||||||
|
process.off(signal, keepAlive);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await fs.rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registers cleanup for SIGQUIT and SIGABRT", () => {
|
||||||
|
expect(__testing.cleanupSignals).toContain("SIGQUIT");
|
||||||
|
expect(__testing.cleanupSignals).toContain("SIGABRT");
|
||||||
|
});
|
||||||
|
it("cleans up locks on SIGINT without removing other handlers", async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-"));
|
||||||
|
const originalKill = process.kill.bind(process) as typeof process.kill;
|
||||||
|
const killCalls: Array<NodeJS.Signals | undefined> = [];
|
||||||
|
let otherHandlerCalled = false;
|
||||||
|
|
||||||
|
process.kill = ((pid: number, signal?: NodeJS.Signals) => {
|
||||||
|
killCalls.push(signal);
|
||||||
|
return true;
|
||||||
|
}) as typeof process.kill;
|
||||||
|
|
||||||
|
const otherHandler = () => {
|
||||||
|
otherHandlerCalled = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on("SIGINT", otherHandler);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessionFile = path.join(root, "sessions.json");
|
||||||
|
const lockPath = `${sessionFile}.lock`;
|
||||||
|
await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 });
|
||||||
|
|
||||||
|
process.emit("SIGINT");
|
||||||
|
|
||||||
|
await expect(fs.access(lockPath)).rejects.toThrow();
|
||||||
|
expect(otherHandlerCalled).toBe(true);
|
||||||
|
expect(killCalls).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
process.off("SIGINT", otherHandler);
|
||||||
|
process.kill = originalKill;
|
||||||
|
await fs.rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cleans up locks on exit", async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-"));
|
||||||
|
try {
|
||||||
|
const sessionFile = path.join(root, "sessions.json");
|
||||||
|
const lockPath = `${sessionFile}.lock`;
|
||||||
|
await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 });
|
||||||
|
|
||||||
|
process.emit("exit", 0);
|
||||||
|
|
||||||
|
await expect(fs.access(lockPath)).rejects.toThrow();
|
||||||
|
} finally {
|
||||||
|
await fs.rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it("keeps other signal listeners registered", () => {
|
||||||
|
const keepAlive = () => {};
|
||||||
|
process.on("SIGINT", keepAlive);
|
||||||
|
|
||||||
|
__testing.handleTerminationSignal("SIGINT");
|
||||||
|
|
||||||
|
expect(process.listeners("SIGINT")).toContain(keepAlive);
|
||||||
|
process.off("SIGINT", keepAlive);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import fsSync from "node:fs";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
@@ -13,6 +14,9 @@ type HeldLock = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const HELD_LOCKS = new Map<string, HeldLock>();
|
const HELD_LOCKS = new Map<string, HeldLock>();
|
||||||
|
const CLEANUP_SIGNALS = ["SIGINT", "SIGTERM", "SIGQUIT", "SIGABRT"] as const;
|
||||||
|
type CleanupSignal = (typeof CLEANUP_SIGNALS)[number];
|
||||||
|
const cleanupHandlers = new Map<CleanupSignal, () => void>();
|
||||||
|
|
||||||
function isAlive(pid: number): boolean {
|
function isAlive(pid: number): boolean {
|
||||||
if (!Number.isFinite(pid) || pid <= 0) return false;
|
if (!Number.isFinite(pid) || pid <= 0) return false;
|
||||||
@@ -24,6 +28,65 @@ function isAlive(pid: number): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronously release all held locks.
|
||||||
|
* Used during process exit when async operations aren't reliable.
|
||||||
|
*/
|
||||||
|
function releaseAllLocksSync(): void {
|
||||||
|
for (const [sessionFile, held] of HELD_LOCKS) {
|
||||||
|
try {
|
||||||
|
if (typeof held.handle.fd === "number") {
|
||||||
|
fsSync.closeSync(held.handle.fd);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore errors during cleanup - best effort
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fsSync.rmSync(held.lockPath, { force: true });
|
||||||
|
} catch {
|
||||||
|
// Ignore errors during cleanup - best effort
|
||||||
|
}
|
||||||
|
HELD_LOCKS.delete(sessionFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let cleanupRegistered = false;
|
||||||
|
|
||||||
|
function handleTerminationSignal(signal: CleanupSignal): void {
|
||||||
|
releaseAllLocksSync();
|
||||||
|
const shouldReraise = process.listenerCount(signal) === 1;
|
||||||
|
if (shouldReraise) {
|
||||||
|
const handler = cleanupHandlers.get(signal);
|
||||||
|
if (handler) process.off(signal, handler);
|
||||||
|
try {
|
||||||
|
process.kill(process.pid, signal);
|
||||||
|
} catch {
|
||||||
|
// Ignore errors during shutdown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerCleanupHandlers(): void {
|
||||||
|
if (cleanupRegistered) return;
|
||||||
|
cleanupRegistered = true;
|
||||||
|
|
||||||
|
// Cleanup on normal exit and process.exit() calls
|
||||||
|
process.on("exit", () => {
|
||||||
|
releaseAllLocksSync();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle termination signals
|
||||||
|
for (const signal of CLEANUP_SIGNALS) {
|
||||||
|
try {
|
||||||
|
const handler = () => handleTerminationSignal(signal);
|
||||||
|
cleanupHandlers.set(signal, handler);
|
||||||
|
process.on(signal, handler);
|
||||||
|
} catch {
|
||||||
|
// Ignore unsupported signals on this platform.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function readLockPayload(lockPath: string): Promise<LockFilePayload | null> {
|
async function readLockPayload(lockPath: string): Promise<LockFilePayload | null> {
|
||||||
try {
|
try {
|
||||||
const raw = await fs.readFile(lockPath, "utf8");
|
const raw = await fs.readFile(lockPath, "utf8");
|
||||||
@@ -43,6 +106,7 @@ export async function acquireSessionWriteLock(params: {
|
|||||||
}): Promise<{
|
}): Promise<{
|
||||||
release: () => Promise<void>;
|
release: () => Promise<void>;
|
||||||
}> {
|
}> {
|
||||||
|
registerCleanupHandlers();
|
||||||
const timeoutMs = params.timeoutMs ?? 10_000;
|
const timeoutMs = params.timeoutMs ?? 10_000;
|
||||||
const staleMs = params.staleMs ?? 30 * 60 * 1000;
|
const staleMs = params.staleMs ?? 30 * 60 * 1000;
|
||||||
const sessionFile = path.resolve(params.sessionFile);
|
const sessionFile = path.resolve(params.sessionFile);
|
||||||
@@ -116,3 +180,9 @@ export async function acquireSessionWriteLock(params: {
|
|||||||
const owner = payload?.pid ? `pid=${payload.pid}` : "unknown";
|
const owner = payload?.pid ? `pid=${payload.pid}` : "unknown";
|
||||||
throw new Error(`session file locked (timeout ${timeoutMs}ms): ${owner} ${lockPath}`);
|
throw new Error(`session file locked (timeout ${timeoutMs}ms): ${owner} ${lockPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const __testing = {
|
||||||
|
cleanupSignals: [...CLEANUP_SIGNALS],
|
||||||
|
handleTerminationSignal,
|
||||||
|
releaseAllLocksSync,
|
||||||
|
};
|
||||||
@@ -6,6 +6,7 @@ import type { SkillEligibilityContext, SkillEntry } from "./types.js";
|
|||||||
|
|
||||||
const DEFAULT_CONFIG_VALUES: Record<string, boolean> = {
|
const DEFAULT_CONFIG_VALUES: Record<string, boolean> = {
|
||||||
"browser.enabled": true,
|
"browser.enabled": true,
|
||||||
|
"browser.evaluateEnabled": true,
|
||||||
};
|
};
|
||||||
|
|
||||||
function isTruthy(value: unknown): boolean {
|
function isTruthy(value: unknown): boolean {
|
||||||
|
|||||||
@@ -165,12 +165,9 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
workspaceDir?: string;
|
workspaceDir?: string;
|
||||||
workspaceAccess?: "none" | "ro" | "rw";
|
workspaceAccess?: "none" | "ro" | "rw";
|
||||||
agentWorkspaceMount?: string;
|
agentWorkspaceMount?: string;
|
||||||
browserControlUrl?: string;
|
browserBridgeUrl?: string;
|
||||||
browserNoVncUrl?: string;
|
browserNoVncUrl?: string;
|
||||||
hostBrowserAllowed?: boolean;
|
hostBrowserAllowed?: boolean;
|
||||||
allowedControlUrls?: string[];
|
|
||||||
allowedControlHosts?: string[];
|
|
||||||
allowedControlPorts?: number[];
|
|
||||||
elevated?: {
|
elevated?: {
|
||||||
allowed: boolean;
|
allowed: boolean;
|
||||||
defaultLevel: "on" | "off" | "ask" | "full";
|
defaultLevel: "on" | "off" | "ask" | "full";
|
||||||
@@ -419,9 +416,7 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
: ""
|
: ""
|
||||||
}`
|
}`
|
||||||
: "",
|
: "",
|
||||||
params.sandboxInfo.browserControlUrl
|
params.sandboxInfo.browserBridgeUrl ? "Sandbox browser: enabled." : "",
|
||||||
? `Sandbox browser control URL: ${params.sandboxInfo.browserControlUrl}`
|
|
||||||
: "",
|
|
||||||
params.sandboxInfo.browserNoVncUrl
|
params.sandboxInfo.browserNoVncUrl
|
||||||
? `Sandbox browser observer (noVNC): ${params.sandboxInfo.browserNoVncUrl}`
|
? `Sandbox browser observer (noVNC): ${params.sandboxInfo.browserNoVncUrl}`
|
||||||
: "",
|
: "",
|
||||||
@@ -430,15 +425,6 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
: params.sandboxInfo.hostBrowserAllowed === false
|
: params.sandboxInfo.hostBrowserAllowed === false
|
||||||
? "Host browser control: blocked."
|
? "Host browser control: blocked."
|
||||||
: "",
|
: "",
|
||||||
params.sandboxInfo.allowedControlUrls?.length
|
|
||||||
? `Browser control URL allowlist: ${params.sandboxInfo.allowedControlUrls.join(", ")}`
|
|
||||||
: "",
|
|
||||||
params.sandboxInfo.allowedControlHosts?.length
|
|
||||||
? `Browser control host allowlist: ${params.sandboxInfo.allowedControlHosts.join(", ")}`
|
|
||||||
: "",
|
|
||||||
params.sandboxInfo.allowedControlPorts?.length
|
|
||||||
? `Browser control port allowlist: ${params.sandboxInfo.allowedControlPorts.join(", ")}`
|
|
||||||
: "",
|
|
||||||
params.sandboxInfo.elevated?.allowed
|
params.sandboxInfo.elevated?.allowed
|
||||||
? "Elevated exec is available for this session."
|
? "Elevated exec is available for this session."
|
||||||
: "",
|
: "",
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const BROWSER_TOOL_ACTIONS = [
|
|||||||
"act",
|
"act",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const BROWSER_TARGETS = ["sandbox", "host", "custom", "node"] as const;
|
const BROWSER_TARGETS = ["sandbox", "host", "node"] as const;
|
||||||
|
|
||||||
const BROWSER_SNAPSHOT_FORMATS = ["aria", "ai"] as const;
|
const BROWSER_SNAPSHOT_FORMATS = ["aria", "ai"] as const;
|
||||||
const BROWSER_SNAPSHOT_MODES = ["efficient"] as const;
|
const BROWSER_SNAPSHOT_MODES = ["efficient"] as const;
|
||||||
@@ -86,7 +86,6 @@ export const BrowserToolSchema = Type.Object({
|
|||||||
target: optionalStringEnum(BROWSER_TARGETS),
|
target: optionalStringEnum(BROWSER_TARGETS),
|
||||||
node: Type.Optional(Type.String()),
|
node: Type.Optional(Type.String()),
|
||||||
profile: Type.Optional(Type.String()),
|
profile: Type.Optional(Type.String()),
|
||||||
controlUrl: Type.Optional(Type.String()),
|
|
||||||
targetUrl: Type.Optional(Type.String()),
|
targetUrl: Type.Optional(Type.String()),
|
||||||
targetId: Type.Optional(Type.String()),
|
targetId: Type.Optional(Type.String()),
|
||||||
limit: Type.Optional(Type.Number()),
|
limit: Type.Optional(Type.Number()),
|
||||||
|
|||||||
@@ -28,23 +28,7 @@ vi.mock("../../browser/client.js", () => browserClientMocks);
|
|||||||
const browserConfigMocks = vi.hoisted(() => ({
|
const browserConfigMocks = vi.hoisted(() => ({
|
||||||
resolveBrowserConfig: vi.fn(() => ({
|
resolveBrowserConfig: vi.fn(() => ({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
controlUrl: "http://127.0.0.1:18791",
|
|
||||||
controlHost: "127.0.0.1",
|
|
||||||
controlPort: 18791,
|
controlPort: 18791,
|
||||||
cdpProtocol: "http",
|
|
||||||
cdpHost: "127.0.0.1",
|
|
||||||
cdpIsLoopback: true,
|
|
||||||
color: "#FF0000",
|
|
||||||
headless: true,
|
|
||||||
noSandbox: false,
|
|
||||||
attachOnly: false,
|
|
||||||
defaultProfile: "clawd",
|
|
||||||
profiles: {
|
|
||||||
clawd: {
|
|
||||||
cdpPort: 18792,
|
|
||||||
color: "#FF0000",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
vi.mock("../../browser/config.js", () => browserConfigMocks);
|
vi.mock("../../browser/config.js", () => browserConfigMocks);
|
||||||
@@ -99,7 +83,7 @@ describe("browser tool snapshot maxChars", () => {
|
|||||||
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai" });
|
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai" });
|
||||||
|
|
||||||
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
||||||
"http://127.0.0.1:18791",
|
undefined,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
format: "ai",
|
format: "ai",
|
||||||
maxChars: DEFAULT_AI_SNAPSHOT_MAX_CHARS,
|
maxChars: DEFAULT_AI_SNAPSHOT_MAX_CHARS,
|
||||||
@@ -117,7 +101,7 @@ describe("browser tool snapshot maxChars", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
||||||
"http://127.0.0.1:18791",
|
undefined,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
maxChars: override,
|
maxChars: override,
|
||||||
}),
|
}),
|
||||||
@@ -141,7 +125,7 @@ describe("browser tool snapshot maxChars", () => {
|
|||||||
const tool = createBrowserTool();
|
const tool = createBrowserTool();
|
||||||
await tool.execute?.(null, { action: "profiles" });
|
await tool.execute?.(null, { action: "profiles" });
|
||||||
|
|
||||||
expect(browserClientMocks.browserProfiles).toHaveBeenCalledWith("http://127.0.0.1:18791");
|
expect(browserClientMocks.browserProfiles).toHaveBeenCalledWith(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes refs mode through to browser snapshot", async () => {
|
it("passes refs mode through to browser snapshot", async () => {
|
||||||
@@ -149,7 +133,7 @@ describe("browser tool snapshot maxChars", () => {
|
|||||||
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "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",
|
undefined,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
format: "ai",
|
format: "ai",
|
||||||
refs: "aria",
|
refs: "aria",
|
||||||
@@ -165,7 +149,7 @@ describe("browser tool snapshot maxChars", () => {
|
|||||||
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai" });
|
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai" });
|
||||||
|
|
||||||
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
||||||
"http://127.0.0.1:18791",
|
undefined,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
mode: "efficient",
|
mode: "efficient",
|
||||||
}),
|
}),
|
||||||
@@ -185,11 +169,11 @@ describe("browser tool snapshot maxChars", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
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({ sandboxBridgeUrl: "http://127.0.0.1:9999" });
|
||||||
await tool.execute?.(null, { action: "snapshot", profile: "chrome", snapshotFormat: "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",
|
undefined,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
profile: "chrome",
|
profile: "chrome",
|
||||||
}),
|
}),
|
||||||
@@ -220,7 +204,7 @@ describe("browser tool snapshot maxChars", () => {
|
|||||||
expect(browserClientMocks.browserStatus).not.toHaveBeenCalled();
|
expect(browserClientMocks.browserStatus).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps sandbox control url when node proxy is available", async () => {
|
it("keeps sandbox bridge url when node proxy is available", async () => {
|
||||||
nodesUtilsMocks.listNodes.mockResolvedValue([
|
nodesUtilsMocks.listNodes.mockResolvedValue([
|
||||||
{
|
{
|
||||||
nodeId: "node-1",
|
nodeId: "node-1",
|
||||||
@@ -230,7 +214,7 @@ describe("browser tool snapshot maxChars", () => {
|
|||||||
commands: ["browser.proxy"],
|
commands: ["browser.proxy"],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const tool = createBrowserTool({ defaultControlUrl: "http://127.0.0.1:9999" });
|
const tool = createBrowserTool({ sandboxBridgeUrl: "http://127.0.0.1:9999" });
|
||||||
await tool.execute?.(null, { action: "status" });
|
await tool.execute?.(null, { action: "status" });
|
||||||
|
|
||||||
expect(browserClientMocks.browserStatus).toHaveBeenCalledWith(
|
expect(browserClientMocks.browserStatus).toHaveBeenCalledWith(
|
||||||
@@ -254,7 +238,7 @@ describe("browser tool snapshot maxChars", () => {
|
|||||||
await tool.execute?.(null, { action: "status", profile: "chrome" });
|
await tool.execute?.(null, { action: "status", profile: "chrome" });
|
||||||
|
|
||||||
expect(browserClientMocks.browserStatus).toHaveBeenCalledWith(
|
expect(browserClientMocks.browserStatus).toHaveBeenCalledWith(
|
||||||
"http://127.0.0.1:18791",
|
undefined,
|
||||||
expect.objectContaining({ profile: "chrome" }),
|
expect.objectContaining({ profile: "chrome" }),
|
||||||
);
|
);
|
||||||
expect(gatewayMocks.callGatewayTool).not.toHaveBeenCalled();
|
expect(gatewayMocks.callGatewayTool).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -55,9 +55,8 @@ function isBrowserNode(node: NodeListNode) {
|
|||||||
|
|
||||||
async function resolveBrowserNodeTarget(params: {
|
async function resolveBrowserNodeTarget(params: {
|
||||||
requestedNode?: string;
|
requestedNode?: string;
|
||||||
target?: "sandbox" | "host" | "custom" | "node";
|
target?: "sandbox" | "host" | "node";
|
||||||
controlUrl?: string;
|
sandboxBridgeUrl?: string;
|
||||||
defaultControlUrl?: string;
|
|
||||||
}): Promise<BrowserNodeTarget | null> {
|
}): Promise<BrowserNodeTarget | null> {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const policy = cfg.gateway?.nodes?.browser;
|
const policy = cfg.gateway?.nodes?.browser;
|
||||||
@@ -68,10 +67,9 @@ async function resolveBrowserNodeTarget(params: {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (params.defaultControlUrl?.trim() && params.target !== "node" && !params.requestedNode) {
|
if (params.sandboxBridgeUrl?.trim() && params.target !== "node" && !params.requestedNode) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (params.controlUrl?.trim()) return null;
|
|
||||||
if (params.target && params.target !== "node") return null;
|
if (params.target && params.target !== "node") return null;
|
||||||
if (mode === "manual" && params.target !== "node" && !params.requestedNode) {
|
if (mode === "manual" && params.target !== "node" && !params.requestedNode) {
|
||||||
return null;
|
return null;
|
||||||
@@ -187,70 +185,22 @@ function applyProxyPaths(result: unknown, mapping: Map<string, string>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveBrowserBaseUrl(params: {
|
function resolveBrowserBaseUrl(params: {
|
||||||
target?: "sandbox" | "host" | "custom";
|
target?: "sandbox" | "host";
|
||||||
controlUrl?: string;
|
sandboxBridgeUrl?: string;
|
||||||
defaultControlUrl?: string;
|
|
||||||
allowHostControl?: boolean;
|
allowHostControl?: boolean;
|
||||||
allowedControlUrls?: string[];
|
}): string | undefined {
|
||||||
allowedControlHosts?: string[];
|
|
||||||
allowedControlPorts?: number[];
|
|
||||||
}) {
|
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const resolved = resolveBrowserConfig(cfg.browser);
|
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||||
const normalizedControlUrl = params.controlUrl?.trim() ?? "";
|
const normalizedSandbox = params.sandboxBridgeUrl?.trim() ?? "";
|
||||||
const normalizedDefault = params.defaultControlUrl?.trim() ?? "";
|
const target = params.target ?? (normalizedSandbox ? "sandbox" : "host");
|
||||||
const target =
|
|
||||||
params.target ?? (normalizedControlUrl ? "custom" : normalizedDefault ? "sandbox" : "host");
|
|
||||||
|
|
||||||
const assertAllowedControlUrl = (url: string) => {
|
|
||||||
const allowedUrls = params.allowedControlUrls?.map((entry) => entry.trim().replace(/\/$/, ""));
|
|
||||||
const allowedHosts = params.allowedControlHosts?.map((entry) => entry.trim().toLowerCase());
|
|
||||||
const allowedPorts = params.allowedControlPorts;
|
|
||||||
if (!allowedUrls?.length && !allowedHosts?.length && !allowedPorts?.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let parsed: URL;
|
|
||||||
try {
|
|
||||||
parsed = new URL(url);
|
|
||||||
} catch {
|
|
||||||
throw new Error(`Invalid browser controlUrl: ${url}`);
|
|
||||||
}
|
|
||||||
const normalizedUrl = parsed.toString().replace(/\/$/, "");
|
|
||||||
if (allowedUrls?.length && !allowedUrls.includes(normalizedUrl)) {
|
|
||||||
throw new Error("Browser controlUrl is not in the allowed URL list.");
|
|
||||||
}
|
|
||||||
if (allowedHosts?.length && !allowedHosts.includes(parsed.hostname)) {
|
|
||||||
throw new Error("Browser controlUrl hostname is not in the allowed host list.");
|
|
||||||
}
|
|
||||||
if (allowedPorts?.length) {
|
|
||||||
const port =
|
|
||||||
parsed.port?.trim() !== "" ? Number(parsed.port) : parsed.protocol === "https:" ? 443 : 80;
|
|
||||||
if (!Number.isFinite(port) || !allowedPorts.includes(port)) {
|
|
||||||
throw new Error("Browser controlUrl port is not in the allowed port list.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (target !== "custom" && params.target && normalizedControlUrl) {
|
|
||||||
throw new Error('controlUrl is only supported with target="custom".');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (target === "custom") {
|
|
||||||
if (!normalizedControlUrl) {
|
|
||||||
throw new Error("Custom browser target requires controlUrl.");
|
|
||||||
}
|
|
||||||
const normalized = normalizedControlUrl.replace(/\/$/, "");
|
|
||||||
assertAllowedControlUrl(normalized);
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (target === "sandbox") {
|
if (target === "sandbox") {
|
||||||
if (!normalizedDefault) {
|
if (!normalizedSandbox) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Sandbox browser is unavailable. Enable agents.defaults.sandbox.browser.enabled or use target="host" if allowed.',
|
'Sandbox browser is unavailable. Enable agents.defaults.sandbox.browser.enabled or use target="host" if allowed.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return normalizedDefault.replace(/\/$/, "");
|
return normalizedSandbox.replace(/\/$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params.allowHostControl === false) {
|
if (params.allowHostControl === false) {
|
||||||
@@ -261,27 +211,16 @@ function resolveBrowserBaseUrl(params: {
|
|||||||
"Browser control is disabled. Set browser.enabled=true in ~/.clawdbot/clawdbot.json.",
|
"Browser control is disabled. Set browser.enabled=true in ~/.clawdbot/clawdbot.json.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const normalized = resolved.controlUrl.replace(/\/$/, "");
|
return undefined;
|
||||||
assertAllowedControlUrl(normalized);
|
|
||||||
return normalized;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createBrowserTool(opts?: {
|
export function createBrowserTool(opts?: {
|
||||||
defaultControlUrl?: string;
|
sandboxBridgeUrl?: string;
|
||||||
allowHostControl?: boolean;
|
allowHostControl?: boolean;
|
||||||
allowedControlUrls?: string[];
|
|
||||||
allowedControlHosts?: string[];
|
|
||||||
allowedControlPorts?: number[];
|
|
||||||
}): AnyAgentTool {
|
}): AnyAgentTool {
|
||||||
const targetDefault = opts?.defaultControlUrl ? "sandbox" : "host";
|
const targetDefault = opts?.sandboxBridgeUrl ? "sandbox" : "host";
|
||||||
const hostHint =
|
const hostHint =
|
||||||
opts?.allowHostControl === false ? "Host target blocked by policy." : "Host target allowed.";
|
opts?.allowHostControl === false ? "Host target blocked by policy." : "Host target allowed.";
|
||||||
const allowlistHint =
|
|
||||||
opts?.allowedControlUrls?.length ||
|
|
||||||
opts?.allowedControlHosts?.length ||
|
|
||||||
opts?.allowedControlPorts?.length
|
|
||||||
? "Custom targets are restricted by sandbox allowlists."
|
|
||||||
: "Custom targets are unrestricted.";
|
|
||||||
return {
|
return {
|
||||||
label: "Browser",
|
label: "Browser",
|
||||||
name: "browser",
|
name: "browser",
|
||||||
@@ -294,33 +233,22 @@ export function createBrowserTool(opts?: {
|
|||||||
"When using refs from snapshot (e.g. e12), keep the same tab: prefer passing targetId from the snapshot response into subsequent actions (act/click/type/etc).",
|
"When using refs from snapshot (e.g. e12), keep the same tab: prefer passing targetId from the snapshot response into subsequent actions (act/click/type/etc).",
|
||||||
'For stable, self-resolving refs across calls, use snapshot with refs="aria" (Playwright aria-ref ids). Default refs="role" are role+name-based.',
|
'For stable, self-resolving refs across calls, use snapshot with refs="aria" (Playwright aria-ref ids). Default refs="role" are role+name-based.',
|
||||||
"Use snapshot+act for UI automation. Avoid act:wait by default; use only in exceptional cases when no reliable UI state exists.",
|
"Use snapshot+act for UI automation. Avoid act:wait by default; use only in exceptional cases when no reliable UI state exists.",
|
||||||
`target selects browser location (sandbox|host|custom|node). Default: ${targetDefault}.`,
|
`target selects browser location (sandbox|host|node). Default: ${targetDefault}.`,
|
||||||
"controlUrl implies target=custom (remote control server).",
|
|
||||||
hostHint,
|
hostHint,
|
||||||
allowlistHint,
|
|
||||||
].join(" "),
|
].join(" "),
|
||||||
parameters: BrowserToolSchema,
|
parameters: BrowserToolSchema,
|
||||||
execute: async (_toolCallId, args) => {
|
execute: async (_toolCallId, args) => {
|
||||||
const params = args as Record<string, unknown>;
|
const params = args as Record<string, unknown>;
|
||||||
const action = readStringParam(params, "action", { required: true });
|
const action = readStringParam(params, "action", { required: true });
|
||||||
const controlUrl = readStringParam(params, "controlUrl");
|
|
||||||
const profile = readStringParam(params, "profile");
|
const profile = readStringParam(params, "profile");
|
||||||
const requestedNode = readStringParam(params, "node");
|
const requestedNode = readStringParam(params, "node");
|
||||||
let target = readStringParam(params, "target") as
|
let target = readStringParam(params, "target") as "sandbox" | "host" | "node" | undefined;
|
||||||
| "sandbox"
|
|
||||||
| "host"
|
|
||||||
| "custom"
|
|
||||||
| "node"
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
if (controlUrl?.trim() && (target === "node" || requestedNode)) {
|
if (requestedNode && target && target !== "node") {
|
||||||
throw new Error('controlUrl is not supported with target="node".');
|
throw new Error('node is only supported with target="node".');
|
||||||
}
|
|
||||||
if (target === "custom" && requestedNode) {
|
|
||||||
throw new Error('node is not supported with target="custom".');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!target && !controlUrl?.trim() && !requestedNode && profile === "chrome") {
|
if (!target && !requestedNode && profile === "chrome") {
|
||||||
// Chrome extension relay takeover is a host Chrome feature; prefer host unless explicitly targeting a node.
|
// Chrome extension relay takeover is a host Chrome feature; prefer host unless explicitly targeting a node.
|
||||||
target = "host";
|
target = "host";
|
||||||
}
|
}
|
||||||
@@ -328,21 +256,16 @@ export function createBrowserTool(opts?: {
|
|||||||
const nodeTarget = await resolveBrowserNodeTarget({
|
const nodeTarget = await resolveBrowserNodeTarget({
|
||||||
requestedNode: requestedNode ?? undefined,
|
requestedNode: requestedNode ?? undefined,
|
||||||
target,
|
target,
|
||||||
controlUrl,
|
sandboxBridgeUrl: opts?.sandboxBridgeUrl,
|
||||||
defaultControlUrl: opts?.defaultControlUrl,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const resolvedTarget = target === "node" ? undefined : target;
|
const resolvedTarget = target === "node" ? undefined : target;
|
||||||
const baseUrl = nodeTarget
|
const baseUrl = nodeTarget
|
||||||
? ""
|
? undefined
|
||||||
: resolveBrowserBaseUrl({
|
: resolveBrowserBaseUrl({
|
||||||
target: resolvedTarget,
|
target: resolvedTarget,
|
||||||
controlUrl,
|
sandboxBridgeUrl: opts?.sandboxBridgeUrl,
|
||||||
defaultControlUrl: opts?.defaultControlUrl,
|
|
||||||
allowHostControl: opts?.allowHostControl,
|
allowHostControl: opts?.allowHostControl,
|
||||||
allowedControlUrls: opts?.allowedControlUrls,
|
|
||||||
allowedControlHosts: opts?.allowedControlHosts,
|
|
||||||
allowedControlPorts: opts?.allowedControlPorts,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const proxyRequest = nodeTarget
|
const proxyRequest = nodeTarget
|
||||||
|
|||||||
@@ -8,12 +8,17 @@ const sendMessageTelegram = vi.fn(async () => ({
|
|||||||
messageId: "789",
|
messageId: "789",
|
||||||
chatId: "123",
|
chatId: "123",
|
||||||
}));
|
}));
|
||||||
|
const sendStickerTelegram = vi.fn(async () => ({
|
||||||
|
messageId: "456",
|
||||||
|
chatId: "123",
|
||||||
|
}));
|
||||||
const deleteMessageTelegram = vi.fn(async () => ({ ok: true }));
|
const deleteMessageTelegram = vi.fn(async () => ({ ok: true }));
|
||||||
const originalToken = process.env.TELEGRAM_BOT_TOKEN;
|
const originalToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||||
|
|
||||||
vi.mock("../../telegram/send.js", () => ({
|
vi.mock("../../telegram/send.js", () => ({
|
||||||
reactMessageTelegram: (...args: unknown[]) => reactMessageTelegram(...args),
|
reactMessageTelegram: (...args: unknown[]) => reactMessageTelegram(...args),
|
||||||
sendMessageTelegram: (...args: unknown[]) => sendMessageTelegram(...args),
|
sendMessageTelegram: (...args: unknown[]) => sendMessageTelegram(...args),
|
||||||
|
sendStickerTelegram: (...args: unknown[]) => sendStickerTelegram(...args),
|
||||||
deleteMessageTelegram: (...args: unknown[]) => deleteMessageTelegram(...args),
|
deleteMessageTelegram: (...args: unknown[]) => deleteMessageTelegram(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -21,6 +26,7 @@ describe("handleTelegramAction", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
reactMessageTelegram.mockClear();
|
reactMessageTelegram.mockClear();
|
||||||
sendMessageTelegram.mockClear();
|
sendMessageTelegram.mockClear();
|
||||||
|
sendStickerTelegram.mockClear();
|
||||||
deleteMessageTelegram.mockClear();
|
deleteMessageTelegram.mockClear();
|
||||||
process.env.TELEGRAM_BOT_TOKEN = "tok";
|
process.env.TELEGRAM_BOT_TOKEN = "tok";
|
||||||
});
|
});
|
||||||
@@ -96,6 +102,40 @@ describe("handleTelegramAction", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects sticker actions when disabled by default", async () => {
|
||||||
|
const cfg = { channels: { telegram: { botToken: "tok" } } } as ClawdbotConfig;
|
||||||
|
await expect(
|
||||||
|
handleTelegramAction(
|
||||||
|
{
|
||||||
|
action: "sendSticker",
|
||||||
|
to: "123",
|
||||||
|
fileId: "sticker",
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/sticker actions are disabled/i);
|
||||||
|
expect(sendStickerTelegram).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends stickers when enabled", async () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: { telegram: { botToken: "tok", actions: { sticker: true } } },
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
await handleTelegramAction(
|
||||||
|
{
|
||||||
|
action: "sendSticker",
|
||||||
|
to: "123",
|
||||||
|
fileId: "sticker",
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
expect(sendStickerTelegram).toHaveBeenCalledWith(
|
||||||
|
"123",
|
||||||
|
"sticker",
|
||||||
|
expect.objectContaining({ token: "tok" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("removes reactions when remove flag set", async () => {
|
it("removes reactions when remove flag set", async () => {
|
||||||
const cfg = {
|
const cfg = {
|
||||||
channels: { telegram: { botToken: "tok", reactionLevel: "extensive" } },
|
channels: { telegram: { botToken: "tok", reactionLevel: "extensive" } },
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import {
|
|||||||
editMessageTelegram,
|
editMessageTelegram,
|
||||||
reactMessageTelegram,
|
reactMessageTelegram,
|
||||||
sendMessageTelegram,
|
sendMessageTelegram,
|
||||||
|
sendStickerTelegram,
|
||||||
} from "../../telegram/send.js";
|
} from "../../telegram/send.js";
|
||||||
|
import { getCacheStats, searchStickers } from "../../telegram/sticker-cache.js";
|
||||||
import { resolveTelegramToken } from "../../telegram/token.js";
|
import { resolveTelegramToken } from "../../telegram/token.js";
|
||||||
import {
|
import {
|
||||||
resolveTelegramInlineButtonsScope,
|
resolveTelegramInlineButtonsScope,
|
||||||
@@ -255,5 +257,64 @@ export async function handleTelegramAction(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action === "sendSticker") {
|
||||||
|
if (!isActionEnabled("sticker", false)) {
|
||||||
|
throw new Error(
|
||||||
|
"Telegram sticker actions are disabled. Set channels.telegram.actions.sticker to true.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const to = readStringParam(params, "to", { required: true });
|
||||||
|
const fileId = readStringParam(params, "fileId", { required: true });
|
||||||
|
const replyToMessageId = readNumberParam(params, "replyToMessageId", {
|
||||||
|
integer: true,
|
||||||
|
});
|
||||||
|
const messageThreadId = readNumberParam(params, "messageThreadId", {
|
||||||
|
integer: true,
|
||||||
|
});
|
||||||
|
const token = resolveTelegramToken(cfg, { accountId }).token;
|
||||||
|
if (!token) {
|
||||||
|
throw new Error(
|
||||||
|
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const result = await sendStickerTelegram(to, fileId, {
|
||||||
|
token,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
replyToMessageId: replyToMessageId ?? undefined,
|
||||||
|
messageThreadId: messageThreadId ?? undefined,
|
||||||
|
});
|
||||||
|
return jsonResult({
|
||||||
|
ok: true,
|
||||||
|
messageId: result.messageId,
|
||||||
|
chatId: result.chatId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "searchSticker") {
|
||||||
|
if (!isActionEnabled("sticker", false)) {
|
||||||
|
throw new Error(
|
||||||
|
"Telegram sticker actions are disabled. Set channels.telegram.actions.sticker to true.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const query = readStringParam(params, "query", { required: true });
|
||||||
|
const limit = readNumberParam(params, "limit", { integer: true }) ?? 5;
|
||||||
|
const results = searchStickers(query, limit);
|
||||||
|
return jsonResult({
|
||||||
|
ok: true,
|
||||||
|
count: results.length,
|
||||||
|
stickers: results.map((s) => ({
|
||||||
|
fileId: s.fileId,
|
||||||
|
emoji: s.emoji,
|
||||||
|
description: s.description,
|
||||||
|
setName: s.setName,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "stickerCacheStats") {
|
||||||
|
const stats = getCacheStats();
|
||||||
|
return jsonResult({ ok: true, ...stats });
|
||||||
|
}
|
||||||
|
|
||||||
throw new Error(`Unsupported Telegram action: ${action}`);
|
throw new Error(`Unsupported Telegram action: ${action}`);
|
||||||
}
|
}
|
||||||
@@ -181,9 +181,44 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
|||||||
defineChatCommand({
|
defineChatCommand({
|
||||||
key: "tts",
|
key: "tts",
|
||||||
nativeName: "tts",
|
nativeName: "tts",
|
||||||
description: "Configure text-to-speech.",
|
description: "Control text-to-speech (TTS).",
|
||||||
textAlias: "/tts",
|
textAlias: "/tts",
|
||||||
acceptsArgs: true,
|
args: [
|
||||||
|
{
|
||||||
|
name: "action",
|
||||||
|
description: "TTS action",
|
||||||
|
type: "string",
|
||||||
|
choices: [
|
||||||
|
{ value: "on", label: "On" },
|
||||||
|
{ value: "off", label: "Off" },
|
||||||
|
{ value: "status", label: "Status" },
|
||||||
|
{ value: "provider", label: "Provider" },
|
||||||
|
{ value: "limit", label: "Limit" },
|
||||||
|
{ value: "summary", label: "Summary" },
|
||||||
|
{ value: "audio", label: "Audio" },
|
||||||
|
{ value: "help", label: "Help" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "value",
|
||||||
|
description: "Provider, limit, or text",
|
||||||
|
type: "string",
|
||||||
|
captureRemaining: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
argsMenu: {
|
||||||
|
arg: "action",
|
||||||
|
title:
|
||||||
|
"TTS Actions:\n" +
|
||||||
|
"• On – Enable TTS for responses\n" +
|
||||||
|
"• Off – Disable TTS\n" +
|
||||||
|
"• Status – Show current settings\n" +
|
||||||
|
"• Provider – Set voice provider (edge, elevenlabs, openai)\n" +
|
||||||
|
"• Limit – Set max characters for TTS\n" +
|
||||||
|
"• Summary – Toggle AI summary for long texts\n" +
|
||||||
|
"• Audio – Generate TTS from custom text\n" +
|
||||||
|
"• Help – Show usage guide",
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
defineChatCommand({
|
defineChatCommand({
|
||||||
key: "whoami",
|
key: "whoami",
|
||||||
|
|||||||
@@ -229,7 +229,12 @@ describe("commands registry args", () => {
|
|||||||
|
|
||||||
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
|
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
|
||||||
expect(menu?.arg.name).toBe("mode");
|
expect(menu?.arg.name).toBe("mode");
|
||||||
expect(menu?.choices).toEqual(["off", "tokens", "full", "cost"]);
|
expect(menu?.choices).toEqual([
|
||||||
|
{ label: "off", value: "off" },
|
||||||
|
{ label: "tokens", value: "tokens" },
|
||||||
|
{ label: "full", value: "full" },
|
||||||
|
{ label: "cost", value: "cost" },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not show menus when arg already provided", () => {
|
it("does not show menus when arg already provided", () => {
|
||||||
@@ -284,7 +289,10 @@ describe("commands registry args", () => {
|
|||||||
|
|
||||||
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
|
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
|
||||||
expect(menu?.arg.name).toBe("level");
|
expect(menu?.arg.name).toBe("level");
|
||||||
expect(menu?.choices).toEqual(["low", "high"]);
|
expect(menu?.choices).toEqual([
|
||||||
|
{ label: "low", value: "low" },
|
||||||
|
{ label: "high", value: "high" },
|
||||||
|
]);
|
||||||
expect(seen?.commandKey).toBe("think");
|
expect(seen?.commandKey).toBe("think");
|
||||||
expect(seen?.argName).toBe("level");
|
expect(seen?.argName).toBe("level");
|
||||||
expect(seen?.provider).toBeTruthy();
|
expect(seen?.provider).toBeTruthy();
|
||||||
|
|||||||
@@ -255,33 +255,41 @@ function resolveDefaultCommandContext(cfg?: ClawdbotConfig): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ResolvedCommandArgChoice = { value: string; label: string };
|
||||||
|
|
||||||
export function resolveCommandArgChoices(params: {
|
export function resolveCommandArgChoices(params: {
|
||||||
command: ChatCommandDefinition;
|
command: ChatCommandDefinition;
|
||||||
arg: CommandArgDefinition;
|
arg: CommandArgDefinition;
|
||||||
cfg?: ClawdbotConfig;
|
cfg?: ClawdbotConfig;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
}): string[] {
|
}): ResolvedCommandArgChoice[] {
|
||||||
const { command, arg, cfg } = params;
|
const { command, arg, cfg } = params;
|
||||||
if (!arg.choices) return [];
|
if (!arg.choices) return [];
|
||||||
const provided = arg.choices;
|
const provided = arg.choices;
|
||||||
if (Array.isArray(provided)) return provided;
|
const raw = Array.isArray(provided)
|
||||||
const defaults = resolveDefaultCommandContext(cfg);
|
? provided
|
||||||
const context: CommandArgChoiceContext = {
|
: (() => {
|
||||||
cfg,
|
const defaults = resolveDefaultCommandContext(cfg);
|
||||||
provider: params.provider ?? defaults.provider,
|
const context: CommandArgChoiceContext = {
|
||||||
model: params.model ?? defaults.model,
|
cfg,
|
||||||
command,
|
provider: params.provider ?? defaults.provider,
|
||||||
arg,
|
model: params.model ?? defaults.model,
|
||||||
};
|
command,
|
||||||
return provided(context);
|
arg,
|
||||||
|
};
|
||||||
|
return provided(context);
|
||||||
|
})();
|
||||||
|
return raw.map((choice) =>
|
||||||
|
typeof choice === "string" ? { value: choice, label: choice } : choice,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveCommandArgMenu(params: {
|
export function resolveCommandArgMenu(params: {
|
||||||
command: ChatCommandDefinition;
|
command: ChatCommandDefinition;
|
||||||
args?: CommandArgs;
|
args?: CommandArgs;
|
||||||
cfg?: ClawdbotConfig;
|
cfg?: ClawdbotConfig;
|
||||||
}): { arg: CommandArgDefinition; choices: string[]; title?: string } | null {
|
}): { arg: CommandArgDefinition; choices: ResolvedCommandArgChoice[]; title?: string } | null {
|
||||||
const { command, args, cfg } = params;
|
const { command, args, cfg } = params;
|
||||||
if (!command.args || !command.argsMenu) return null;
|
if (!command.args || !command.argsMenu) return null;
|
||||||
if (command.argsParsing === "none") return null;
|
if (command.argsParsing === "none") return null;
|
||||||
|
|||||||
@@ -12,14 +12,16 @@ export type CommandArgChoiceContext = {
|
|||||||
arg: CommandArgDefinition;
|
arg: CommandArgDefinition;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CommandArgChoicesProvider = (context: CommandArgChoiceContext) => string[];
|
export type CommandArgChoice = string | { value: string; label: string };
|
||||||
|
|
||||||
|
export type CommandArgChoicesProvider = (context: CommandArgChoiceContext) => CommandArgChoice[];
|
||||||
|
|
||||||
export type CommandArgDefinition = {
|
export type CommandArgDefinition = {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
type: CommandArgType;
|
type: CommandArgType;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
choices?: string[] | CommandArgChoicesProvider;
|
choices?: CommandArgChoice[] | CommandArgChoicesProvider;
|
||||||
captureRemaining?: boolean;
|
captureRemaining?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
cfg: params.followupRun.run.config,
|
cfg: params.followupRun.run.config,
|
||||||
provider: params.followupRun.run.provider,
|
provider: params.followupRun.run.provider,
|
||||||
model: params.followupRun.run.model,
|
model: params.followupRun.run.model,
|
||||||
|
agentDir: params.followupRun.run.agentDir,
|
||||||
fallbacksOverride: resolveAgentModelFallbacksOverride(
|
fallbacksOverride: resolveAgentModelFallbacksOverride(
|
||||||
params.followupRun.run.config,
|
params.followupRun.run.config,
|
||||||
resolveAgentIdFromSessionKey(params.followupRun.run.sessionKey),
|
resolveAgentIdFromSessionKey(params.followupRun.run.sessionKey),
|
||||||
@@ -231,6 +232,10 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
groupChannel:
|
groupChannel:
|
||||||
params.sessionCtx.GroupChannel?.trim() ?? params.sessionCtx.GroupSubject?.trim(),
|
params.sessionCtx.GroupChannel?.trim() ?? params.sessionCtx.GroupSubject?.trim(),
|
||||||
groupSpace: params.sessionCtx.GroupSpace?.trim() ?? undefined,
|
groupSpace: params.sessionCtx.GroupSpace?.trim() ?? undefined,
|
||||||
|
senderId: params.sessionCtx.SenderId?.trim() || undefined,
|
||||||
|
senderName: params.sessionCtx.SenderName?.trim() || undefined,
|
||||||
|
senderUsername: params.sessionCtx.SenderUsername?.trim() || undefined,
|
||||||
|
senderE164: params.sessionCtx.SenderE164?.trim() || undefined,
|
||||||
// Provider threading context for tool auto-injection
|
// Provider threading context for tool auto-injection
|
||||||
...buildThreadingToolContext({
|
...buildThreadingToolContext({
|
||||||
sessionCtx: params.sessionCtx,
|
sessionCtx: params.sessionCtx,
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ export async function runMemoryFlushIfNeeded(params: {
|
|||||||
cfg: params.followupRun.run.config,
|
cfg: params.followupRun.run.config,
|
||||||
provider: params.followupRun.run.provider,
|
provider: params.followupRun.run.provider,
|
||||||
model: params.followupRun.run.model,
|
model: params.followupRun.run.model,
|
||||||
|
agentDir: params.followupRun.run.agentDir,
|
||||||
fallbacksOverride: resolveAgentModelFallbacksOverride(
|
fallbacksOverride: resolveAgentModelFallbacksOverride(
|
||||||
params.followupRun.run.config,
|
params.followupRun.run.config,
|
||||||
resolveAgentIdFromSessionKey(params.followupRun.run.sessionKey),
|
resolveAgentIdFromSessionKey(params.followupRun.run.sessionKey),
|
||||||
@@ -114,6 +115,10 @@ export async function runMemoryFlushIfNeeded(params: {
|
|||||||
config: params.followupRun.run.config,
|
config: params.followupRun.run.config,
|
||||||
hasRepliedRef: params.opts?.hasRepliedRef,
|
hasRepliedRef: params.opts?.hasRepliedRef,
|
||||||
}),
|
}),
|
||||||
|
senderId: params.sessionCtx.SenderId?.trim() || undefined,
|
||||||
|
senderName: params.sessionCtx.SenderName?.trim() || undefined,
|
||||||
|
senderUsername: params.sessionCtx.SenderUsername?.trim() || undefined,
|
||||||
|
senderE164: params.sessionCtx.SenderE164?.trim() || undefined,
|
||||||
sessionFile: params.followupRun.run.sessionFile,
|
sessionFile: params.followupRun.run.sessionFile,
|
||||||
workspaceDir: params.followupRun.run.workspaceDir,
|
workspaceDir: params.followupRun.run.workspaceDir,
|
||||||
agentDir: params.followupRun.run.agentDir,
|
agentDir: params.followupRun.run.agentDir,
|
||||||
|
|||||||
@@ -6,20 +6,18 @@ import {
|
|||||||
getTtsMaxLength,
|
getTtsMaxLength,
|
||||||
getTtsProvider,
|
getTtsProvider,
|
||||||
isSummarizationEnabled,
|
isSummarizationEnabled,
|
||||||
|
isTtsEnabled,
|
||||||
isTtsProviderConfigured,
|
isTtsProviderConfigured,
|
||||||
normalizeTtsAutoMode,
|
|
||||||
resolveTtsAutoMode,
|
|
||||||
resolveTtsApiKey,
|
resolveTtsApiKey,
|
||||||
resolveTtsConfig,
|
resolveTtsConfig,
|
||||||
resolveTtsPrefsPath,
|
resolveTtsPrefsPath,
|
||||||
resolveTtsProviderOrder,
|
|
||||||
setLastTtsAttempt,
|
setLastTtsAttempt,
|
||||||
setSummarizationEnabled,
|
setSummarizationEnabled,
|
||||||
|
setTtsEnabled,
|
||||||
setTtsMaxLength,
|
setTtsMaxLength,
|
||||||
setTtsProvider,
|
setTtsProvider,
|
||||||
textToSpeech,
|
textToSpeech,
|
||||||
} from "../../tts/tts.js";
|
} from "../../tts/tts.js";
|
||||||
import { updateSessionStore } from "../../config/sessions.js";
|
|
||||||
|
|
||||||
type ParsedTtsCommand = {
|
type ParsedTtsCommand = {
|
||||||
action: string;
|
action: string;
|
||||||
@@ -40,14 +38,27 @@ function ttsUsage(): ReplyPayload {
|
|||||||
// Keep usage in one place so help/validation stays consistent.
|
// Keep usage in one place so help/validation stays consistent.
|
||||||
return {
|
return {
|
||||||
text:
|
text:
|
||||||
"⚙️ Usage: /tts <off|always|inbound|tagged|status|provider|limit|summary|audio> [value]" +
|
`🔊 **TTS (Text-to-Speech) Help**\n\n` +
|
||||||
"\nExamples:\n" +
|
`**Commands:**\n` +
|
||||||
"/tts always\n" +
|
`• /tts on — Enable automatic TTS for replies\n` +
|
||||||
"/tts provider openai\n" +
|
`• /tts off — Disable TTS\n` +
|
||||||
"/tts provider edge\n" +
|
`• /tts status — Show current settings\n` +
|
||||||
"/tts limit 2000\n" +
|
`• /tts provider [name] — View/change provider\n` +
|
||||||
"/tts summary off\n" +
|
`• /tts limit [number] — View/change text limit\n` +
|
||||||
"/tts audio Hello from Clawdbot",
|
`• /tts summary [on|off] — View/change auto-summary\n` +
|
||||||
|
`• /tts audio <text> — Generate audio from text\n\n` +
|
||||||
|
`**Providers:**\n` +
|
||||||
|
`• edge — Free, fast (default)\n` +
|
||||||
|
`• openai — High quality (requires API key)\n` +
|
||||||
|
`• elevenlabs — Premium voices (requires API key)\n\n` +
|
||||||
|
`**Text Limit (default: 1500, max: 4096):**\n` +
|
||||||
|
`When text exceeds the limit:\n` +
|
||||||
|
`• Summary ON: AI summarizes, then generates audio\n` +
|
||||||
|
`• Summary OFF: Truncates text, then generates audio\n\n` +
|
||||||
|
`**Examples:**\n` +
|
||||||
|
`/tts provider edge\n` +
|
||||||
|
`/tts limit 2000\n` +
|
||||||
|
`/tts audio Hello, this is a test!`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,35 +83,27 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
|
|||||||
return { shouldContinue: false, reply: ttsUsage() };
|
return { shouldContinue: false, reply: ttsUsage() };
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestedAuto = normalizeTtsAutoMode(
|
if (action === "on") {
|
||||||
action === "on" ? "always" : action === "off" ? "off" : action,
|
setTtsEnabled(prefsPath, true);
|
||||||
);
|
return { shouldContinue: false, reply: { text: "🔊 TTS enabled." } };
|
||||||
if (requestedAuto) {
|
}
|
||||||
const entry = params.sessionEntry;
|
|
||||||
const sessionKey = params.sessionKey;
|
if (action === "off") {
|
||||||
const store = params.sessionStore;
|
setTtsEnabled(prefsPath, false);
|
||||||
if (entry && store && sessionKey) {
|
return { shouldContinue: false, reply: { text: "🔇 TTS disabled." } };
|
||||||
entry.ttsAuto = requestedAuto;
|
|
||||||
entry.updatedAt = Date.now();
|
|
||||||
store[sessionKey] = entry;
|
|
||||||
if (params.storePath) {
|
|
||||||
await updateSessionStore(params.storePath, (store) => {
|
|
||||||
store[sessionKey] = entry;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const label = requestedAuto === "always" ? "enabled (always)" : requestedAuto;
|
|
||||||
return {
|
|
||||||
shouldContinue: false,
|
|
||||||
reply: {
|
|
||||||
text: requestedAuto === "off" ? "🔇 TTS disabled." : `🔊 TTS ${label}.`,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === "audio") {
|
if (action === "audio") {
|
||||||
if (!args.trim()) {
|
if (!args.trim()) {
|
||||||
return { shouldContinue: false, reply: ttsUsage() };
|
return {
|
||||||
|
shouldContinue: false,
|
||||||
|
reply: {
|
||||||
|
text:
|
||||||
|
`🎤 Generate audio from text.\n\n` +
|
||||||
|
`Usage: /tts audio <text>\n` +
|
||||||
|
`Example: /tts audio Hello, this is a test!`,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
@@ -146,9 +149,6 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
|
|||||||
if (action === "provider") {
|
if (action === "provider") {
|
||||||
const currentProvider = getTtsProvider(config, prefsPath);
|
const currentProvider = getTtsProvider(config, prefsPath);
|
||||||
if (!args.trim()) {
|
if (!args.trim()) {
|
||||||
const fallback = resolveTtsProviderOrder(currentProvider)
|
|
||||||
.slice(1)
|
|
||||||
.filter((provider) => isTtsProviderConfigured(config, provider));
|
|
||||||
const hasOpenAI = Boolean(resolveTtsApiKey(config, "openai"));
|
const hasOpenAI = Boolean(resolveTtsApiKey(config, "openai"));
|
||||||
const hasElevenLabs = Boolean(resolveTtsApiKey(config, "elevenlabs"));
|
const hasElevenLabs = Boolean(resolveTtsApiKey(config, "elevenlabs"));
|
||||||
const hasEdge = isTtsProviderConfigured(config, "edge");
|
const hasEdge = isTtsProviderConfigured(config, "edge");
|
||||||
@@ -158,7 +158,6 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
|
|||||||
text:
|
text:
|
||||||
`🎙️ TTS provider\n` +
|
`🎙️ TTS provider\n` +
|
||||||
`Primary: ${currentProvider}\n` +
|
`Primary: ${currentProvider}\n` +
|
||||||
`Fallbacks: ${fallback.join(", ") || "none"}\n` +
|
|
||||||
`OpenAI key: ${hasOpenAI ? "✅" : "❌"}\n` +
|
`OpenAI key: ${hasOpenAI ? "✅" : "❌"}\n` +
|
||||||
`ElevenLabs key: ${hasElevenLabs ? "✅" : "❌"}\n` +
|
`ElevenLabs key: ${hasElevenLabs ? "✅" : "❌"}\n` +
|
||||||
`Edge enabled: ${hasEdge ? "✅" : "❌"}\n` +
|
`Edge enabled: ${hasEdge ? "✅" : "❌"}\n` +
|
||||||
@@ -173,18 +172,9 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
|
|||||||
}
|
}
|
||||||
|
|
||||||
setTtsProvider(prefsPath, requested);
|
setTtsProvider(prefsPath, requested);
|
||||||
const fallback = resolveTtsProviderOrder(requested)
|
|
||||||
.slice(1)
|
|
||||||
.filter((provider) => isTtsProviderConfigured(config, provider));
|
|
||||||
return {
|
return {
|
||||||
shouldContinue: false,
|
shouldContinue: false,
|
||||||
reply: {
|
reply: { text: `✅ TTS provider set to ${requested}.` },
|
||||||
text:
|
|
||||||
`✅ TTS provider set to ${requested} (fallbacks: ${fallback.join(", ") || "none"}).` +
|
|
||||||
(requested === "edge"
|
|
||||||
? "\nEnable Edge TTS in config: messages.tts.edge.enabled = true."
|
|
||||||
: ""),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,12 +183,22 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
|
|||||||
const currentLimit = getTtsMaxLength(prefsPath);
|
const currentLimit = getTtsMaxLength(prefsPath);
|
||||||
return {
|
return {
|
||||||
shouldContinue: false,
|
shouldContinue: false,
|
||||||
reply: { text: `📏 TTS limit: ${currentLimit} characters.` },
|
reply: {
|
||||||
|
text:
|
||||||
|
`📏 TTS limit: ${currentLimit} characters.\n\n` +
|
||||||
|
`Text longer than this triggers summary (if enabled).\n` +
|
||||||
|
`Range: 100-4096 chars (Telegram max).\n\n` +
|
||||||
|
`To change: /tts limit <number>\n` +
|
||||||
|
`Example: /tts limit 2000`,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const next = Number.parseInt(args.trim(), 10);
|
const next = Number.parseInt(args.trim(), 10);
|
||||||
if (!Number.isFinite(next) || next < 100 || next > 10_000) {
|
if (!Number.isFinite(next) || next < 100 || next > 4096) {
|
||||||
return { shouldContinue: false, reply: ttsUsage() };
|
return {
|
||||||
|
shouldContinue: false,
|
||||||
|
reply: { text: "❌ Limit must be between 100 and 4096 characters." },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
setTtsMaxLength(prefsPath, next);
|
setTtsMaxLength(prefsPath, next);
|
||||||
return {
|
return {
|
||||||
@@ -210,9 +210,17 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
|
|||||||
if (action === "summary") {
|
if (action === "summary") {
|
||||||
if (!args.trim()) {
|
if (!args.trim()) {
|
||||||
const enabled = isSummarizationEnabled(prefsPath);
|
const enabled = isSummarizationEnabled(prefsPath);
|
||||||
|
const maxLen = getTtsMaxLength(prefsPath);
|
||||||
return {
|
return {
|
||||||
shouldContinue: false,
|
shouldContinue: false,
|
||||||
reply: { text: `📝 TTS auto-summary: ${enabled ? "on" : "off"}.` },
|
reply: {
|
||||||
|
text:
|
||||||
|
`📝 TTS auto-summary: ${enabled ? "on" : "off"}.\n\n` +
|
||||||
|
`When text exceeds ${maxLen} chars:\n` +
|
||||||
|
`• ON: summarizes text, then generates audio\n` +
|
||||||
|
`• OFF: truncates text, then generates audio\n\n` +
|
||||||
|
`To change: /tts summary on | off`,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const requested = args.trim().toLowerCase();
|
const requested = args.trim().toLowerCase();
|
||||||
@@ -229,27 +237,16 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action === "status") {
|
if (action === "status") {
|
||||||
const sessionAuto = params.sessionEntry?.ttsAuto;
|
const enabled = isTtsEnabled(config, prefsPath);
|
||||||
const autoMode = resolveTtsAutoMode({ config, prefsPath, sessionAuto });
|
|
||||||
const enabled = autoMode !== "off";
|
|
||||||
const provider = getTtsProvider(config, prefsPath);
|
const provider = getTtsProvider(config, prefsPath);
|
||||||
const hasKey = isTtsProviderConfigured(config, provider);
|
const hasKey = isTtsProviderConfigured(config, provider);
|
||||||
const providerStatus =
|
|
||||||
provider === "edge"
|
|
||||||
? hasKey
|
|
||||||
? "✅ enabled"
|
|
||||||
: "❌ disabled"
|
|
||||||
: hasKey
|
|
||||||
? "✅ key"
|
|
||||||
: "❌ no key";
|
|
||||||
const maxLength = getTtsMaxLength(prefsPath);
|
const maxLength = getTtsMaxLength(prefsPath);
|
||||||
const summarize = isSummarizationEnabled(prefsPath);
|
const summarize = isSummarizationEnabled(prefsPath);
|
||||||
const last = getLastTtsAttempt();
|
const last = getLastTtsAttempt();
|
||||||
const autoLabel = sessionAuto ? `${autoMode} (session)` : autoMode;
|
|
||||||
const lines = [
|
const lines = [
|
||||||
"📊 TTS status",
|
"📊 TTS status",
|
||||||
`Auto: ${enabled ? autoLabel : "off"}`,
|
`State: ${enabled ? "✅ enabled" : "❌ disabled"}`,
|
||||||
`Provider: ${provider} (${providerStatus})`,
|
`Provider: ${provider} (${hasKey ? "✅ configured" : "❌ not configured"})`,
|
||||||
`Text limit: ${maxLength} chars`,
|
`Text limit: ${maxLength} chars`,
|
||||||
`Auto-summary: ${summarize ? "on" : "off"}`,
|
`Auto-summary: ${summarize ? "on" : "off"}`,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -420,3 +420,17 @@ describe("handleCommands subagents", () => {
|
|||||||
expect(result.reply?.text).toContain("Status: done");
|
expect(result.reply?.text).toContain("Status: done");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("handleCommands /tts", () => {
|
||||||
|
it("returns status for bare /tts on text command surfaces", async () => {
|
||||||
|
const cfg = {
|
||||||
|
commands: { text: true },
|
||||||
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||||
|
messages: { tts: { prefsPath: path.join(testWorkspaceDir, "tts.json") } },
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
const params = buildParams("/tts", cfg);
|
||||||
|
const result = await handleCommands(params);
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(result.reply?.text).toContain("TTS status");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,7 +16,7 @@ import { formatAbortReplyText, tryFastAbortFromMessage } from "./abort.js";
|
|||||||
import { shouldSkipDuplicateInbound } from "./inbound-dedupe.js";
|
import { shouldSkipDuplicateInbound } from "./inbound-dedupe.js";
|
||||||
import type { ReplyDispatcher, ReplyDispatchKind } from "./reply-dispatcher.js";
|
import type { ReplyDispatcher, ReplyDispatchKind } from "./reply-dispatcher.js";
|
||||||
import { isRoutableChannel, routeReply } from "./route-reply.js";
|
import { isRoutableChannel, routeReply } from "./route-reply.js";
|
||||||
import { maybeApplyTtsToPayload, normalizeTtsAutoMode } from "../../tts/tts.js";
|
import { maybeApplyTtsToPayload, normalizeTtsAutoMode, resolveTtsConfig } from "../../tts/tts.js";
|
||||||
|
|
||||||
const AUDIO_PLACEHOLDER_RE = /^<media:audio>(\s*\([^)]*\))?$/i;
|
const AUDIO_PLACEHOLDER_RE = /^<media:audio>(\s*\([^)]*\))?$/i;
|
||||||
const AUDIO_HEADER_RE = /^\[Audio\b/i;
|
const AUDIO_HEADER_RE = /^\[Audio\b/i;
|
||||||
@@ -266,12 +266,26 @@ export async function dispatchReplyFromConfig(params: {
|
|||||||
return { queuedFinal, counts };
|
return { queuedFinal, counts };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track accumulated block text for TTS generation after streaming completes.
|
||||||
|
// When block streaming succeeds, there's no final reply, so we need to generate
|
||||||
|
// TTS audio separately from the accumulated block content.
|
||||||
|
let accumulatedBlockText = "";
|
||||||
|
let blockCount = 0;
|
||||||
|
|
||||||
const replyResult = await (params.replyResolver ?? getReplyFromConfig)(
|
const replyResult = await (params.replyResolver ?? getReplyFromConfig)(
|
||||||
ctx,
|
ctx,
|
||||||
{
|
{
|
||||||
...params.replyOptions,
|
...params.replyOptions,
|
||||||
onBlockReply: (payload: ReplyPayload, context) => {
|
onBlockReply: (payload: ReplyPayload, context) => {
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
|
// Accumulate block text for TTS generation after streaming
|
||||||
|
if (payload.text) {
|
||||||
|
if (accumulatedBlockText.length > 0) {
|
||||||
|
accumulatedBlockText += "\n";
|
||||||
|
}
|
||||||
|
accumulatedBlockText += payload.text;
|
||||||
|
blockCount++;
|
||||||
|
}
|
||||||
const ttsPayload = await maybeApplyTtsToPayload({
|
const ttsPayload = await maybeApplyTtsToPayload({
|
||||||
payload,
|
payload,
|
||||||
cfg,
|
cfg,
|
||||||
@@ -327,6 +341,62 @@ export async function dispatchReplyFromConfig(params: {
|
|||||||
queuedFinal = dispatcher.sendFinalReply(ttsReply) || queuedFinal;
|
queuedFinal = dispatcher.sendFinalReply(ttsReply) || queuedFinal;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ttsMode = resolveTtsConfig(cfg).mode ?? "final";
|
||||||
|
// Generate TTS-only reply after block streaming completes (when there's no final reply).
|
||||||
|
// This handles the case where block streaming succeeds and drops final payloads,
|
||||||
|
// but we still want TTS audio to be generated from the accumulated block content.
|
||||||
|
if (
|
||||||
|
ttsMode === "final" &&
|
||||||
|
replies.length === 0 &&
|
||||||
|
blockCount > 0 &&
|
||||||
|
accumulatedBlockText.trim()
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const ttsSyntheticReply = await maybeApplyTtsToPayload({
|
||||||
|
payload: { text: accumulatedBlockText },
|
||||||
|
cfg,
|
||||||
|
channel: ttsChannel,
|
||||||
|
kind: "final",
|
||||||
|
inboundAudio,
|
||||||
|
ttsAuto: sessionTtsAuto,
|
||||||
|
});
|
||||||
|
// Only send if TTS was actually applied (mediaUrl exists)
|
||||||
|
if (ttsSyntheticReply.mediaUrl) {
|
||||||
|
// Send TTS-only payload (no text, just audio) so it doesn't duplicate the block content
|
||||||
|
const ttsOnlyPayload: ReplyPayload = {
|
||||||
|
mediaUrl: ttsSyntheticReply.mediaUrl,
|
||||||
|
audioAsVoice: ttsSyntheticReply.audioAsVoice,
|
||||||
|
};
|
||||||
|
if (shouldRouteToOriginating && originatingChannel && originatingTo) {
|
||||||
|
const result = await routeReply({
|
||||||
|
payload: ttsOnlyPayload,
|
||||||
|
channel: originatingChannel,
|
||||||
|
to: originatingTo,
|
||||||
|
sessionKey: ctx.SessionKey,
|
||||||
|
accountId: ctx.AccountId,
|
||||||
|
threadId: ctx.MessageThreadId,
|
||||||
|
cfg,
|
||||||
|
});
|
||||||
|
queuedFinal = result.ok || queuedFinal;
|
||||||
|
if (result.ok) routedFinalCount += 1;
|
||||||
|
if (!result.ok) {
|
||||||
|
logVerbose(
|
||||||
|
`dispatch-from-config: route-reply (tts-only) failed: ${result.error ?? "unknown error"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const didQueue = dispatcher.sendFinalReply(ttsOnlyPayload);
|
||||||
|
queuedFinal = didQueue || queuedFinal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logVerbose(
|
||||||
|
`dispatch-from-config: accumulated block TTS failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await dispatcher.waitForIdle();
|
await dispatcher.waitForIdle();
|
||||||
|
|
||||||
const counts = dispatcher.getQueuedCounts();
|
const counts = dispatcher.getQueuedCounts();
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ export function createFollowupRunner(params: {
|
|||||||
cfg: queued.run.config,
|
cfg: queued.run.config,
|
||||||
provider: queued.run.provider,
|
provider: queued.run.provider,
|
||||||
model: queued.run.model,
|
model: queued.run.model,
|
||||||
|
agentDir: queued.run.agentDir,
|
||||||
fallbacksOverride: resolveAgentModelFallbacksOverride(
|
fallbacksOverride: resolveAgentModelFallbacksOverride(
|
||||||
queued.run.config,
|
queued.run.config,
|
||||||
resolveAgentIdFromSessionKey(queued.run.sessionKey),
|
resolveAgentIdFromSessionKey(queued.run.sessionKey),
|
||||||
@@ -146,6 +147,10 @@ export function createFollowupRunner(params: {
|
|||||||
groupId: queued.run.groupId,
|
groupId: queued.run.groupId,
|
||||||
groupChannel: queued.run.groupChannel,
|
groupChannel: queued.run.groupChannel,
|
||||||
groupSpace: queued.run.groupSpace,
|
groupSpace: queued.run.groupSpace,
|
||||||
|
senderId: queued.run.senderId,
|
||||||
|
senderName: queued.run.senderName,
|
||||||
|
senderUsername: queued.run.senderUsername,
|
||||||
|
senderE164: queued.run.senderE164,
|
||||||
sessionFile: queued.run.sessionFile,
|
sessionFile: queued.run.sessionFile,
|
||||||
workspaceDir: queued.run.workspaceDir,
|
workspaceDir: queued.run.workspaceDir,
|
||||||
config: queued.run.config,
|
config: queued.run.config,
|
||||||
|
|||||||
@@ -370,6 +370,10 @@ export async function runPreparedReply(
|
|||||||
groupId: resolveGroupSessionKey(sessionCtx)?.id ?? undefined,
|
groupId: resolveGroupSessionKey(sessionCtx)?.id ?? undefined,
|
||||||
groupChannel: sessionCtx.GroupChannel?.trim() ?? sessionCtx.GroupSubject?.trim(),
|
groupChannel: sessionCtx.GroupChannel?.trim() ?? sessionCtx.GroupSubject?.trim(),
|
||||||
groupSpace: sessionCtx.GroupSpace?.trim() ?? undefined,
|
groupSpace: sessionCtx.GroupSpace?.trim() ?? undefined,
|
||||||
|
senderId: sessionCtx.SenderId?.trim() || undefined,
|
||||||
|
senderName: sessionCtx.SenderName?.trim() || undefined,
|
||||||
|
senderUsername: sessionCtx.SenderUsername?.trim() || undefined,
|
||||||
|
senderE164: sessionCtx.SenderE164?.trim() || undefined,
|
||||||
sessionFile,
|
sessionFile,
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ export type FollowupRun = {
|
|||||||
groupId?: string;
|
groupId?: string;
|
||||||
groupChannel?: string;
|
groupChannel?: string;
|
||||||
groupSpace?: string;
|
groupSpace?: string;
|
||||||
|
senderId?: string;
|
||||||
|
senderName?: string;
|
||||||
|
senderUsername?: string;
|
||||||
|
senderE164?: string;
|
||||||
sessionFile: string;
|
sessionFile: string;
|
||||||
workspaceDir: string;
|
workspaceDir: string;
|
||||||
config: ClawdbotConfig;
|
config: ClawdbotConfig;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ChannelId } from "../channels/plugins/types.js";
|
import type { ChannelId } from "../channels/plugins/types.js";
|
||||||
|
import type { StickerMetadata } from "../telegram/bot/types.js";
|
||||||
import type { InternalMessageChannel } from "../utils/message-channel.js";
|
import type { InternalMessageChannel } from "../utils/message-channel.js";
|
||||||
import type { CommandArgs } from "./commands-registry.types.js";
|
import type { CommandArgs } from "./commands-registry.types.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -64,6 +65,8 @@ export type MsgContext = {
|
|||||||
MediaPaths?: string[];
|
MediaPaths?: string[];
|
||||||
MediaUrls?: string[];
|
MediaUrls?: string[];
|
||||||
MediaTypes?: string[];
|
MediaTypes?: string[];
|
||||||
|
/** Telegram sticker metadata (emoji, set name, file IDs, cached description). */
|
||||||
|
Sticker?: StickerMetadata;
|
||||||
OutputDir?: string;
|
OutputDir?: string;
|
||||||
OutputBase?: string;
|
OutputBase?: string;
|
||||||
/** Remote host for SCP when media lives on a different machine (e.g., clawdbot@192.168.64.3). */
|
/** Remote host for SCP when media lives on a different machine (e.g., clawdbot@192.168.64.3). */
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import express from "express";
|
|||||||
|
|
||||||
import type { ResolvedBrowserConfig } from "./config.js";
|
import type { ResolvedBrowserConfig } from "./config.js";
|
||||||
import { registerBrowserRoutes } from "./routes/index.js";
|
import { registerBrowserRoutes } from "./routes/index.js";
|
||||||
|
import type { BrowserRouteRegistrar } from "./routes/types.js";
|
||||||
import {
|
import {
|
||||||
type BrowserServerState,
|
type BrowserServerState,
|
||||||
createBrowserRouteContext,
|
createBrowserRouteContext,
|
||||||
@@ -50,7 +51,7 @@ export async function startBrowserBridgeServer(params: {
|
|||||||
getState: () => state,
|
getState: () => state,
|
||||||
onEnsureAttachTarget: params.onEnsureAttachTarget,
|
onEnsureAttachTarget: params.onEnsureAttachTarget,
|
||||||
});
|
});
|
||||||
registerBrowserRoutes(app, ctx);
|
registerBrowserRoutes(app as unknown as BrowserRouteRegistrar, ctx);
|
||||||
|
|
||||||
const server = await new Promise<Server>((resolve, reject) => {
|
const server = await new Promise<Server>((resolve, reject) => {
|
||||||
const s = app.listen(port, host, () => resolve(s));
|
const s = app.listen(port, host, () => resolve(s));
|
||||||
@@ -61,11 +62,9 @@ export async function startBrowserBridgeServer(params: {
|
|||||||
const resolvedPort = address?.port ?? port;
|
const resolvedPort = address?.port ?? port;
|
||||||
state.server = server;
|
state.server = server;
|
||||||
state.port = resolvedPort;
|
state.port = resolvedPort;
|
||||||
state.resolved.controlHost = host;
|
|
||||||
state.resolved.controlPort = resolvedPort;
|
state.resolved.controlPort = resolvedPort;
|
||||||
state.resolved.controlUrl = `http://${host}:${resolvedPort}`;
|
|
||||||
|
|
||||||
const baseUrl = state.resolved.controlUrl;
|
const baseUrl = `http://${host}:${resolvedPort}`;
|
||||||
return { server, port: resolvedPort, baseUrl, state };
|
return { server, port: resolvedPort, baseUrl, state };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ function buildProfileQuery(profile?: string): string {
|
|||||||
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
|
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withBaseUrl(baseUrl: string | undefined, path: string): string {
|
||||||
|
const trimmed = baseUrl?.trim();
|
||||||
|
if (!trimmed) return path;
|
||||||
|
return `${trimmed.replace(/\/$/, "")}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
export type BrowserFormField = {
|
export type BrowserFormField = {
|
||||||
ref: string;
|
ref: string;
|
||||||
type: string;
|
type: string;
|
||||||
@@ -92,11 +98,15 @@ export type BrowserDownloadPayload = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function browserNavigate(
|
export async function browserNavigate(
|
||||||
baseUrl: string,
|
baseUrl: string | undefined,
|
||||||
opts: { url: string; targetId?: string; profile?: string },
|
opts: {
|
||||||
|
url: string;
|
||||||
|
targetId?: string;
|
||||||
|
profile?: string;
|
||||||
|
},
|
||||||
): Promise<BrowserActionTabResult> {
|
): Promise<BrowserActionTabResult> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson<BrowserActionTabResult>(`${baseUrl}/navigate${q}`, {
|
return await fetchBrowserJson<BrowserActionTabResult>(withBaseUrl(baseUrl, `/navigate${q}`), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ url: opts.url, targetId: opts.targetId }),
|
body: JSON.stringify({ url: opts.url, targetId: opts.targetId }),
|
||||||
@@ -105,7 +115,7 @@ export async function browserNavigate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserArmDialog(
|
export async function browserArmDialog(
|
||||||
baseUrl: string,
|
baseUrl: string | undefined,
|
||||||
opts: {
|
opts: {
|
||||||
accept: boolean;
|
accept: boolean;
|
||||||
promptText?: string;
|
promptText?: string;
|
||||||
@@ -115,7 +125,7 @@ export async function browserArmDialog(
|
|||||||
},
|
},
|
||||||
): Promise<BrowserActionOk> {
|
): Promise<BrowserActionOk> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson<BrowserActionOk>(`${baseUrl}/hooks/dialog${q}`, {
|
return await fetchBrowserJson<BrowserActionOk>(withBaseUrl(baseUrl, `/hooks/dialog${q}`), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -129,7 +139,7 @@ export async function browserArmDialog(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserArmFileChooser(
|
export async function browserArmFileChooser(
|
||||||
baseUrl: string,
|
baseUrl: string | undefined,
|
||||||
opts: {
|
opts: {
|
||||||
paths: string[];
|
paths: string[];
|
||||||
ref?: string;
|
ref?: string;
|
||||||
@@ -141,7 +151,7 @@ export async function browserArmFileChooser(
|
|||||||
},
|
},
|
||||||
): Promise<BrowserActionOk> {
|
): Promise<BrowserActionOk> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson<BrowserActionOk>(`${baseUrl}/hooks/file-chooser${q}`, {
|
return await fetchBrowserJson<BrowserActionOk>(withBaseUrl(baseUrl, `/hooks/file-chooser${q}`), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -157,7 +167,7 @@ export async function browserArmFileChooser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserWaitForDownload(
|
export async function browserWaitForDownload(
|
||||||
baseUrl: string,
|
baseUrl: string | undefined,
|
||||||
opts: {
|
opts: {
|
||||||
path?: string;
|
path?: string;
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
@@ -170,7 +180,7 @@ export async function browserWaitForDownload(
|
|||||||
ok: true;
|
ok: true;
|
||||||
targetId: string;
|
targetId: string;
|
||||||
download: BrowserDownloadPayload;
|
download: BrowserDownloadPayload;
|
||||||
}>(`${baseUrl}/wait/download${q}`, {
|
}>(withBaseUrl(baseUrl, `/wait/download${q}`), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -183,7 +193,7 @@ export async function browserWaitForDownload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserDownload(
|
export async function browserDownload(
|
||||||
baseUrl: string,
|
baseUrl: string | undefined,
|
||||||
opts: {
|
opts: {
|
||||||
ref: string;
|
ref: string;
|
||||||
path: string;
|
path: string;
|
||||||
@@ -197,7 +207,7 @@ export async function browserDownload(
|
|||||||
ok: true;
|
ok: true;
|
||||||
targetId: string;
|
targetId: string;
|
||||||
download: BrowserDownloadPayload;
|
download: BrowserDownloadPayload;
|
||||||
}>(`${baseUrl}/download${q}`, {
|
}>(withBaseUrl(baseUrl, `/download${q}`), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -211,12 +221,12 @@ export async function browserDownload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserAct(
|
export async function browserAct(
|
||||||
baseUrl: string,
|
baseUrl: string | undefined,
|
||||||
req: BrowserActRequest,
|
req: BrowserActRequest,
|
||||||
opts?: { profile?: string },
|
opts?: { profile?: string },
|
||||||
): Promise<BrowserActResponse> {
|
): Promise<BrowserActResponse> {
|
||||||
const q = buildProfileQuery(opts?.profile);
|
const q = buildProfileQuery(opts?.profile);
|
||||||
return await fetchBrowserJson<BrowserActResponse>(`${baseUrl}/act${q}`, {
|
return await fetchBrowserJson<BrowserActResponse>(withBaseUrl(baseUrl, `/act${q}`), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(req),
|
body: JSON.stringify(req),
|
||||||
@@ -225,7 +235,7 @@ export async function browserAct(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserScreenshotAction(
|
export async function browserScreenshotAction(
|
||||||
baseUrl: string,
|
baseUrl: string | undefined,
|
||||||
opts: {
|
opts: {
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
fullPage?: boolean;
|
fullPage?: boolean;
|
||||||
@@ -236,7 +246,7 @@ export async function browserScreenshotAction(
|
|||||||
},
|
},
|
||||||
): Promise<BrowserActionPathResult> {
|
): Promise<BrowserActionPathResult> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson<BrowserActionPathResult>(`${baseUrl}/screenshot${q}`, {
|
return await fetchBrowserJson<BrowserActionPathResult>(withBaseUrl(baseUrl, `/screenshot${q}`), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
@@ -10,8 +10,14 @@ function buildProfileQuery(profile?: string): string {
|
|||||||
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
|
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withBaseUrl(baseUrl: string | undefined, path: string): string {
|
||||||
|
const trimmed = baseUrl?.trim();
|
||||||
|
if (!trimmed) return path;
|
||||||
|
return `${trimmed.replace(/\/$/, "")}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function browserConsoleMessages(
|
export async function browserConsoleMessages(
|
||||||
baseUrl: string,
|
baseUrl: string | undefined,
|
||||||
opts: { level?: string; targetId?: string; profile?: string } = {},
|
opts: { level?: string; targetId?: string; profile?: string } = {},
|
||||||
): Promise<{ ok: true; messages: BrowserConsoleMessage[]; targetId: string }> {
|
): Promise<{ ok: true; messages: BrowserConsoleMessage[]; targetId: string }> {
|
||||||
const q = new URLSearchParams();
|
const q = new URLSearchParams();
|
||||||
@@ -23,15 +29,15 @@ export async function browserConsoleMessages(
|
|||||||
ok: true;
|
ok: true;
|
||||||
messages: BrowserConsoleMessage[];
|
messages: BrowserConsoleMessage[];
|
||||||
targetId: string;
|
targetId: string;
|
||||||
}>(`${baseUrl}/console${suffix}`, { timeoutMs: 20000 });
|
}>(withBaseUrl(baseUrl, `/console${suffix}`), { timeoutMs: 20000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function browserPdfSave(
|
export async function browserPdfSave(
|
||||||
baseUrl: string,
|
baseUrl: string | undefined,
|
||||||
opts: { targetId?: string; profile?: string } = {},
|
opts: { targetId?: string; profile?: string } = {},
|
||||||
): Promise<BrowserActionPathResult> {
|
): Promise<BrowserActionPathResult> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson<BrowserActionPathResult>(`${baseUrl}/pdf${q}`, {
|
return await fetchBrowserJson<BrowserActionPathResult>(withBaseUrl(baseUrl, `/pdf${q}`), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ targetId: opts.targetId }),
|
body: JSON.stringify({ targetId: opts.targetId }),
|
||||||
@@ -40,7 +46,7 @@ export async function browserPdfSave(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserPageErrors(
|
export async function browserPageErrors(
|
||||||
baseUrl: string,
|
baseUrl: string | undefined,
|
||||||
opts: { targetId?: string; clear?: boolean; profile?: string } = {},
|
opts: { targetId?: string; clear?: boolean; profile?: string } = {},
|
||||||
): Promise<{ ok: true; targetId: string; errors: BrowserPageError[] }> {
|
): Promise<{ ok: true; targetId: string; errors: BrowserPageError[] }> {
|
||||||
const q = new URLSearchParams();
|
const q = new URLSearchParams();
|
||||||
@@ -52,11 +58,11 @@ export async function browserPageErrors(
|
|||||||
ok: true;
|
ok: true;
|
||||||
targetId: string;
|
targetId: string;
|
||||||
errors: BrowserPageError[];
|
errors: BrowserPageError[];
|
||||||
}>(`${baseUrl}/errors${suffix}`, { timeoutMs: 20000 });
|
}>(withBaseUrl(baseUrl, `/errors${suffix}`), { timeoutMs: 20000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function browserRequests(
|
export async function browserRequests(
|
||||||
baseUrl: string,
|
baseUrl: string | undefined,
|
||||||
opts: {
|
opts: {
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
filter?: string;
|
filter?: string;
|
||||||
@@ -74,11 +80,11 @@ export async function browserRequests(
|
|||||||
ok: true;
|
ok: true;
|
||||||
targetId: string;
|
targetId: string;
|
||||||
requests: BrowserNetworkRequest[];
|
requests: BrowserNetworkRequest[];
|
||||||
}>(`${baseUrl}/requests${suffix}`, { timeoutMs: 20000 });
|
}>(withBaseUrl(baseUrl, `/requests${suffix}`), { timeoutMs: 20000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function browserTraceStart(
|
export async function browserTraceStart(
|
||||||
baseUrl: string,
|
baseUrl: string | undefined,
|
||||||
opts: {
|
opts: {
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
screenshots?: boolean;
|
screenshots?: boolean;
|
||||||
@@ -88,7 +94,7 @@ export async function browserTraceStart(
|
|||||||
} = {},
|
} = {},
|
||||||
): Promise<BrowserActionTargetOk> {
|
): Promise<BrowserActionTargetOk> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/trace/start${q}`, {
|
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/trace/start${q}`), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -102,11 +108,11 @@ export async function browserTraceStart(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserTraceStop(
|
export async function browserTraceStop(
|
||||||
baseUrl: string,
|
baseUrl: string | undefined,
|
||||||
opts: { targetId?: string; path?: string; profile?: string } = {},
|
opts: { targetId?: string; path?: string; profile?: string } = {},
|
||||||
): Promise<BrowserActionPathResult> {
|
): Promise<BrowserActionPathResult> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson<BrowserActionPathResult>(`${baseUrl}/trace/stop${q}`, {
|
return await fetchBrowserJson<BrowserActionPathResult>(withBaseUrl(baseUrl, `/trace/stop${q}`), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ targetId: opts.targetId, path: opts.path }),
|
body: JSON.stringify({ targetId: opts.targetId, path: opts.path }),
|
||||||
@@ -115,11 +121,11 @@ export async function browserTraceStop(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserHighlight(
|
export async function browserHighlight(
|
||||||
baseUrl: string,
|
baseUrl: string | undefined,
|
||||||
opts: { ref: string; targetId?: string; profile?: string },
|
opts: { ref: string; targetId?: string; profile?: string },
|
||||||
): Promise<BrowserActionTargetOk> {
|
): Promise<BrowserActionTargetOk> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/highlight${q}`, {
|
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/highlight${q}`), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ targetId: opts.targetId, ref: opts.ref }),
|
body: JSON.stringify({ targetId: opts.targetId, ref: opts.ref }),
|
||||||
@@ -128,7 +134,7 @@ export async function browserHighlight(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserResponseBody(
|
export async function browserResponseBody(
|
||||||
baseUrl: string,
|
baseUrl: string | undefined,
|
||||||
opts: {
|
opts: {
|
||||||
url: string;
|
url: string;
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
@@ -158,7 +164,7 @@ export async function browserResponseBody(
|
|||||||
body: string;
|
body: string;
|
||||||
truncated?: boolean;
|
truncated?: boolean;
|
||||||
};
|
};
|
||||||
}>(`${baseUrl}/response/body${q}`, {
|
}>(withBaseUrl(baseUrl, `/response/body${q}`), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
@@ -5,8 +5,14 @@ function buildProfileQuery(profile?: string): string {
|
|||||||
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
|
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withBaseUrl(baseUrl: string | undefined, path: string): string {
|
||||||
|
const trimmed = baseUrl?.trim();
|
||||||
|
if (!trimmed) return path;
|
||||||
|
return `${trimmed.replace(/\/$/, "")}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function browserCookies(
|
export async function browserCookies(
|
||||||
baseUrl: string,
|
baseUrl: string | undefined,
|
||||||
opts: { targetId?: string; profile?: string } = {},
|
opts: { targetId?: string; profile?: string } = {},
|
||||||
): Promise<{ ok: true; targetId: string; cookies: unknown[] }> {
|
): Promise<{ ok: true; targetId: string; cookies: unknown[] }> {
|
||||||
const q = new URLSearchParams();
|
const q = new URLSearchParams();
|
||||||
@@ -17,11 +23,11 @@ export async function browserCookies(
|
|||||||
ok: true;
|
ok: true;
|
||||||
targetId: string;
|
targetId: string;
|
||||||
cookies: unknown[];
|
cookies: unknown[];
|
||||||
}>(`${baseUrl}/cookies${suffix}`, { timeoutMs: 20000 });
|
}>(withBaseUrl(baseUrl, `/cookies${suffix}`), { timeoutMs: 20000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function browserCookiesSet(
|
export async function browserCookiesSet(
|
||||||
baseUrl: string,
|
baseUrl: string | undefined,
|
||||||
opts: {
|
opts: {
|
||||||
cookie: Record<string, unknown>;
|
cookie: Record<string, unknown>;
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
@@ -29,7 +35,7 @@ export async function browserCookiesSet(
|
|||||||
},
|
},
|
||||||
): Promise<BrowserActionTargetOk> {
|
): Promise<BrowserActionTargetOk> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/cookies/set${q}`, {
|
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/cookies/set${q}`), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ targetId: opts.targetId, cookie: opts.cookie }),
|
body: JSON.stringify({ targetId: opts.targetId, cookie: opts.cookie }),
|
||||||
@@ -38,11 +44,11 @@ export async function browserCookiesSet(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserCookiesClear(
|
export async function browserCookiesClear(
|
||||||
baseUrl: string,
|
baseUrl: string | undefined,
|
||||||
opts: { targetId?: string; profile?: string } = {},
|
opts: { targetId?: string; profile?: string } = {},
|
||||||
): Promise<BrowserActionTargetOk> {
|
): Promise<BrowserActionTargetOk> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/cookies/clear${q}`, {
|
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/cookies/clear${q}`), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ targetId: opts.targetId }),
|
body: JSON.stringify({ targetId: opts.targetId }),
|
||||||
@@ -51,7 +57,7 @@ export async function browserCookiesClear(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserStorageGet(
|
export async function browserStorageGet(
|
||||||
baseUrl: string,
|
baseUrl: string | undefined,
|
||||||
opts: {
|
opts: {
|
||||||
kind: "local" | "session";
|
kind: "local" | "session";
|
||||||
key?: string;
|
key?: string;
|
||||||
@@ -68,11 +74,11 @@ export async function browserStorageGet(
|
|||||||
ok: true;
|
ok: true;
|
||||||
targetId: string;
|
targetId: string;
|
||||||
values: Record<string, string>;
|
values: Record<string, string>;
|
||||||
}>(`${baseUrl}/storage/${opts.kind}${suffix}`, { timeoutMs: 20000 });
|
}>(withBaseUrl(baseUrl, `/storage/${opts.kind}${suffix}`), { timeoutMs: 20000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function browserStorageSet(
|
export async function browserStorageSet(
|
||||||
baseUrl: string,
|
baseUrl: string | undefined,
|
||||||
opts: {
|
opts: {
|
||||||
kind: "local" | "session";
|
kind: "local" | "session";
|
||||||
key: string;
|
key: string;
|
||||||
@@ -82,25 +88,28 @@ export async function browserStorageSet(
|
|||||||
},
|
},
|
||||||
): Promise<BrowserActionTargetOk> {
|
): Promise<BrowserActionTargetOk> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/storage/${opts.kind}/set${q}`, {
|
return await fetchBrowserJson<BrowserActionTargetOk>(
|
||||||
method: "POST",
|
withBaseUrl(baseUrl, `/storage/${opts.kind}/set${q}`),
|
||||||
headers: { "Content-Type": "application/json" },
|
{
|
||||||
body: JSON.stringify({
|
method: "POST",
|
||||||
targetId: opts.targetId,
|
headers: { "Content-Type": "application/json" },
|
||||||
key: opts.key,
|
body: JSON.stringify({
|
||||||
value: opts.value,
|
targetId: opts.targetId,
|
||||||
}),
|
key: opts.key,
|
||||||
timeoutMs: 20000,
|
value: opts.value,
|
||||||
});
|
}),
|
||||||
|
timeoutMs: 20000,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function browserStorageClear(
|
export async function browserStorageClear(
|
||||||
baseUrl: string,
|
baseUrl: string | undefined,
|
||||||
opts: { kind: "local" | "session"; targetId?: string; profile?: string },
|
opts: { kind: "local" | "session"; targetId?: string; profile?: string },
|
||||||
): Promise<BrowserActionTargetOk> {
|
): Promise<BrowserActionTargetOk> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson<BrowserActionTargetOk>(
|
return await fetchBrowserJson<BrowserActionTargetOk>(
|
||||||
`${baseUrl}/storage/${opts.kind}/clear${q}`,
|
withBaseUrl(baseUrl, `/storage/${opts.kind}/clear${q}`),
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -111,11 +120,11 @@ export async function browserStorageClear(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserSetOffline(
|
export async function browserSetOffline(
|
||||||
baseUrl: string,
|
baseUrl: string | undefined,
|
||||||
opts: { offline: boolean; targetId?: string; profile?: string },
|
opts: { offline: boolean; targetId?: string; profile?: string },
|
||||||
): Promise<BrowserActionTargetOk> {
|
): Promise<BrowserActionTargetOk> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/offline${q}`, {
|
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/offline${q}`), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ targetId: opts.targetId, offline: opts.offline }),
|
body: JSON.stringify({ targetId: opts.targetId, offline: opts.offline }),
|
||||||
@@ -124,7 +133,7 @@ export async function browserSetOffline(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserSetHeaders(
|
export async function browserSetHeaders(
|
||||||
baseUrl: string,
|
baseUrl: string | undefined,
|
||||||
opts: {
|
opts: {
|
||||||
headers: Record<string, string>;
|
headers: Record<string, string>;
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
@@ -132,7 +141,7 @@ export async function browserSetHeaders(
|
|||||||
},
|
},
|
||||||
): Promise<BrowserActionTargetOk> {
|
): Promise<BrowserActionTargetOk> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/headers${q}`, {
|
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/headers${q}`), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ targetId: opts.targetId, headers: opts.headers }),
|
body: JSON.stringify({ targetId: opts.targetId, headers: opts.headers }),
|
||||||
@@ -141,7 +150,7 @@ export async function browserSetHeaders(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserSetHttpCredentials(
|
export async function browserSetHttpCredentials(
|
||||||
baseUrl: string,
|
baseUrl: string | undefined,
|
||||||
opts: {
|
opts: {
|
||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
@@ -151,21 +160,24 @@ export async function browserSetHttpCredentials(
|
|||||||
} = {},
|
} = {},
|
||||||
): Promise<BrowserActionTargetOk> {
|
): Promise<BrowserActionTargetOk> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/credentials${q}`, {
|
return await fetchBrowserJson<BrowserActionTargetOk>(
|
||||||
method: "POST",
|
withBaseUrl(baseUrl, `/set/credentials${q}`),
|
||||||
headers: { "Content-Type": "application/json" },
|
{
|
||||||
body: JSON.stringify({
|
method: "POST",
|
||||||
targetId: opts.targetId,
|
headers: { "Content-Type": "application/json" },
|
||||||
username: opts.username,
|
body: JSON.stringify({
|
||||||
password: opts.password,
|
targetId: opts.targetId,
|
||||||
clear: opts.clear,
|
username: opts.username,
|
||||||
}),
|
password: opts.password,
|
||||||
timeoutMs: 20000,
|
clear: opts.clear,
|
||||||
});
|
}),
|
||||||
|
timeoutMs: 20000,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function browserSetGeolocation(
|
export async function browserSetGeolocation(
|
||||||
baseUrl: string,
|
baseUrl: string | undefined,
|
||||||
opts: {
|
opts: {
|
||||||
latitude?: number;
|
latitude?: number;
|
||||||
longitude?: number;
|
longitude?: number;
|
||||||
@@ -177,23 +189,26 @@ export async function browserSetGeolocation(
|
|||||||
} = {},
|
} = {},
|
||||||
): Promise<BrowserActionTargetOk> {
|
): Promise<BrowserActionTargetOk> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/geolocation${q}`, {
|
return await fetchBrowserJson<BrowserActionTargetOk>(
|
||||||
method: "POST",
|
withBaseUrl(baseUrl, `/set/geolocation${q}`),
|
||||||
headers: { "Content-Type": "application/json" },
|
{
|
||||||
body: JSON.stringify({
|
method: "POST",
|
||||||
targetId: opts.targetId,
|
headers: { "Content-Type": "application/json" },
|
||||||
latitude: opts.latitude,
|
body: JSON.stringify({
|
||||||
longitude: opts.longitude,
|
targetId: opts.targetId,
|
||||||
accuracy: opts.accuracy,
|
latitude: opts.latitude,
|
||||||
origin: opts.origin,
|
longitude: opts.longitude,
|
||||||
clear: opts.clear,
|
accuracy: opts.accuracy,
|
||||||
}),
|
origin: opts.origin,
|
||||||
timeoutMs: 20000,
|
clear: opts.clear,
|
||||||
});
|
}),
|
||||||
|
timeoutMs: 20000,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function browserSetMedia(
|
export async function browserSetMedia(
|
||||||
baseUrl: string,
|
baseUrl: string | undefined,
|
||||||
opts: {
|
opts: {
|
||||||
colorScheme: "dark" | "light" | "no-preference" | "none";
|
colorScheme: "dark" | "light" | "no-preference" | "none";
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
@@ -201,7 +216,7 @@ export async function browserSetMedia(
|
|||||||
},
|
},
|
||||||
): Promise<BrowserActionTargetOk> {
|
): Promise<BrowserActionTargetOk> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/media${q}`, {
|
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/media${q}`), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -213,11 +228,11 @@ export async function browserSetMedia(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserSetTimezone(
|
export async function browserSetTimezone(
|
||||||
baseUrl: string,
|
baseUrl: string | undefined,
|
||||||
opts: { timezoneId: string; targetId?: string; profile?: string },
|
opts: { timezoneId: string; targetId?: string; profile?: string },
|
||||||
): Promise<BrowserActionTargetOk> {
|
): Promise<BrowserActionTargetOk> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/timezone${q}`, {
|
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/timezone${q}`), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -229,11 +244,11 @@ export async function browserSetTimezone(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserSetLocale(
|
export async function browserSetLocale(
|
||||||
baseUrl: string,
|
baseUrl: string | undefined,
|
||||||
opts: { locale: string; targetId?: string; profile?: string },
|
opts: { locale: string; targetId?: string; profile?: string },
|
||||||
): Promise<BrowserActionTargetOk> {
|
): Promise<BrowserActionTargetOk> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/locale${q}`, {
|
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/locale${q}`), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ targetId: opts.targetId, locale: opts.locale }),
|
body: JSON.stringify({ targetId: opts.targetId, locale: opts.locale }),
|
||||||
@@ -242,11 +257,11 @@ export async function browserSetLocale(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserSetDevice(
|
export async function browserSetDevice(
|
||||||
baseUrl: string,
|
baseUrl: string | undefined,
|
||||||
opts: { name: string; targetId?: string; profile?: string },
|
opts: { name: string; targetId?: string; profile?: string },
|
||||||
): Promise<BrowserActionTargetOk> {
|
): Promise<BrowserActionTargetOk> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/device${q}`, {
|
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/device${q}`), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ targetId: opts.targetId, name: opts.name }),
|
body: JSON.stringify({ targetId: opts.targetId, name: opts.name }),
|
||||||
@@ -255,11 +270,11 @@ export async function browserSetDevice(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserClearPermissions(
|
export async function browserClearPermissions(
|
||||||
baseUrl: string,
|
baseUrl: string | undefined,
|
||||||
opts: { targetId?: string; profile?: string } = {},
|
opts: { targetId?: string; profile?: string } = {},
|
||||||
): Promise<BrowserActionOk> {
|
): Promise<BrowserActionOk> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson<BrowserActionOk>(`${baseUrl}/set/geolocation${q}`, {
|
return await fetchBrowserJson<BrowserActionOk>(withBaseUrl(baseUrl, `/set/geolocation${q}`), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ targetId: opts.targetId, clear: true }),
|
body: JSON.stringify({ targetId: opts.targetId, clear: true }),
|
||||||
|
|||||||
+87
-67
@@ -1,57 +1,51 @@
|
|||||||
import { extractErrorCode, formatErrorMessage } from "../infra/errors.js";
|
|
||||||
import { loadConfig } from "../config/config.js";
|
|
||||||
import { formatCliCommand } from "../cli/command-format.js";
|
import { formatCliCommand } from "../cli/command-format.js";
|
||||||
import { resolveBrowserConfig } from "./config.js";
|
import {
|
||||||
|
createBrowserControlContext,
|
||||||
|
startBrowserControlServiceFromConfig,
|
||||||
|
} from "./control-service.js";
|
||||||
|
import { createBrowserRouteDispatcher } from "./routes/dispatcher.js";
|
||||||
|
|
||||||
let cachedConfigToken: string | null | undefined = undefined;
|
function isAbsoluteHttp(url: string): boolean {
|
||||||
|
return /^https?:\/\//i.test(url.trim());
|
||||||
function getBrowserControlToken(): string | null {
|
|
||||||
const env = process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN?.trim();
|
|
||||||
if (env) return env;
|
|
||||||
|
|
||||||
if (cachedConfigToken !== undefined) return cachedConfigToken;
|
|
||||||
try {
|
|
||||||
const cfg = loadConfig();
|
|
||||||
const resolved = resolveBrowserConfig(cfg.browser);
|
|
||||||
const token = resolved.controlToken?.trim() || "";
|
|
||||||
cachedConfigToken = token ? token : null;
|
|
||||||
} catch {
|
|
||||||
cachedConfigToken = null;
|
|
||||||
}
|
|
||||||
return cachedConfigToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
function unwrapCause(err: unknown): unknown {
|
|
||||||
if (!err || typeof err !== "object") return null;
|
|
||||||
const cause = (err as { cause?: unknown }).cause;
|
|
||||||
return cause ?? null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function enhanceBrowserFetchError(url: string, err: unknown, timeoutMs: number): Error {
|
function enhanceBrowserFetchError(url: string, err: unknown, timeoutMs: number): Error {
|
||||||
const cause = unwrapCause(err);
|
const hint = isAbsoluteHttp(url)
|
||||||
const code = extractErrorCode(cause) ?? extractErrorCode(err) ?? "";
|
? "If this is a sandboxed session, ensure the sandbox browser is running and try again."
|
||||||
|
: `Start (or restart) the Clawdbot gateway (Clawdbot.app menubar, or \`${formatCliCommand("clawdbot gateway")}\`) and try again.`;
|
||||||
const hint = `Start (or restart) the Clawdbot gateway (Clawdbot.app menubar, or \`${formatCliCommand("clawdbot gateway")}\`) and try again.`;
|
const msg = String(err);
|
||||||
|
const msgLower = msg.toLowerCase();
|
||||||
if (code === "ECONNREFUSED") {
|
const looksLikeTimeout =
|
||||||
|
msgLower.includes("timed out") ||
|
||||||
|
msgLower.includes("timeout") ||
|
||||||
|
msgLower.includes("aborted") ||
|
||||||
|
msgLower.includes("abort") ||
|
||||||
|
msgLower.includes("aborterror");
|
||||||
|
if (looksLikeTimeout) {
|
||||||
return new Error(
|
return new Error(
|
||||||
`Can't reach the clawd browser control server at ${url} (connection refused). ${hint}`,
|
`Can't reach the clawd browser control service (timed out after ${timeoutMs}ms). ${hint}`,
|
||||||
);
|
|
||||||
}
|
|
||||||
if (code === "ETIMEDOUT" || code === "UND_ERR_CONNECT_TIMEOUT") {
|
|
||||||
return new Error(
|
|
||||||
`Can't reach the clawd browser control server at ${url} (timed out after ${timeoutMs}ms). ${hint}`,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return new Error(`Can't reach the clawd browser control service. ${hint} (${msg})`);
|
||||||
|
}
|
||||||
|
|
||||||
const msg = formatErrorMessage(err);
|
async function fetchHttpJson<T>(
|
||||||
if (msg.toLowerCase().includes("abort")) {
|
url: string,
|
||||||
return new Error(
|
init: RequestInit & { timeoutMs?: number },
|
||||||
`Can't reach the clawd browser control server at ${url} (timed out after ${timeoutMs}ms). ${hint}`,
|
): Promise<T> {
|
||||||
);
|
const timeoutMs = init.timeoutMs ?? 5000;
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { ...init, signal: ctrl.signal });
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new Error(text || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return (await res.json()) as T;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(t);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Error(`Can't reach the clawd browser control server at ${url}. ${hint} (${msg})`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchBrowserJson<T>(
|
export async function fetchBrowserJson<T>(
|
||||||
@@ -59,32 +53,58 @@ export async function fetchBrowserJson<T>(
|
|||||||
init?: RequestInit & { timeoutMs?: number },
|
init?: RequestInit & { timeoutMs?: number },
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const timeoutMs = init?.timeoutMs ?? 5000;
|
const timeoutMs = init?.timeoutMs ?? 5000;
|
||||||
const ctrl = new AbortController();
|
|
||||||
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
||||||
let res: Response;
|
|
||||||
try {
|
try {
|
||||||
const token = getBrowserControlToken();
|
if (isAbsoluteHttp(url)) {
|
||||||
const mergedHeaders = (() => {
|
return await fetchHttpJson<T>(url, { ...init, timeoutMs });
|
||||||
if (!token) return init?.headers;
|
}
|
||||||
const h = new Headers(init?.headers ?? {});
|
const started = await startBrowserControlServiceFromConfig();
|
||||||
if (!h.has("Authorization")) {
|
if (!started) {
|
||||||
h.set("Authorization", `Bearer ${token}`);
|
throw new Error("browser control disabled");
|
||||||
|
}
|
||||||
|
const dispatcher = createBrowserRouteDispatcher(createBrowserControlContext());
|
||||||
|
const parsed = new URL(url, "http://localhost");
|
||||||
|
const query: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of parsed.searchParams.entries()) {
|
||||||
|
query[key] = value;
|
||||||
|
}
|
||||||
|
let body = init?.body;
|
||||||
|
if (typeof body === "string") {
|
||||||
|
try {
|
||||||
|
body = JSON.parse(body);
|
||||||
|
} catch {
|
||||||
|
// keep as string
|
||||||
}
|
}
|
||||||
return h;
|
}
|
||||||
})();
|
const dispatchPromise = dispatcher.dispatch({
|
||||||
res = await fetch(url, {
|
method:
|
||||||
...init,
|
init?.method?.toUpperCase() === "DELETE"
|
||||||
...(mergedHeaders ? { headers: mergedHeaders } : {}),
|
? "DELETE"
|
||||||
signal: ctrl.signal,
|
: init?.method?.toUpperCase() === "POST"
|
||||||
} as RequestInit);
|
? "POST"
|
||||||
|
: "GET",
|
||||||
|
path: parsed.pathname,
|
||||||
|
query,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await (timeoutMs
|
||||||
|
? Promise.race([
|
||||||
|
dispatchPromise,
|
||||||
|
new Promise<never>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error("timed out")), timeoutMs),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
: dispatchPromise);
|
||||||
|
|
||||||
|
if (result.status >= 400) {
|
||||||
|
const message =
|
||||||
|
result.body && typeof result.body === "object" && "error" in result.body
|
||||||
|
? String((result.body as { error?: unknown }).error)
|
||||||
|
: `HTTP ${result.status}`;
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
return result.body as T;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw enhanceBrowserFetchError(url, err, timeoutMs);
|
throw enhanceBrowserFetchError(url, err, timeoutMs);
|
||||||
} finally {
|
|
||||||
clearTimeout(t);
|
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text().catch(() => "");
|
|
||||||
throw new Error(text ? `${res.status}: ${text}` : `HTTP ${res.status}`);
|
|
||||||
}
|
|
||||||
return (await res.json()) as T;
|
|
||||||
}
|
}
|
||||||
Loaded 100 of 229 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user